From 906fb231cc3c1e085550ecefc8c7616440134cd2 Mon Sep 17 00:00:00 2001
From: upstream-mirror
Date: Wed, 23 Sep 2026 17:39:41 +0500
Subject: [PATCH] snapshot of https://github.com/mountain-loop/yaak.git (main,
2026-09-23; blobs > 131072B omitted)
---
.claude-context.md | 83 +
.claude/rules.md | 27 +
.../release-generate-release-notes/SKILL.md | 52 +
.codex/skills/yaak-changelog/SKILL.md | 104 +
.../skills/yaak-changelog/agents/openai.yaml | 7 +
.dockerignore | 11 +
.gitattributes | 7 +
.github/FUNDING.yml | 3 +
.github/ISSUE_TEMPLATE/bug_report.md | 40 +
.github/ISSUE_TEMPLATE/config.yml | 5 +
.github/dependabot.yml | 55 +
.github/pull_request_template.md | 22 +
.github/scripts/check-contribution-policy.js | 881 +++++
.../scripts/preserve-release-artifacts.mjs | 79 +
.github/workflows/ci.yml | 37 +
.github/workflows/contribution-policy.yml | 48 +
.github/workflows/flathub.yml | 56 +
.github/workflows/release-api-npm.yml | 60 +
.github/workflows/release-app.yml | 292 ++
.github/workflows/release-cli-npm.yml | 219 ++
.github/workflows/release-web-image.yml | 123 +
.github/workflows/sponsors.yml | 45 +
.gitignore | 61 +
.node-version | 1 +
.npmrc | 2 +
.oxfmtrc.json | 8 +
.vite-hooks/post-checkout | 1 +
.vite-hooks/pre-commit | 2 +
.vscode/extensions.json | 7 +
.vscode/launch.json | 26 +
.vscode/settings.json | 8 +
AGENTS.md | 2 +
CONTRIBUTING.md | 15 +
Cargo.toml | 118 +
DEVELOPMENT.md | 105 +
Dockerfile.playground | 15 +
Dockerfile.web | 49 +
LICENSE | 21 +
README.md | 80 +
apps/yaak-client/.gitignore | 2 +
apps/yaak-client/commands/commands.tsx | 170 +
.../commands/createEnvironment.tsx | 51 +
.../commands/deleteWebsocketConnections.ts | 8 +
apps/yaak-client/commands/importCurl.ts | 52 +
apps/yaak-client/commands/moveToWorkspace.tsx | 33 +
.../commands/openFolderSettings.tsx | 17 +
apps/yaak-client/commands/openSettings.tsx | 48 +
.../commands/openWorkspaceFromSyncDir.tsx | 27 +
.../commands/openWorkspaceSettings.tsx | 10 +
apps/yaak-client/commands/switchWorkspace.tsx | 46 +
.../components/BinaryFileEditor.tsx | 63 +
apps/yaak-client/components/CargoFeature.tsx | 19 +
.../components/CloneGitRepositoryDialog.tsx | 163 +
.../yaak-client/components/ColorIndicator.tsx | 28 +
.../components/CommandPaletteDialog.tsx | 507 +++
.../components/CommercialUseBanner.tsx | 129 +
.../components/ConfirmLargeRequestBody.tsx | 74 +
.../components/ConfirmLargeResponse.tsx | 61 +
.../ConfirmLargeResponseRequest.tsx | 56 +
apps/yaak-client/components/ContextMenus.tsx | 26 +
apps/yaak-client/components/CookieDialog.tsx | 731 ++++
.../yaak-client/components/CookieDropdown.tsx | 92 +
apps/yaak-client/components/CopyButton.tsx | 33 +
.../yaak-client/components/CopyIconButton.tsx | 31 +
.../yaak-client/components/CreateDropdown.tsx | 21 +
.../components/CreateEnvironmentDialog.tsx | 68 +
.../components/CreateWorkspaceDialog.tsx | 97 +
apps/yaak-client/components/Dialogs.tsx | 41 +
.../components/DnsOverridesEditor.tsx | 190 +
apps/yaak-client/components/DropMarker.tsx | 34 +
apps/yaak-client/components/DynamicForm.tsx | 628 +++
.../yaak-client/components/EmptyStateText.tsx | 24 +
.../yaak-client/components/EncryptionHelp.tsx | 13 +
.../components/EnvironmentActionsDropdown.tsx | 85 +
.../components/EnvironmentColorIndicator.tsx | 23 +
.../components/EnvironmentColorPicker.tsx | 33 +
.../components/EnvironmentEditDialog.tsx | 444 +++
.../components/EnvironmentEditor.tsx | 175 +
.../components/EnvironmentSharableTooltip.tsx | 7 +
apps/yaak-client/components/ErrorBoundary.tsx | 66 +
.../components/ExportDataDialog.tsx | 169 +
apps/yaak-client/components/FeedbackToast.tsx | 76 +
apps/yaak-client/components/FolderLayout.tsx | 212 ++
.../components/FolderSettingsDialog.tsx | 209 +
.../components/FormMultipartEditor.tsx | 57 +
.../components/FormUrlencodedEditor.tsx | 46 +
apps/yaak-client/components/GlobalHooks.tsx | 45 +
.../components/GrpcConnectionLayout.tsx | 129 +
apps/yaak-client/components/GrpcEditor.tsx | 302 ++
.../components/GrpcProtoSelectionDialog.tsx | 183 +
.../components/GrpcRequestPane.tsx | 318 ++
.../components/GrpcResponsePane.tsx | 248 ++
apps/yaak-client/components/HeadersEditor.tsx | 190 +
.../components/HttpAuthenticationEditor.tsx | 260 ++
.../components/HttpRequestLayout.tsx | 69 +
.../components/HttpRequestPane.tsx | 497 +++
.../components/HttpResponsePane.tsx | 461 +++
.../components/HttpResponseTimeline.tsx | 466 +++
apps/yaak-client/components/ImportCurl.tsx | 112 +
.../components/ImportDataDialog.tsx | 795 ++++
apps/yaak-client/components/IsDev.tsx | 14 +
.../yaak-client/components/JsonBodyEditor.tsx | 122 +
.../components/KeyboardShortcutsDialog.tsx | 10 +
.../components/LargeValueDialog.tsx | 274 ++
apps/yaak-client/components/LicenseBadge.tsx | 139 +
apps/yaak-client/components/LocalImage.tsx | 30 +
apps/yaak-client/components/Markdown.tsx | 113 +
.../yaak-client/components/MarkdownEditor.tsx | 83 +
.../components/ModelSettingsEditor.tsx | 660 ++++
.../components/MoveToWorkspaceDialog.tsx | 88 +
apps/yaak-client/components/Onboarding.tsx | 212 ++
apps/yaak-client/components/Prose.css | 212 ++
apps/yaak-client/components/Prose.tsx | 12 +
.../RecentGrpcConnectionsDropdown.tsx | 115 +
.../RecentHttpResponsesDropdown.tsx | 159 +
.../components/RecentRequestsDropdown.tsx | 101 +
.../RecentWebsocketConnectionsDropdown.tsx | 119 +
.../components/RedirectToLatestWorkspace.tsx | 51 +
.../components/RequestBodyViewer.tsx | 102 +
.../components/RequestMethodDropdown.tsx | 78 +
.../components/ResponseCookies.tsx | 225 ++
.../components/ResponseHeaders.tsx | 110 +
apps/yaak-client/components/ResponseInfo.tsx | 44 +
apps/yaak-client/components/RouteError.tsx | 43 +
apps/yaak-client/components/SelectFile.tsx | 138 +
.../components/Settings/Settings.tsx | 155 +
.../Settings/SettingsCertificates.tsx | 254 ++
.../components/Settings/SettingsGeneral.tsx | 180 +
.../components/Settings/SettingsHotkeys.tsx | 365 ++
.../components/Settings/SettingsInterface.tsx | 274 ++
.../components/Settings/SettingsLicense.tsx | 187 +
.../components/Settings/SettingsPlugins.tsx | 426 +++
.../components/Settings/SettingsProxy.tsx | 180 +
.../components/Settings/SettingsTheme.tsx | 173 +
.../components/SettingsDropdown.tsx | 113 +
apps/yaak-client/components/Sidebar.tsx | 1182 ++++++
.../yaak-client/components/SidebarActions.tsx | 39 +
.../components/SwitchWorkspaceDialog.tsx | 60 +
.../components/SyncToFilesystemSetting.tsx | 121 +
.../components/TemplateFunctionDialog.tsx | 296 ++
apps/yaak-client/components/Toasts.tsx | 43 +
apps/yaak-client/components/UrlBar.tsx | 113 +
.../components/UrlParameterEditor.tsx | 54 +
.../components/WebsocketRequestLayout.tsx | 49 +
.../components/WebsocketRequestPane.tsx | 314 ++
.../components/WebsocketResponsePane.tsx | 241 ++
apps/yaak-client/components/Workspace.tsx | 227 ++
.../components/WorkspaceActionsDropdown.tsx | 193 +
.../components/WorkspaceEncryptionSetting.tsx | 356 ++
.../components/WorkspaceHeader.tsx | 94 +
.../components/WorkspaceSettingsDialog.tsx | 191 +
apps/yaak-client/components/core/Alert.tsx | 21 +
.../components/core/AutoScroller.tsx | 118 +
.../components/core/BulkPairEditor.test.ts | 36 +
.../components/core/BulkPairEditor.tsx | 63 +
apps/yaak-client/components/core/Button.tsx | 34 +
.../components/core/ButtonInfiniteLoading.tsx | 25 +
apps/yaak-client/components/core/Checkbox.tsx | 73 +
.../components/core/CheckboxTree.tsx | 114 +
.../components/core/ColorPicker.tsx | 117 +
apps/yaak-client/components/core/Confirm.tsx | 74 +
.../components/core/CountBadge.tsx | 48 +
.../components/core/DetailsBanner.tsx | 60 +
apps/yaak-client/components/core/Dialog.tsx | 131 +
.../components/core/DismissibleBanner.tsx | 111 +
apps/yaak-client/components/core/Dropdown.tsx | 1069 ++++++
.../core/Editor/BetterMatchDecorator.ts | 13 +
.../components/core/Editor/DiffViewer.css | 41 +
.../components/core/Editor/DiffViewer.tsx | 64 +
.../components/core/Editor/Editor.css | 515 +++
.../components/core/Editor/Editor.tsx | 770 ++++
.../components/core/Editor/LazyEditor.tsx | 12 +
.../components/core/Editor/extensions.ts | 343 ++
.../core/Editor/filter/extension.ts | 211 ++
.../core/Editor/filter/filter.grammar | 78 +
.../core/Editor/filter/filter.test.ts | 42 +
.../components/core/Editor/filter/filter.ts | 22 +
.../core/Editor/filter/format.test.ts | 43 +
.../components/core/Editor/filter/format.ts | 7 +
.../core/Editor/filter/highlight.ts | 22 +
.../components/core/Editor/filter/query.ts | 308 ++
.../core/Editor/genericCompletion.ts | 43 +
.../core/Editor/hyperlink/extension.ts | 129 +
.../components/core/Editor/json-lint.ts | 60 +
.../core/Editor/json/breadcrumbExtension.ts | 41 +
.../core/Editor/json/jsonPath.test.ts | 136 +
.../components/core/Editor/json/jsonPath.ts | 102 +
.../core/Editor/largeValues.test.ts | 500 +++
.../components/core/Editor/largeValues.ts | 584 +++
.../components/core/Editor/pairs/extension.ts | 12 +
.../components/core/Editor/pairs/highlight.ts | 7 +
.../core/Editor/pairs/pairs.grammar | 9 +
.../core/Editor/pairs/pairs.terms.ts | 5 +
.../core/Editor/pairs/pairs.test.ts | 26 +
.../components/core/Editor/pairs/pairs.ts | 29 +
.../core/Editor/searchMatchCount.test.ts | 202 +
.../core/Editor/searchMatchCount.ts | 325 ++
.../components/core/Editor/singleLine.ts | 47 +
.../components/core/Editor/sniffValue.test.ts | 170 +
.../components/core/Editor/sniffValue.ts | 219 ++
.../components/core/Editor/text/extension.ts | 12 +
.../components/core/Editor/text/text.grammar | 5 +
.../components/core/Editor/text/text.terms.ts | 3 +
.../components/core/Editor/text/text.ts | 16 +
.../core/Editor/timeline/extension.ts | 12 +
.../core/Editor/timeline/highlight.ts | 7 +
.../core/Editor/timeline/timeline.grammar | 21 +
.../core/Editor/timeline/timeline.terms.ts | 11 +
.../core/Editor/timeline/timeline.ts | 21 +
.../core/Editor/twig/completion.test.ts | 155 +
.../components/core/Editor/twig/completion.ts | 156 +
.../components/core/Editor/twig/extension.ts | 85 +
.../components/core/Editor/twig/highlight.ts | 7 +
.../core/Editor/twig/pathParameters.ts | 106 +
.../core/Editor/twig/templateTags.ts | 224 ++
.../components/core/Editor/twig/twig.grammar | 17 +
.../components/core/Editor/twig/twig.terms.ts | 7 +
.../components/core/Editor/twig/twig.test.ts | 108 +
.../components/core/Editor/twig/twig.ts | 20 +
.../components/core/Editor/twig/util.ts | 37 +
.../core/Editor/url/completion.test.ts | 49 +
.../components/core/Editor/url/completion.ts | 51 +
.../components/core/Editor/url/extension.ts | 12 +
.../components/core/Editor/url/highlight.ts | 10 +
.../components/core/Editor/url/url.grammar | 24 +
.../components/core/Editor/url/url.terms.ts | 9 +
.../components/core/Editor/url/url.test.ts | 52 +
.../components/core/Editor/url/url.ts | 18 +
.../components/core/EventViewer.tsx | 312 ++
.../components/core/EventViewerRow.tsx | 38 +
apps/yaak-client/components/core/Hotkey.tsx | 45 +
.../components/core/HotkeyLabel.tsx | 15 +
.../components/core/HotkeyList.test.tsx | 25 +
.../components/core/HotkeyList.tsx | 31 +
.../components/core/HttpMethodTag.tsx | 91 +
.../core/HttpResponseDurationTag.tsx | 45 +
.../components/core/HttpStatusTag.tsx | 55 +
.../components/core/IconButton.tsx | 37 +
.../components/core/IconTooltip.tsx | 30 +
apps/yaak-client/components/core/Input.tsx | 677 ++++
.../components/core/JsonAttributeTree.tsx | 146 +
.../components/core/KeyValueRow.tsx | 106 +
apps/yaak-client/components/core/Label.tsx | 49 +
apps/yaak-client/components/core/Link.tsx | 63 +
.../components/core/PairEditor.tsx | 971 +++++
.../components/core/PairEditor.util.tsx | 10 +
.../components/core/PairOrBulkEditor.tsx | 38 +
.../components/core/PillButton.tsx | 14 +
.../components/core/PlainInput.tsx | 244 ++
apps/yaak-client/components/core/Prompt.tsx | 66 +
.../components/core/RadioCards.tsx | 64 +
.../components/core/RadioDropdown.tsx | 69 +
.../components/core/SegmentedControl.tsx | 136 +
apps/yaak-client/components/core/Select.tsx | 149 +
.../yaak-client/components/core/Separator.tsx | 77 +
.../components/core/SettingRow.tsx | 514 +++
apps/yaak-client/components/core/SizeTag.tsx | 22 +
.../yaak-client/components/core/Tabs/Tabs.tsx | 585 +++
apps/yaak-client/components/core/Toast.tsx | 148 +
apps/yaak-client/components/core/Tooltip.tsx | 165 +
.../components/core/WebsocketStatusTag.tsx | 31 +
.../components/git/BranchSelectionDialog.tsx | 43 +
.../components/git/FileHistoryDialog.tsx | 131 +
.../components/git/GitCommitDialog.tsx | 481 +++
.../components/git/GitDropdown.tsx | 681 ++++
.../components/git/GitRemotesDialog.tsx | 73 +
.../components/git/HistoryDialog.tsx | 44 +
apps/yaak-client/components/git/callbacks.tsx | 31 +
.../components/git/credentials.tsx | 49 +
apps/yaak-client/components/git/diverged.tsx | 100 +
apps/yaak-client/components/git/git-util.ts | 36 +
.../components/git/showAddRemoteDialog.tsx | 20 +
.../components/git/uncommitted.tsx | 13 +
.../graphql/GraphQLDocsExplorer.tsx | 1008 +++++
.../components/graphql/GraphQLEditor.tsx | 434 +++
.../components/graphql/graphqlAtoms.ts | 14 +
.../responseViewers/AudioViewer.tsx | 30 +
.../responseViewers/BinaryViewer.tsx | 37 +
.../responseViewers/CsvViewer.test.tsx | 32 +
.../components/responseViewers/CsvViewer.tsx | 63 +
.../responseViewers/EventStreamViewer.tsx | 428 +++
.../responseViewers/HTMLOrTextViewer.tsx | 106 +
.../responseViewers/ImageViewer.tsx | 39 +
.../components/responseViewers/JsonViewer.tsx | 22 +
.../responseViewers/MultipartViewer.tsx | 136 +
.../components/responseViewers/PdfViewer.css | 3 +
.../components/responseViewers/PdfViewer.tsx | 81 +
.../responseViewers/RecentFiltersDropdown.tsx | 96 +
.../responseViewers/ResponseBreadcrumbBar.tsx | 192 +
.../components/responseViewers/SvgViewer.tsx | 30 +
.../components/responseViewers/TextViewer.tsx | 222 ++
.../responseViewers/VideoViewer.tsx | 29 +
.../responseViewers/WebPageViewer.tsx | 28 +
apps/yaak-client/font-size.ts | 19 +
apps/yaak-client/font.ts | 23 +
apps/yaak-client/hooks/useActiveCookieJar.ts | 58 +
.../yaak-client/hooks/useActiveEnvironment.ts | 29 +
.../hooks/useActiveEnvironmentVariables.ts | 8 +
apps/yaak-client/hooks/useActiveFolder.ts | 9 +
apps/yaak-client/hooks/useActiveFolderId.ts | 11 +
apps/yaak-client/hooks/useActiveRequest.ts | 23 +
apps/yaak-client/hooks/useActiveRequestId.ts | 11 +
apps/yaak-client/hooks/useActiveWorkspace.ts | 24 +
.../hooks/useActiveWorkspaceChangedToast.tsx | 31 +
apps/yaak-client/hooks/useAllRequests.ts | 40 +
apps/yaak-client/hooks/useAuthTab.tsx | 206 +
.../hooks/useCancelHttpResponse.ts | 9 +
apps/yaak-client/hooks/useCheckForUpdates.tsx | 26 +
apps/yaak-client/hooks/useClickOutside.ts | 41 +
apps/yaak-client/hooks/useCopyHttpResponse.ts | 16 +
apps/yaak-client/hooks/useCreateCookieJar.ts | 33 +
.../hooks/useCreateDropdownItems.tsx | 118 +
apps/yaak-client/hooks/useCreateWorkspace.tsx | 14 +
.../hooks/useDeleteGrpcConnections.ts | 12 +
.../hooks/useDeleteHttpResponses.ts | 12 +
.../hooks/useDeleteSendHistory.tsx | 52 +
.../hooks/useEnvironmentValueVisibility.ts | 10 +
.../hooks/useEnvironmentVariables.ts | 66 +
.../hooks/useEnvironmentsBreakdown.ts | 35 +
.../hooks/useEventViewerKeyboard.ts | 85 +
apps/yaak-client/hooks/useExportData.tsx | 41 +
apps/yaak-client/hooks/useFastMutation.ts | 76 +
.../hooks/useFloatingSidebarHidden.ts | 14 +
apps/yaak-client/hooks/useFolderActions.ts | 50 +
apps/yaak-client/hooks/useFormatText.ts | 30 +
.../yaak-client/hooks/useGraphQLSchemaFile.ts | 18 +
apps/yaak-client/hooks/useGrpc.ts | 78 +
apps/yaak-client/hooks/useGrpcProtoFiles.ts | 17 +
.../hooks/useGrpcRequestActions.ts | 53 +
apps/yaak-client/hooks/useHeadersTab.tsx | 31 +
apps/yaak-client/hooks/useHotKey.ts | 442 +++
.../hooks/useHttpAuthentication.ts | 49 +
.../hooks/useHttpAuthenticationConfig.ts | 84 +
.../hooks/useHttpRequestActions.ts | 50 +
apps/yaak-client/hooks/useHttpRequestBody.ts | 32 +
.../hooks/useHttpResponseEvents.ts | 32 +
.../hooks/useInheritedAuthentication.ts | 46 +
apps/yaak-client/hooks/useInheritedHeaders.ts | 53 +
apps/yaak-client/hooks/useInstallPlugin.ts | 11 +
.../yaak-client/hooks/useIntrospectGraphQL.ts | 268 ++
.../hooks/useIsEncryptionEnabled.ts | 7 +
apps/yaak-client/hooks/useKeyValue.ts | 90 +
apps/yaak-client/hooks/useKeyboardEvent.ts | 16 +
.../hooks/useLatestGrpcConnection.ts | 7 +
.../hooks/useLatestHttpResponse.ts | 7 +
apps/yaak-client/hooks/useModelAncestors.ts | 41 +
apps/yaak-client/hooks/useParentFolders.ts | 24 +
.../hooks/usePinnedGrpcConnection.ts | 84 +
.../hooks/usePinnedHttpResponse.ts | 29 +
.../hooks/usePinnedWebsocketConnection.ts | 71 +
apps/yaak-client/hooks/usePlatformEvent.ts | 12 +
apps/yaak-client/hooks/usePluginInfo.ts | 29 +
apps/yaak-client/hooks/usePlugins.ts | 37 +
.../hooks/usePreferredAppearance.ts | 15 +
apps/yaak-client/hooks/useRandomKey.ts | 8 +
apps/yaak-client/hooks/useRecentCookieJars.ts | 53 +
.../hooks/useRecentEnvironments.ts | 51 +
apps/yaak-client/hooks/useRecentFilters.ts | 67 +
apps/yaak-client/hooks/useRecentRequests.ts | 53 +
apps/yaak-client/hooks/useRecentWorkspaces.ts | 48 +
apps/yaak-client/hooks/useRenderTemplate.ts | 71 +
apps/yaak-client/hooks/useRequestEditor.tsx | 85 +
apps/yaak-client/hooks/useRequestUpdateKey.ts | 41 +
.../hooks/useResolvedAppearance.ts | 10 +
apps/yaak-client/hooks/useResolvedTheme.ts | 25 +
.../hooks/useResponseBodyEventSource.ts | 17 +
.../hooks/useResponseBodySseSummary.ts | 19 +
apps/yaak-client/hooks/useResponseBodyText.ts | 43 +
apps/yaak-client/hooks/useResponseBodyUrl.ts | 24 +
apps/yaak-client/hooks/useResponseFilter.ts | 129 +
apps/yaak-client/hooks/useResponseViewMode.ts | 8 +
apps/yaak-client/hooks/useSaveResponse.tsx | 42 +
apps/yaak-client/hooks/useScrollIntoView.ts | 9 +
.../hooks/useSendAnyHttpRequest.test.ts | 133 +
.../hooks/useSendAnyHttpRequest.ts | 37 +
apps/yaak-client/hooks/useSendManyRequests.ts | 14 +
apps/yaak-client/hooks/useSidebarHidden.ts | 14 +
.../hooks/useSidebarItemCollapsed.ts | 12 +
apps/yaak-client/hooks/useSidebarWidth.ts | 14 +
.../hooks/useSseSummaryResultKeyPath.ts | 98 +
apps/yaak-client/hooks/useStateWithDeps.ts | 14 +
.../yaak-client/hooks/useStoplightsVisible.ts | 8 +
.../hooks/useSyncFontSizeSetting.ts | 17 +
.../hooks/useSyncWorkspaceChildModels.ts | 17 +
.../hooks/useSyncWorkspaceRequestTitle.ts | 34 +
apps/yaak-client/hooks/useSyncZoomSetting.ts | 16 +
.../hooks/useTemplateFunctionConfig.ts | 76 +
.../hooks/useTemplateFunctions.tsx | 61 +
.../hooks/useTemplateTokensToString.ts | 15 +
apps/yaak-client/hooks/useTimelineViewMode.ts | 14 +
apps/yaak-client/hooks/useToggle.ts | 7 +
.../hooks/useToggleCommandPalette.tsx | 20 +
.../hooks/useWebsocketRequestActions.ts | 52 +
apps/yaak-client/hooks/useWindowFocus.ts | 12 +
apps/yaak-client/hooks/useWorkspaceActions.ts | 50 +
apps/yaak-client/hooks/useZoom.ts | 27 +
apps/yaak-client/index.html | 39 +
apps/yaak-client/init/git.ts | 38 +
apps/yaak-client/init/sync.ts | 78 +
apps/yaak-client/lib/alert.ts | 29 +
apps/yaak-client/lib/appInfo.ts | 23 +
apps/yaak-client/lib/atoms.ts | 22 +
.../lib/atoms/atomWithKVStorage.ts | 25 +
apps/yaak-client/lib/capitalize.ts | 6 +
apps/yaak-client/lib/color.ts | 20 +
apps/yaak-client/lib/confirm.ts | 34 +
apps/yaak-client/lib/confirmWebProxy.test.tsx | 64 +
apps/yaak-client/lib/confirmWebProxy.tsx | 60 +
apps/yaak-client/lib/contentType.ts | 99 +
apps/yaak-client/lib/contextMenu.ts | 46 +
apps/yaak-client/lib/copy.ts | 22 +
.../lib/createRequestAndNavigate.tsx | 31 +
apps/yaak-client/lib/data/acceptLanguages.ts | 15 +
apps/yaak-client/lib/data/cacheControl.ts | 16 +
apps/yaak-client/lib/data/charsets.ts | 121 +
apps/yaak-client/lib/data/connections.ts | 1 +
apps/yaak-client/lib/data/encodings.ts | 1 +
apps/yaak-client/lib/data/headerNames.ts | 70 +
.../lib/data/headerValuePresets.ts | 7 +
apps/yaak-client/lib/data/mimetypes.ts | 213 ++
apps/yaak-client/lib/data/userAgents.ts | 51 +
apps/yaak-client/lib/defaultHeaders.ts | 8 +
.../lib/deleteModelWithConfirm.tsx | 65 +
apps/yaak-client/lib/dialog.ts | 22 +
apps/yaak-client/lib/diffYaml.ts | 14 +
apps/yaak-client/lib/dnd.ts | 39 +
apps/yaak-client/lib/docFingerprint.test.ts | 67 +
apps/yaak-client/lib/docFingerprint.ts | 33 +
.../duplicateRequestOrFolderAndNavigate.tsx | 19 +
apps/yaak-client/lib/editEnvironment.tsx | 62 +
apps/yaak-client/lib/encryption.ts | 57 +
apps/yaak-client/lib/featureFeedback.tsx | 103 +
.../lib/featureFeedbackConstants.ts | 8 +
apps/yaak-client/lib/fireAndForget.ts | 16 +
apps/yaak-client/lib/formatters.ts | 45 +
apps/yaak-client/lib/generateId.ts | 7 +
apps/yaak-client/lib/getNodeText.ts | 22 +
apps/yaak-client/lib/gitWorktreeStatus.ts | 22 +
.../lib/graphqlOperationNames.test.ts | 37 +
apps/yaak-client/lib/graphqlOperationNames.ts | 26 +
apps/yaak-client/lib/graphqlSchema.test.ts | 85 +
apps/yaak-client/lib/graphqlSchema.ts | 51 +
apps/yaak-client/lib/importData.tsx | 136 +
apps/yaak-client/lib/initGlobalListeners.tsx | 370 ++
apps/yaak-client/lib/jotai.ts | 3 +
apps/yaak-client/lib/jsonComments.ts | 30 +
.../yaak-client/lib/jsonSchemaExample.test.ts | 251 ++
apps/yaak-client/lib/jsonSchemaExample.ts | 121 +
apps/yaak-client/lib/keyValueStore.ts | 70 +
apps/yaak-client/lib/largeValue.ts | 236 ++
apps/yaak-client/lib/markdown.ts | 27 +
apps/yaak-client/lib/minPromiseMillis.ts | 19 +
apps/yaak-client/lib/model_util.test.ts | 91 +
apps/yaak-client/lib/model_util.ts | 95 +
apps/yaak-client/lib/onboarding.ts | 44 +
apps/yaak-client/lib/pathPlaceholders.test.ts | 214 ++
apps/yaak-client/lib/pathPlaceholders.ts | 102 +
apps/yaak-client/lib/pluralize.ts | 20 +
.../lib/prepareImportQuerystring.ts | 25 +
apps/yaak-client/lib/pricingUrl.ts | 3 +
apps/yaak-client/lib/prompt-form.tsx | 47 +
apps/yaak-client/lib/prompt.ts | 45 +
apps/yaak-client/lib/queryClient.ts | 18 +
.../yaak-client/lib/renameModelWithPrompt.tsx | 32 +
.../lib/requestBodyConversion.test.ts | 152 +
apps/yaak-client/lib/requestBodyConversion.ts | 199 +
apps/yaak-client/lib/requestSettings.ts | 99 +
apps/yaak-client/lib/resolvedModelName.ts | 61 +
apps/yaak-client/lib/responseBody.ts | 78 +
apps/yaak-client/lib/reveal.ts | 9 +
apps/yaak-client/lib/router.ts | 12 +
apps/yaak-client/lib/rpc.ts | 19 +
apps/yaak-client/lib/scopes.ts | 3 +
apps/yaak-client/lib/sendEphemeralRequest.ts | 17 +
.../lib/setWorkspaceSearchParams.ts | 47 +
apps/yaak-client/lib/settings.ts | 6 +
.../lib/setupOrConfigureEncryption.tsx | 32 +
apps/yaak-client/lib/showColorPicker.tsx | 23 +
apps/yaak-client/lib/sleep.ts | 3 +
apps/yaak-client/lib/themes.ts | 36 +
apps/yaak-client/lib/toast.tsx | 70 +
apps/yaak-client/lib/truncate.ts | 4 +
apps/yaak-client/main.css | 96 +
apps/yaak-client/main.tsx | 46 +
apps/yaak-client/modules.d.ts | 1 +
apps/yaak-client/package.json | 102 +
apps/yaak-client/postcss.config.cjs | 3 +
apps/yaak-client/react-augment.d.ts | 8 +
apps/yaak-client/routeTree.gen.ts | 151 +
apps/yaak-client/routes/__root.tsx | 54 +
apps/yaak-client/routes/index.tsx | 19 +
.../routes/workspaces/$workspaceId/index.tsx | 40 +
.../$workspaceId/requests/$requestId.tsx | 23 +
.../workspaces/$workspaceId/settings.tsx | 19 +
apps/yaak-client/routes/workspaces/index.tsx | 10 +
apps/yaak-client/theme.ts | 81 +
apps/yaak-client/tsconfig.json | 28 +
apps/yaak-client/tsconfig.node.json | 11 +
apps/yaak-client/tsr.config.json | 3 +
apps/yaak-client/vite-env.d.ts | 1 +
apps/yaak-client/vite.config.ts | 226 ++
apps/yaak-client/web/og.html | 129 +
apps/yaak-proxy/components/ActionButton.tsx | 31 +
.../components/ActionIconButton.tsx | 29 +
apps/yaak-proxy/components/ExchangesTable.tsx | 77 +
apps/yaak-proxy/components/ProxyLayout.tsx | 146 +
apps/yaak-proxy/components/Sidebar.tsx | 219 ++
apps/yaak-proxy/font-size.ts | 2 +
apps/yaak-proxy/hooks/useActionMetadata.ts | 32 +
apps/yaak-proxy/hooks/useRpcEvent.ts | 15 +
apps/yaak-proxy/hooks/useRpcMutation.ts | 18 +
apps/yaak-proxy/hooks/useRpcQuery.ts | 20 +
apps/yaak-proxy/hooks/useRpcQueryWithEvent.ts | 23 +
apps/yaak-proxy/index.html | 28 +
apps/yaak-proxy/lib/fireAndForget.ts | 5 +
apps/yaak-proxy/lib/hotkeys.ts | 63 +
apps/yaak-proxy/lib/rpc.ts | 17 +
apps/yaak-proxy/lib/store.ts | 11 +
apps/yaak-proxy/lib/tauri.ts | 30 +
apps/yaak-proxy/lib/theme.ts | 35 +
apps/yaak-proxy/main.css | 92 +
apps/yaak-proxy/main.tsx | 44 +
apps/yaak-proxy/package.json | 37 +
apps/yaak-proxy/postcss.config.cjs | 3 +
apps/yaak-proxy/tsconfig.json | 28 +
apps/yaak-proxy/tsconfig.node.json | 11 +
apps/yaak-proxy/vite-env.d.ts | 1 +
apps/yaak-proxy/vite.config.ts | 16 +
crates-cli/yaak-cli/Cargo.toml | 59 +
crates-cli/yaak-cli/README.md | 66 +
crates-cli/yaak-cli/skills/use-yaak/SKILL.md | 168 +
crates-cli/yaak-cli/src/cli.rs | 622 +++
crates-cli/yaak-cli/src/commands/agent.rs | 269 ++
crates-cli/yaak-cli/src/commands/auth.rs | 596 +++
.../yaak-cli/src/commands/cookie_jar.rs | 42 +
.../yaak-cli/src/commands/environment.rs | 179 +
crates-cli/yaak-cli/src/commands/folder.rs | 159 +
.../yaak-cli/src/commands/import_export.rs | 277 ++
crates-cli/yaak-cli/src/commands/mod.rs | 12 +
crates-cli/yaak-cli/src/commands/plugin.rs | 1018 +++++
crates-cli/yaak-cli/src/commands/request.rs | 620 +++
crates-cli/yaak-cli/src/commands/response.rs | 141 +
crates-cli/yaak-cli/src/commands/send.rs | 242 ++
.../src/commands/template_function.rs | 111 +
crates-cli/yaak-cli/src/commands/workspace.rs | 140 +
crates-cli/yaak-cli/src/context.rs | 163 +
crates-cli/yaak-cli/src/main.rs | 331 ++
crates-cli/yaak-cli/src/plugin_events.rs | 1211 ++++++
crates-cli/yaak-cli/src/ui.rs | 42 +
crates-cli/yaak-cli/src/utils/confirm.rs | 16 +
crates-cli/yaak-cli/src/utils/http.rs | 47 +
crates-cli/yaak-cli/src/utils/json.rs | 131 +
crates-cli/yaak-cli/src/utils/mod.rs | 5 +
crates-cli/yaak-cli/src/utils/schema.rs | 15 +
crates-cli/yaak-cli/src/utils/workspace.rs | 19 +
crates-cli/yaak-cli/src/version.rs | 3 +
crates-cli/yaak-cli/src/version_check.rs | 225 ++
.../yaak-cli/tests/common/http_server.rs | 89 +
crates-cli/yaak-cli/tests/common/mod.rs | 101 +
.../yaak-cli/tests/environment_commands.rs | 146 +
crates-cli/yaak-cli/tests/folder_commands.rs | 122 +
.../yaak-cli/tests/import_export_commands.rs | 324 ++
crates-cli/yaak-cli/tests/request_commands.rs | 291 ++
crates-cli/yaak-cli/tests/send_commands.rs | 50 +
.../yaak-cli/tests/workspace_commands.rs | 77 +
crates-proxy/yaak-proxy-lib/Cargo.toml | 21 +
.../bindings/ModelChangeEvent.ts | 3 +
.../yaak-proxy-lib/bindings/gen_models.ts | 8 +
.../yaak-proxy-lib/bindings/gen_rpc.ts | 28 +
crates-proxy/yaak-proxy-lib/bindings/index.ts | 2 +
.../migrations/00000001_init.sql | 14 +
crates-proxy/yaak-proxy-lib/package.json | 6 +
crates-proxy/yaak-proxy-lib/src/actions.rs | 49 +
crates-proxy/yaak-proxy-lib/src/db.rs | 31 +
crates-proxy/yaak-proxy-lib/src/lib.rs | 271 ++
crates-proxy/yaak-proxy-lib/src/models.rs | 119 +
crates-server/yaak-playground/Cargo.toml | 25 +
crates-server/yaak-playground/README.md | 21 +
crates-server/yaak-playground/src/auth.rs | 185 +
crates-server/yaak-playground/src/config.rs | 59 +
crates-server/yaak-playground/src/data.rs | 59 +
crates-server/yaak-playground/src/error.rs | 57 +
crates-server/yaak-playground/src/lib.rs | 170 +
crates-server/yaak-playground/src/limits.rs | 50 +
crates-server/yaak-playground/src/main.rs | 29 +
.../yaak-playground/src/openapi.json | 396 ++
.../yaak-playground/src/resources.rs | 229 ++
crates-server/yaak-playground/src/seed.json | 88 +
crates-server/yaak-playground/src/store.rs | 83 +
crates-server/yaak-playground/tests/api.rs | 262 ++
crates-server/yaak-web/Cargo.toml | 40 +
crates-server/yaak-web/README.md | 237 ++
crates-server/yaak-web/bindings/gen_models.ts | 135 +
crates-server/yaak-web/bindings/gen_web.ts | 64 +
crates-server/yaak-web/index.ts | 4 +
crates-server/yaak-web/package.json | 6 +
crates-server/yaak-web/src/config.rs | 98 +
crates-server/yaak-web/src/guard.rs | 271 ++
crates-server/yaak-web/src/lib.rs | 295 ++
crates-server/yaak-web/src/limits.rs | 90 +
crates-server/yaak-web/src/main.rs | 35 +
crates-server/yaak-web/src/send.rs | 359 ++
crates-server/yaak-web/src/wire.rs | 97 +
crates-server/yaak-web/tests/logging.rs | 98 +
crates-server/yaak-web/tests/router.rs | 89 +
crates-tauri/yaak-app-client/.gitignore | 10 +
crates-tauri/yaak-app-client/Cargo.toml | 101 +
.../yaak-app-client/bindings/gen_watch.ts | 3 +
.../yaak-app-client/bindings/index.ts | 37 +
.../yaak-app-client/bindings/plugins_ext.ts | 5 +
crates-tauri/yaak-app-client/build.rs | 3 +
.../yaak-app-client/capabilities/default.json | 55 +
.../yaak-app-client/icons/dev/128x128.png | Bin 0 -> 6214 bytes
.../yaak-app-client/icons/dev/128x128@2x.png | Bin 0 -> 12644 bytes
.../yaak-app-client/icons/dev/32x32.png | Bin 0 -> 1509 bytes
.../yaak-app-client/icons/dev/64x64.png | Bin 0 -> 2978 bytes
.../icons/dev/Square107x107Logo.png | Bin 0 -> 5001 bytes
.../icons/dev/Square142x142Logo.png | Bin 0 -> 6864 bytes
.../icons/dev/Square150x150Logo.png | Bin 0 -> 7139 bytes
.../icons/dev/Square284x284Logo.png | Bin 0 -> 13915 bytes
.../icons/dev/Square30x30Logo.png | Bin 0 -> 1413 bytes
.../icons/dev/Square310x310Logo.png | Bin 0 -> 15253 bytes
.../icons/dev/Square44x44Logo.png | Bin 0 -> 2034 bytes
.../icons/dev/Square71x71Logo.png | Bin 0 -> 3439 bytes
.../icons/dev/Square89x89Logo.png | Bin 0 -> 4214 bytes
.../yaak-app-client/icons/dev/StoreLogo.png | Bin 0 -> 2326 bytes
.../dev/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2341 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7751 bytes
.../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2341 bytes
.../dev/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2329 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 5057 bytes
.../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2329 bytes
.../dev/android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4542 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 10421 bytes
.../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4542 bytes
.../dev/android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6868 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 16153 bytes
.../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 6868 bytes
.../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9100 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 22021 bytes
.../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 9100 bytes
.../yaak-app-client/icons/dev/icon.icns | Bin 0 -> 122779 bytes
.../yaak-app-client/icons/dev/icon.ico | Bin 0 -> 21841 bytes
.../yaak-app-client/icons/dev/icon.png | Bin 0 -> 26374 bytes
.../icons/dev/ios/AppIcon-20x20@1x.png | Bin 0 -> 848 bytes
.../icons/dev/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 1852 bytes
.../icons/dev/ios/AppIcon-20x20@2x.png | Bin 0 -> 1852 bytes
.../icons/dev/ios/AppIcon-20x20@3x.png | Bin 0 -> 2846 bytes
.../icons/dev/ios/AppIcon-29x29@1x.png | Bin 0 -> 1294 bytes
.../icons/dev/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 2740 bytes
.../icons/dev/ios/AppIcon-29x29@2x.png | Bin 0 -> 2740 bytes
.../icons/dev/ios/AppIcon-29x29@3x.png | Bin 0 -> 3989 bytes
.../icons/dev/ios/AppIcon-40x40@1x.png | Bin 0 -> 1852 bytes
.../icons/dev/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 3759 bytes
.../icons/dev/ios/AppIcon-40x40@2x.png | Bin 0 -> 3759 bytes
.../icons/dev/ios/AppIcon-40x40@3x.png | Bin 0 -> 5762 bytes
.../icons/dev/ios/AppIcon-512@2x.png | Bin 0 -> 32171 bytes
.../icons/dev/ios/AppIcon-60x60@2x.png | Bin 0 -> 5762 bytes
.../icons/dev/ios/AppIcon-60x60@3x.png | Bin 0 -> 8545 bytes
.../icons/dev/ios/AppIcon-76x76@1x.png | Bin 0 -> 3429 bytes
.../icons/dev/ios/AppIcon-76x76@2x.png | Bin 0 -> 7313 bytes
.../icons/dev/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 7943 bytes
.../yaak-app-client/icons/icon-dev.png | Bin 0 -> 31969 bytes
.../yaak-app-client/icons/release/128x128.png | Bin 0 -> 14307 bytes
.../icons/release/128x128@2x.png | Bin 0 -> 37023 bytes
.../yaak-app-client/icons/release/32x32.png | Bin 0 -> 2229 bytes
.../yaak-app-client/icons/release/64x64.png | Bin 0 -> 5719 bytes
.../icons/release/Square107x107Logo.png | Bin 0 -> 11181 bytes
.../icons/release/Square142x142Logo.png | Bin 0 -> 16232 bytes
.../icons/release/Square150x150Logo.png | Bin 0 -> 17385 bytes
.../icons/release/Square284x284Logo.png | Bin 0 -> 43368 bytes
.../icons/release/Square30x30Logo.png | Bin 0 -> 2090 bytes
.../icons/release/Square310x310Logo.png | Bin 0 -> 49313 bytes
.../icons/release/Square44x44Logo.png | Bin 0 -> 3356 bytes
.../icons/release/Square71x71Logo.png | Bin 0 -> 6599 bytes
.../icons/release/Square89x89Logo.png | Bin 0 -> 8705 bytes
.../icons/release/StoreLogo.png | Bin 0 -> 4128 bytes
.../android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4006 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 19241 bytes
.../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4006 bytes
.../android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3951 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 11245 bytes
.../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3951 bytes
.../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 9706 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 28929 bytes
.../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 9706 bytes
.../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 16664 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 52521 bytes
.../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 16664 bytes
.../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 24506 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 83359 bytes
.../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 24506 bytes
.../yaak-app-client/icons/release/icon.ico | Bin 0 -> 52268 bytes
.../yaak-app-client/icons/release/icon.png | Bin 0 -> 110500 bytes
.../icons/release/ios/AppIcon-20x20@1x.png | Bin 0 -> 1087 bytes
.../icons/release/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 2826 bytes
.../icons/release/ios/AppIcon-20x20@2x.png | Bin 0 -> 2826 bytes
.../icons/release/ios/AppIcon-20x20@3x.png | Bin 0 -> 4901 bytes
.../icons/release/ios/AppIcon-29x29@1x.png | Bin 0 -> 1801 bytes
.../icons/release/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 4770 bytes
.../icons/release/ios/AppIcon-29x29@2x.png | Bin 0 -> 4770 bytes
.../icons/release/ios/AppIcon-29x29@3x.png | Bin 0 -> 8113 bytes
.../icons/release/ios/AppIcon-40x40@1x.png | Bin 0 -> 2826 bytes
.../icons/release/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 7347 bytes
.../icons/release/ios/AppIcon-40x40@2x.png | Bin 0 -> 7347 bytes
.../icons/release/ios/AppIcon-40x40@3x.png | Bin 0 -> 12449 bytes
.../icons/release/ios/AppIcon-60x60@2x.png | Bin 0 -> 12449 bytes
.../icons/release/ios/AppIcon-60x60@3x.png | Bin 0 -> 21555 bytes
.../icons/release/ios/AppIcon-76x76@1x.png | Bin 0 -> 6759 bytes
.../icons/release/ios/AppIcon-76x76@2x.png | Bin 0 -> 17124 bytes
.../release/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 19446 bytes
crates-tauri/yaak-app-client/index.ts | 4 +
.../yaak-app-client/macos/entitlements.plist | 10 +
.../macos/entitlements.yaaknode.plist | 13 +
.../macos/entitlements.yaakprotoc.plist | 6 +
crates-tauri/yaak-app-client/package.json | 6 +
crates-tauri/yaak-app-client/src/encoding.rs | 20 +
crates-tauri/yaak-app-client/src/error.rs | 84 +
crates-tauri/yaak-app-client/src/feedback.rs | 67 +
crates-tauri/yaak-app-client/src/git_ext.rs | 162 +
.../yaak-app-client/src/git_watcher.rs | 185 +
crates-tauri/yaak-app-client/src/grpc.rs | 80 +
crates-tauri/yaak-app-client/src/history.rs | 78 +
.../yaak-app-client/src/http_request.rs | 179 +
crates-tauri/yaak-app-client/src/import.rs | 185 +
crates-tauri/yaak-app-client/src/lib.rs | 1590 ++++++++
crates-tauri/yaak-app-client/src/main.rs | 17 +
.../yaak-app-client/src/models_ext.rs | 196 +
.../yaak-app-client/src/notifications.rs | 179 +
.../yaak-app-client/src/plugin_events.rs | 480 +++
.../yaak-app-client/src/plugins_ext.rs | 440 +++
crates-tauri/yaak-app-client/src/render.rs | 8 +
crates-tauri/yaak-app-client/src/restart.rs | 107 +
crates-tauri/yaak-app-client/src/rpc_ext.rs | 1235 ++++++
crates-tauri/yaak-app-client/src/sync_ext.rs | 92 +
crates-tauri/yaak-app-client/src/updates.rs | 594 +++
.../yaak-app-client/src/uri_scheme.rs | 139 +
.../yaak-app-client/src/window_menu.rs | 176 +
crates-tauri/yaak-app-client/src/ws_ext.rs | 490 +++
crates-tauri/yaak-app-client/static/greg.jpeg | Bin 0 -> 10775 bytes
crates-tauri/yaak-app-client/tauri.conf.json | 47 +
.../tauri.development.conf.json | 13 +
.../yaak-app-client/tauri.linux.conf.json | 3 +
.../yaak-app-client/tauri.release.conf.json | 58 +
crates-tauri/yaak-app-client/template.desktop | 9 +
crates-tauri/yaak-app-proxy/.gitignore | 8 +
crates-tauri/yaak-app-proxy/Cargo.toml | 27 +
crates-tauri/yaak-app-proxy/bindings/index.ts | 1 +
crates-tauri/yaak-app-proxy/build.rs | 3 +
.../yaak-app-proxy/capabilities/default.json | 18 +
crates-tauri/yaak-app-proxy/package.json | 6 +
crates-tauri/yaak-app-proxy/src/lib.rs | 105 +
crates-tauri/yaak-app-proxy/src/main.rs | 5 +
.../yaak-app-proxy/src/window_menu.rs | 138 +
crates-tauri/yaak-app-proxy/tauri.conf.json | 24 +
.../tauri.development.conf.json | 13 +
.../yaak-app-proxy/tauri.release.conf.json | 5 +
crates-tauri/yaak-app-proxy/template.desktop | 9 +
crates-tauri/yaak-fonts/Cargo.toml | 16 +
crates-tauri/yaak-fonts/bindings/gen_fonts.ts | 3 +
crates-tauri/yaak-fonts/build.rs | 5 +
crates-tauri/yaak-fonts/index.ts | 14 +
crates-tauri/yaak-fonts/package.json | 6 +
.../yaak-fonts/permissions/default.toml | 3 +
crates-tauri/yaak-fonts/src/commands.rs | 38 +
crates-tauri/yaak-fonts/src/error.rs | 15 +
crates-tauri/yaak-fonts/src/lib.rs | 15 +
crates-tauri/yaak-license/Cargo.toml | 22 +
.../yaak-license/bindings/gen_models.ts | 3 +
crates-tauri/yaak-license/bindings/license.ts | 11 +
crates-tauri/yaak-license/bindings/models.ts | 3 +
crates-tauri/yaak-license/build.rs | 5 +
crates-tauri/yaak-license/index.ts | 53 +
crates-tauri/yaak-license/package.json | 6 +
.../yaak-license/permissions/default.toml | 3 +
crates-tauri/yaak-license/src/commands.rs | 18 +
crates-tauri/yaak-license/src/error.rs | 34 +
crates-tauri/yaak-license/src/lib.rs | 17 +
crates-tauri/yaak-license/src/license.rs | 261 ++
crates-tauri/yaak-mac-window/Cargo.toml | 22 +
crates-tauri/yaak-mac-window/build.rs | 5 +
crates-tauri/yaak-mac-window/index.ts | 9 +
crates-tauri/yaak-mac-window/package.json | 6 +
.../yaak-mac-window/permissions/default.toml | 3 +
crates-tauri/yaak-mac-window/src/commands.rs | 36 +
crates-tauri/yaak-mac-window/src/lib.rs | 44 +
crates-tauri/yaak-mac-window/src/mac.rs | 429 +++
.../yaak-system-appearance/Cargo.toml | 17 +
.../yaak-system-appearance/src/lib.rs | 208 +
crates-tauri/yaak-tauri-utils/Cargo.toml | 10 +
crates-tauri/yaak-tauri-utils/src/lib.rs | 1 +
crates-tauri/yaak-tauri-utils/src/window.rs | 53 +
crates-tauri/yaak-window/Cargo.toml | 14 +
crates-tauri/yaak-window/src/lib.rs | 3 +
crates-tauri/yaak-window/src/window.rs | 259 ++
crates-tauri/yaak-window/src/window_state.rs | 218 ++
crates/common/yaak-database/Cargo.toml | 28 +
.../yaak-database/src/connection_or_tx.rs | 24 +
crates/common/yaak-database/src/db_context.rs | 194 +
crates/common/yaak-database/src/error.rs | 37 +
crates/common/yaak-database/src/lib.rs | 26 +
crates/common/yaak-database/src/migrate.rs | 79 +
crates/common/yaak-database/src/pool.rs | 81 +
crates/common/yaak-database/src/traits.rs | 36 +
.../common/yaak-database/src/update_source.rs | 31 +
crates/common/yaak-database/src/util.rs | 20 +
crates/common/yaak-rpc-schema/Cargo.toml | 18 +
crates/common/yaak-rpc-schema/README.md | 44 +
.../yaak-rpc-schema/bindings/gen_api.ts | 8 +
.../yaak-rpc-schema/bindings/gen_events.ts | 389 ++
.../yaak-rpc-schema/bindings/gen_git.ts | 35 +
.../yaak-rpc-schema/bindings/gen_grpc.ts | 5 +
.../yaak-rpc-schema/bindings/gen_models.ts | 522 +++
.../yaak-rpc-schema/bindings/gen_rpc.ts | 261 ++
.../yaak-rpc-schema/bindings/gen_search.ts | 5 +
.../yaak-rpc-schema/bindings/gen_sync.ts | 6 +
.../yaak-rpc-schema/bindings/gen_util.ts | 107 +
.../common/yaak-rpc-schema/bindings/parser.ts | 9 +
crates/common/yaak-rpc-schema/bindings/sse.ts | 3 +
crates/common/yaak-rpc-schema/index.ts | 4 +
crates/common/yaak-rpc-schema/package.json | 6 +
crates/common/yaak-rpc-schema/src/lib.rs | 1054 ++++++
crates/common/yaak-rpc/Cargo.toml | 12 +
crates/common/yaak-rpc/src/lib.rs | 259 ++
crates/yaak-api/Cargo.toml | 12 +
crates/yaak-api/src/error.rs | 9 +
crates/yaak-api/src/lib.rs | 80 +
crates/yaak-commands/Cargo.toml | 21 +
crates/yaak-commands/src/actions.rs | 157 +
crates/yaak-commands/src/auth.rs | 62 +
crates/yaak-commands/src/data.rs | 30 +
crates/yaak-commands/src/encryption.rs | 41 +
crates/yaak-commands/src/error.rs | 30 +
crates/yaak-commands/src/host.rs | 229 ++
crates/yaak-commands/src/lib.rs | 28 +
crates/yaak-commands/src/models.rs | 187 +
crates/yaak-commands/src/plugins.rs | 48 +
crates/yaak-commands/src/render.rs | 74 +
crates/yaak-commands/src/resolve.rs | 56 +
crates/yaak-commands/src/responses.rs | 86 +
crates/yaak-commands/src/templates.rs | 79 +
crates/yaak-commands/tests/test_host.rs | 580 +++
crates/yaak-common/Cargo.toml | 9 +
crates/yaak-common/src/command.rs | 42 +
crates/yaak-common/src/lib.rs | 3 +
crates/yaak-common/src/platform.rs | 55 +
crates/yaak-common/src/serde.rs | 30 +
crates/yaak-core/Cargo.toml | 9 +
crates/yaak-core/src/context.rs | 37 +
crates/yaak-core/src/error.rs | 15 +
crates/yaak-core/src/lib.rs | 10 +
crates/yaak-crypto/Cargo.toml | 15 +
crates/yaak-crypto/index.ts | 17 +
crates/yaak-crypto/package.json | 6 +
crates/yaak-crypto/src/encryption.rs | 119 +
crates/yaak-crypto/src/error.rs | 50 +
crates/yaak-crypto/src/lib.rs | 7 +
crates/yaak-crypto/src/manager.rs | 189 +
crates/yaak-crypto/src/master_key.rs | 78 +
crates/yaak-crypto/src/workspace_key.rs | 116 +
crates/yaak-git/Cargo.toml | 24 +
crates/yaak-git/bindings/gen_git.ts | 35 +
crates/yaak-git/bindings/gen_models.ts | 179 +
crates/yaak-git/index.ts | 424 +++
crates/yaak-git/package.json | 6 +
crates/yaak-git/src/add.rs | 16 +
crates/yaak-git/src/binary.rs | 17 +
crates/yaak-git/src/branch.rs | 153 +
crates/yaak-git/src/clone.rs | 53 +
crates/yaak-git/src/commit.rs | 106 +
crates/yaak-git/src/credential.rs | 44 +
crates/yaak-git/src/error.rs | 64 +
crates/yaak-git/src/fetch.rs | 22 +
crates/yaak-git/src/init.rs | 14 +
crates/yaak-git/src/lib.rs | 44 +
crates/yaak-git/src/log.rs | 145 +
crates/yaak-git/src/pull.rs | 208 +
crates/yaak-git/src/push.rs | 89 +
crates/yaak-git/src/remotes.rs | 47 +
crates/yaak-git/src/repository.rs | 38 +
crates/yaak-git/src/reset.rs | 20 +
crates/yaak-git/src/restore.rs | 76 +
crates/yaak-git/src/status.rs | 375 ++
crates/yaak-git/src/unstage.rs | 27 +
crates/yaak-git/src/util.rs | 121 +
crates/yaak-grpc/Cargo.toml | 28 +
crates/yaak-grpc/bindings/gen_grpc.ts | 5 +
crates/yaak-grpc/src/any.rs | 61 +
crates/yaak-grpc/src/client.rs | 188 +
crates/yaak-grpc/src/codec.rs | 50 +
crates/yaak-grpc/src/error.rs | 51 +
crates/yaak-grpc/src/json_schema.rs | 382 ++
crates/yaak-grpc/src/lib.rs | 56 +
crates/yaak-grpc/src/manager.rs | 553 +++
crates/yaak-grpc/src/reflection.rs | 533 +++
crates/yaak-grpc/src/transport.rs | 40 +
crates/yaak-http/Cargo.toml | 43 +
crates/yaak-http/src/chained_reader.rs | 78 +
crates/yaak-http/src/client.rs | 504 +++
crates/yaak-http/src/cookies.rs | 581 +++
crates/yaak-http/src/decompress.rs | 188 +
crates/yaak-http/src/dns.rs | 225 ++
crates/yaak-http/src/error.rs | 43 +
crates/yaak-http/src/lib.rs | 16 +
crates/yaak-http/src/manager.rs | 53 +
crates/yaak-http/src/proto.rs | 29 +
crates/yaak-http/src/sender.rs | 641 ++++
crates/yaak-http/src/tee_reader.rs | 159 +
crates/yaak-http/src/transaction.rs | 1089 ++++++
crates/yaak-http/src/types.rs | 1262 +++++++
crates/yaak-http/tests/test.txt | 1 +
crates/yaak-lifecycle/Cargo.toml | 10 +
crates/yaak-lifecycle/src/lib.rs | 126 +
crates/yaak-models/Cargo.toml | 29 +
.../yaak-models/bindings/ModelChangeEvent.ts | 3 +
crates/yaak-models/bindings/gen_models.ts | 580 +++
crates/yaak-models/bindings/gen_util.ts | 107 +
.../blob_migrations/00000000000000_init.sql | 12 +
crates/yaak-models/build.rs | 5 +
crates/yaak-models/guest-js/atoms.test.ts | 59 +
crates/yaak-models/guest-js/atoms.ts | 87 +
crates/yaak-models/guest-js/index.ts | 11 +
crates/yaak-models/guest-js/store.ts | 413 ++
crates/yaak-models/guest-js/types.ts | 8 +
crates/yaak-models/guest-js/util.ts | 26 +
.../migrations/20230225181302_init.sql | 65 +
.../20230319042610_sort-priority.sql | 1 +
.../20230330143214_request-auth.sql | 2 +
.../20230413232435_response-body-blob.sql | 5 +
.../20231022205109_environments.sql | 15 +
.../migrations/20231028161007_variables.sql | 2 +
.../20231103004111_workspace-variables.sql | 1 +
.../migrations/20231103142807_folders.sql | 19 +
.../migrations/20231112180500_body_object.sql | 16 +
.../migrations/20231113183810_url_params.sql | 2 +
.../migrations/20231122055216_remove_body.sql | 1 +
.../migrations/20240111221224_settings.sql | 13 +
.../20240115193751_workspace_settings.sql | 9 +
.../20240118181105_channel_setting.sql | 1 +
.../migrations/20240127013915_cookies.sql | 10 +
.../20240128230717_more_response_attrs.sql | 3 +
.../migrations/20240203164833_grpc.sql | 68 +
.../20240522031045_theme-settings.sql | 4 +
.../20240529143147_more-settings.sql | 4 +
.../20240607151115_open-workspace-setting.sql | 1 +
.../20240814013812_fix-env-model.sql | 2 +
.../20240826184943_disable-telemetry.sql | 1 +
.../migrations/20240829131004_plugins.sql | 12 +
.../20241003134208_response-state.sql | 5 +
.../20241012181547_proxy-setting.sql | 1 +
.../migrations/20241217204951_docs.sql | 8 +
.../20241219140051_base-environments.sql | 45 +
.../migrations/20250102141937_sync.sql | 21 +
.../20250108035425_editor-keymap.sql | 2 +
.../20250108205117_workspace-meta.sql | 11 +
...14160022_remove-workspace-sync-setting.sql | 2 +
.../migrations/20250123192023_plugin-kv.sql | 11 +
.../migrations/20250128155623_websockets.sql | 66 +
.../20250302041707_hide-window-controls.sql | 2 +
.../20250326193143_key-value-id.sql | 43 +
.../20250401122407_encrypted-key.sql | 1 +
...0250402144842_encryption-key-challenge.sql | 1 +
.../migrations/20250424152740_remove-fks.sql | 243 ++
.../20250507140702_remove-ev-sync-states.sql | 11 +
.../20250508161145_public-environments.sql | 20 +
.../20250516182745_default-attrs.sql | 15 +
.../20250530174021_graphql-introspection.sql | 21 +
.../20250531193722_sync-state-index.sql | 43 +
...20250604102922_colored-methods-setting.sql | 2 +
.../20250608150053_font-settings.sql | 2 +
.../20250611120000_environment-color.sql | 1 +
.../20250727190746_autoupdate_setting.sql | 1 +
...0918141129_request-folder-environments.sql | 62 +
.../20250929132954_dismiss-license-badge.sql | 10 +
.../20251001082054_auto-download.sql | 2 +
...1028060300_check_notifications_setting.sql | 1 +
.../20251029062024_aws-auth-name.sql | 11 +
...251031070515_environment-sort-priority.sql | 2 +
.../20251202080000_use-native-titlebar.sql | 3 +
.../20251209000000_client-certificates.sql | 1 +
...251219074602_default-workspace-headers.sql | 15 +
...0251220000000_response-request-headers.sql | 3 +
.../20251221000000_http-response-events.sql | 15 +
.../20251221100000_request-content-length.sql | 2 +
.../migrations/20260104000000_hotkeys.sql | 1 +
.../migrations/20260111000000_dns-timing.sql | 2 +
.../20260112000000_dns-overrides.sql | 2 +
...45146_remove-default-workspace-headers.sql | 12 +
.../20260216000000_model-changes.sql | 12 +
...60217000000_remove-legacy-faker-plugin.sql | 3 +
...000_plugin-source-and-unique-directory.sql | 33 +
...60302000000_inherited-request-settings.sql | 17 +
.../20260303000000_protocol-tls-settings.sql | 3 +
.../20260629000000_request-message-size.sql | 7 +
.../20260704000000_prompt-feedback.sql | 3 +
.../20260826000000_http-version.sql | 5 +
.../20260901000000_import-sources.sql | 25 +
...60902000000_import-source-content-hash.sql | 24 +
crates/yaak-models/package.json | 6 +
crates/yaak-models/src/blob_manager.rs | 414 ++
crates/yaak-models/src/client_db.rs | 176 +
crates/yaak-models/src/connection_or_tx.rs | 1 +
crates/yaak-models/src/cookies.rs | 107 +
crates/yaak-models/src/error.rs | 69 +
crates/yaak-models/src/example.rs | 104 +
crates/yaak-models/src/example_workspace.json | 181 +
crates/yaak-models/src/export.rs | 33 +
crates/yaak-models/src/lib.rs | 183 +
crates/yaak-models/src/migrate.rs | 147 +
crates/yaak-models/src/models.rs | 3358 +++++++++++++++++
crates/yaak-models/src/models_ops.rs | 89 +
crates/yaak-models/src/path_placeholders.rs | 220 ++
crates/yaak-models/src/queries/any_request.rs | 21 +
crates/yaak-models/src/queries/batch.rs | 73 +
crates/yaak-models/src/queries/cookie_jars.rs | 50 +
.../yaak-models/src/queries/duplicate_name.rs | 56 +
.../yaak-models/src/queries/environments.rs | 362 ++
crates/yaak-models/src/queries/folders.rs | 223 ++
.../src/queries/graphql_introspections.rs | 54 +
.../src/queries/grpc_connections.rs | 97 +
crates/yaak-models/src/queries/grpc_events.rs | 24 +
.../yaak-models/src/queries/grpc_requests.rs | 150 +
.../yaak-models/src/queries/http_requests.rs | 312 ++
.../src/queries/http_response_events.rs | 20 +
.../yaak-models/src/queries/http_responses.rs | 300 ++
.../src/queries/import_source_resources.rs | 96 +
.../yaak-models/src/queries/import_sources.rs | 47 +
crates/yaak-models/src/queries/key_values.rs | 179 +
crates/yaak-models/src/queries/mod.rs | 88 +
.../yaak-models/src/queries/model_changes.rs | 330 ++
.../src/queries/plugin_key_values.rs | 85 +
crates/yaak-models/src/queries/plugins.rs | 37 +
crates/yaak-models/src/queries/settings.rs | 61 +
crates/yaak-models/src/queries/sync_states.rs | 48 +
.../src/queries/websocket_connections.rs | 100 +
.../src/queries/websocket_events.rs | 24 +
.../src/queries/websocket_requests.rs | 172 +
.../src/queries/workspace_metas.rs | 39 +
crates/yaak-models/src/queries/workspaces.rs | 218 ++
crates/yaak-models/src/query_manager.rs | 85 +
crates/yaak-models/src/render.rs | 250 ++
crates/yaak-models/src/util.rs | 328 ++
crates/yaak-plugins/Cargo.toml | 30 +
crates/yaak-plugins/bindings/gen_api.ts | 8 +
crates/yaak-plugins/bindings/gen_events.ts | 663 ++++
crates/yaak-plugins/bindings/gen_models.ts | 514 +++
crates/yaak-plugins/bindings/gen_search.ts | 5 +
.../bindings/serde_json/JsonValue.ts | 3 +
crates/yaak-plugins/index.ts | 30 +
crates/yaak-plugins/package.json | 6 +
crates/yaak-plugins/src/api.rs | 135 +
crates/yaak-plugins/src/checksum.rs | 8 +
crates/yaak-plugins/src/error.rs | 64 +
crates/yaak-plugins/src/events.rs | 1639 ++++++++
crates/yaak-plugins/src/install.rs | 92 +
crates/yaak-plugins/src/lib.rs | 21 +
crates/yaak-plugins/src/manager.rs | 1255 ++++++
.../src/native_template_functions.rs | 269 ++
crates/yaak-plugins/src/nodejs.rs | 98 +
crates/yaak-plugins/src/plugin_handle.rs | 66 +
crates/yaak-plugins/src/plugin_meta.rs | 64 +
crates/yaak-plugins/src/server_ws.rs | 150 +
crates/yaak-plugins/src/template_callback.rs | 87 +
crates/yaak-plugins/src/util.rs | 6 +
crates/yaak-proxy/Cargo.toml | 25 +
crates/yaak-proxy/src/body.rs | 113 +
crates/yaak-proxy/src/cert.rs | 69 +
crates/yaak-proxy/src/connection.rs | 32 +
crates/yaak-proxy/src/lib.rs | 183 +
crates/yaak-proxy/src/request.rs | 339 ++
crates/yaak-sse/Cargo.toml | 9 +
crates/yaak-sse/bindings/sse.ts | 3 +
crates/yaak-sse/index.ts | 2 +
crates/yaak-sse/package.json | 12 +
crates/yaak-sse/src/lib.rs | 1 +
crates/yaak-sse/src/sse.rs | 12 +
crates/yaak-sse/summary.test.ts | 51 +
crates/yaak-sse/summary.ts | 131 +
crates/yaak-sync/Cargo.toml | 20 +
crates/yaak-sync/bindings/gen_models.ts | 192 +
crates/yaak-sync/bindings/gen_sync.ts | 6 +
crates/yaak-sync/bindings/gen_watch.ts | 3 +
crates/yaak-sync/bindings/git.ts | 7 +
crates/yaak-sync/index.ts | 82 +
crates/yaak-sync/package.json | 6 +
crates/yaak-sync/src/error.rs | 44 +
crates/yaak-sync/src/lib.rs | 4 +
crates/yaak-sync/src/models.rs | 311 ++
crates/yaak-sync/src/sync.rs | 605 +++
crates/yaak-sync/src/watch.rs | 90 +
crates/yaak-templates/Cargo.toml | 29 +
crates/yaak-templates/bindings/parser.ts | 9 +
crates/yaak-templates/build-wasm.cjs | 39 +
crates/yaak-templates/index.ts | 15 +
crates/yaak-templates/package.json | 15 +
crates/yaak-templates/pkg/package.json | 17 +
crates/yaak-templates/pkg/yaak_templates.d.ts | 8 +
crates/yaak-templates/pkg/yaak_templates.js | 6 +
.../yaak-templates/pkg/yaak_templates_bg.js | 241 ++
.../yaak-templates/pkg/yaak_templates_bg.wasm | Bin 0 -> 67385 bytes
.../pkg/yaak_templates_bg.wasm.d.ts | 11 +
crates/yaak-templates/src/error.rs | 32 +
crates/yaak-templates/src/escape.rs | 166 +
crates/yaak-templates/src/format_json.rs | 533 +++
crates/yaak-templates/src/lib.rs | 11 +
crates/yaak-templates/src/parser.rs | 904 +++++
crates/yaak-templates/src/renderer.rs | 691 ++++
.../yaak-templates/src/strip_json_comments.rs | 326 ++
crates/yaak-templates/src/wasm.rs | 22 +
crates/yaak-tls/Cargo.toml | 18 +
crates/yaak-tls/src/error.rs | 26 +
crates/yaak-tls/src/lib.rs | 499 +++
crates/yaak-wasm/Cargo.toml | 41 +
crates/yaak-wasm/build-wasm.cjs | 69 +
crates/yaak-wasm/index.ts | 6 +
crates/yaak-wasm/package.json | 15 +
crates/yaak-wasm/pkg/package.json | 17 +
crates/yaak-wasm/pkg/yaak_wasm.d.ts | 62 +
crates/yaak-wasm/pkg/yaak_wasm.js | 9 +
crates/yaak-wasm/pkg/yaak_wasm_bg.js | 1009 +++++
crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts | 33 +
crates/yaak-wasm/src/lib.rs | 641 ++++
crates/yaak-ws/Cargo.toml | 24 +
crates/yaak-ws/index.ts | 43 +
crates/yaak-ws/package.json | 6 +
crates/yaak-ws/src/connect.rs | 60 +
crates/yaak-ws/src/error.rs | 32 +
crates/yaak-ws/src/lib.rs | 12 +
crates/yaak-ws/src/manager.rs | 121 +
crates/yaak-ws/src/render.rs | 80 +
crates/yaak/Cargo.toml | 28 +
crates/yaak/src/error.rs | 21 +
crates/yaak/src/import.rs | 3019 +++++++++++++++
crates/yaak/src/lib.rs | 8 +
crates/yaak/src/plugin_events.rs | 815 ++++
crates/yaak/src/response_body.rs | 235 ++
crates/yaak/src/send.rs | 1539 ++++++++
flatpak/app.yaak.Yaak.metainfo.xml | 57 +
flatpak/fix-lockfile.mjs | 75 +
flatpak/generate-sources.sh | 48 +
flatpak/update-manifest.sh | 86 +
greptile.json | 3 +
npm/cli-darwin-arm64/bin/.gitkeep | 0
npm/cli-darwin-arm64/package.json | 15 +
npm/cli-darwin-x64/bin/.gitkeep | 0
npm/cli-darwin-x64/package.json | 15 +
npm/cli-linux-arm64/bin/.gitkeep | 0
npm/cli-linux-arm64/package.json | 15 +
npm/cli-linux-x64/bin/.gitkeep | 0
npm/cli-linux-x64/package.json | 15 +
npm/cli-win32-arm64/bin/.gitkeep | 0
npm/cli-win32-arm64/package.json | 15 +
npm/cli-win32-x64/bin/.gitkeep | 0
npm/cli-win32-x64/package.json | 15 +
npm/cli/.gitignore | 2 +
npm/cli/bin/cli.js | 31 +
npm/cli/common.js | 20 +
npm/cli/index.js | 21 +
npm/cli/install.js | 97 +
npm/cli/package.json | 26 +
npm/cli/prepublish.js | 5 +
npm/prepare-publish.js | 77 +
package.json | 150 +
packages/common-lib/debounce.test.ts | 76 +
packages/common-lib/debounce.ts | 32 +
packages/common-lib/eagerDebounceAsync.ts | 36 +
packages/common-lib/formatSize.ts | 20 +
packages/common-lib/index.ts | 4 +
packages/common-lib/jsonPath.ts | 39 +
packages/common-lib/package.json | 6 +
packages/common-lib/templateFunction.ts | 49 +
packages/model-store/package.json | 11 +
packages/model-store/src/index.ts | 94 +
packages/platform/package.json | 23 +
packages/platform/src/capabilities.ts | 14 +
packages/platform/src/index.ts | 15 +
packages/platform/src/index.web.ts | 23 +
packages/platform/src/registry.ts | 66 +
packages/platform/src/tauri/index.ts | 228 ++
packages/platform/src/types.ts | 318 ++
packages/platform/src/web/README.md | 228 ++
packages/platform/src/web/commands.ts | 373 ++
packages/platform/src/web/connection.ts | 236 ++
packages/platform/src/web/errors.ts | 32 +
packages/platform/src/web/ids.ts | 14 +
packages/platform/src/web/index.ts | 383 ++
packages/platform/src/web/protocol.ts | 53 +
packages/platform/src/web/send.ts | 352 ++
packages/platform/src/web/server.ts | 83 +
packages/platform/src/web/storage.ts | 18 +
packages/platform/src/web/worker.ts | 196 +
packages/platform/tsconfig.json | 12 +
packages/plugin-runtime-types/.gitignore | 2 +
packages/plugin-runtime-types/README.md | 28 +
packages/plugin-runtime-types/package.json | 38 +
.../src/bindings/gen_api.ts | 8 +
.../src/bindings/gen_events.ts | 663 ++++
.../src/bindings/gen_models.ts | 514 +++
.../src/bindings/gen_search.ts | 5 +
.../src/bindings/serde_json/JsonValue.ts | 3 +
packages/plugin-runtime-types/src/helpers.ts | 2 +
packages/plugin-runtime-types/src/index.ts | 9 +
.../src/plugins/AuthenticationPlugin.ts | 45 +
.../src/plugins/Context.ts | 236 ++
.../src/plugins/FilterPlugin.ts | 11 +
.../src/plugins/FolderActionPlugin.ts | 6 +
.../src/plugins/GrpcRequestActionPlugin.ts | 6 +
.../src/plugins/HttpRequestActionPlugin.ts | 6 +
.../src/plugins/ImporterPlugin.ts | 31 +
.../src/plugins/TemplateFunctionPlugin.ts | 32 +
.../src/plugins/ThemePlugin.ts | 3 +
.../plugins/WebsocketRequestActionPlugin.ts | 9 +
.../src/plugins/WorkspaceActionPlugin.ts | 6 +
.../plugin-runtime-types/src/plugins/index.ts | 44 +
.../plugin-runtime-types/src/themes/index.ts | 44 +
packages/plugin-runtime-types/tsconfig.json | 14 +
packages/plugin-runtime/.node-version | 1 +
packages/plugin-runtime/package.json | 16 +
packages/plugin-runtime/src/EventChannel.ts | 19 +
packages/plugin-runtime/src/PluginHandle.ts | 26 +
packages/plugin-runtime/src/PluginInstance.ts | 1099 ++++++
packages/plugin-runtime/src/common.ts | 75 +
packages/plugin-runtime/src/index.ts | 73 +
.../plugin-runtime/src/interceptStdout.ts | 29 +
packages/plugin-runtime/src/metadata.ts | 190 +
packages/plugin-runtime/src/migrations.ts | 22 +
packages/plugin-runtime/src/responseBody.ts | 194 +
packages/plugin-runtime/tests/common.test.ts | 151 +
.../plugin-runtime/tests/responseBody.test.ts | 203 +
.../tests/sendHttpRequest.test.ts | 65 +
packages/plugin-runtime/tsconfig.json | 17 +
packages/tailwind-config/index.css | 124 +
packages/tailwind-config/package.json | 6 +
packages/theme/package.json | 13 +
packages/theme/src/appearance.ts | 85 +
packages/theme/src/defaultThemes.ts | 76 +
packages/theme/src/index.ts | 28 +
packages/theme/src/window.ts | 413 ++
packages/theme/src/yaakColor.ts | 386 ++
packages/theme/tsconfig.json | 12 +
packages/ui/package.json | 11 +
packages/ui/src/components/Banner.tsx | 28 +
packages/ui/src/components/Button.tsx | 127 +
packages/ui/src/components/DropMarker.tsx | 34 +
packages/ui/src/components/FormattedError.tsx | 23 +
packages/ui/src/components/HeaderSize.tsx | 100 +
packages/ui/src/components/Heading.tsx | 22 +
packages/ui/src/components/Icon.tsx | 340 ++
packages/ui/src/components/IconButton.tsx | 95 +
packages/ui/src/components/InlineCode.tsx | 15 +
packages/ui/src/components/LoadingIcon.tsx | 35 +
packages/ui/src/components/Overlay.tsx | 90 +
packages/ui/src/components/Portal.tsx | 13 +
packages/ui/src/components/ResizeHandle.tsx | 112 +
packages/ui/src/components/SidebarLayout.tsx | 146 +
packages/ui/src/components/SplitLayout.tsx | 168 +
packages/ui/src/components/Stacks.tsx | 93 +
packages/ui/src/components/Table.tsx | 128 +
packages/ui/src/components/WindowControls.tsx | 99 +
packages/ui/src/components/tree/Tree.tsx | 835 ++++
.../src/components/tree/TreeDragOverlay.tsx | 31 +
.../ui/src/components/tree/TreeDropMarker.tsx | 36 +
.../src/components/tree/TreeIndentGuide.tsx | 32 +
packages/ui/src/components/tree/TreeItem.tsx | 415 ++
.../ui/src/components/tree/TreeItemList.tsx | 191 +
packages/ui/src/components/tree/atoms.ts | 76 +
packages/ui/src/components/tree/common.ts | 100 +
packages/ui/src/components/tree/context.ts | 60 +
.../ui/src/components/tree/keyboard.test.ts | 20 +
packages/ui/src/components/tree/keyboard.ts | 7 +
.../src/components/tree/useSelectableItems.ts | 30 +
packages/ui/src/hooks/useContainerSize.ts | 30 +
packages/ui/src/hooks/useDebouncedState.ts | 12 +
packages/ui/src/hooks/useDebouncedValue.ts | 8 +
packages/ui/src/hooks/useIsFullscreen.ts | 22 +
packages/ui/src/hooks/usePortal.ts | 26 +
packages/ui/src/hooks/useTimedBoolean.ts | 19 +
packages/ui/src/index.ts | 48 +
packages/ui/src/lib/clamp.ts | 3 +
packages/ui/src/lib/constants.ts | 4 +
packages/ui/src/lib/dnd.ts | 39 +
packages/ui/src/lib/minPromiseMillis.ts | 16 +
packages/ui/tsconfig.json | 14 +
plugins-external/.gitignore | 1 +
plugins-external/faker/README.md | 76 +
plugins-external/faker/package.json | 24 +
plugins-external/faker/src/index.ts | 106 +
.../tests/__snapshots__/init.test.ts.snap | 234 ++
plugins-external/faker/tests/init.test.ts | 31 +
plugins-external/faker/tsconfig.json | 3 +
plugins-external/httpsnippet/README.md | 52 +
plugins-external/httpsnippet/package.json | 24 +
plugins-external/httpsnippet/src/index.ts | 317 ++
plugins-external/mcp-server/README.md | 58 +
plugins-external/mcp-server/package.json | 28 +
plugins-external/mcp-server/src/index.ts | 36 +
plugins-external/mcp-server/src/server.ts | 72 +
.../mcp-server/src/tools/folder.ts | 148 +
.../mcp-server/src/tools/helpers.ts | 32 +
.../mcp-server/src/tools/httpRequest.test.ts | 98 +
.../mcp-server/src/tools/httpRequest.ts | 201 +
.../mcp-server/src/tools/schemas.ts | 67 +
.../mcp-server/src/tools/toast.ts | 59 +
.../mcp-server/src/tools/window.ts | 47 +
.../mcp-server/src/tools/workspace.ts | 24 +
plugins-external/mcp-server/src/types.ts | 5 +
plugins-external/mcp-server/tsconfig.json | 7 +
plugins/.gitignore | 1 +
plugins/action-copy-curl/README.md | 68 +
plugins/action-copy-curl/package.json | 17 +
plugins/action-copy-curl/src/index.ts | 179 +
plugins/action-copy-curl/tests/index.test.ts | 470 +++
plugins/action-copy-curl/tsconfig.json | 3 +
plugins/action-copy-grpcurl/README.md | 75 +
plugins/action-copy-grpcurl/package.json | 17 +
plugins/action-copy-grpcurl/src/index.ts | 158 +
.../action-copy-grpcurl/tests/index.test.ts | 195 +
plugins/action-copy-grpcurl/tsconfig.json | 3 +
plugins/action-send-folder/package.json | 16 +
plugins/action-send-folder/src/index.ts | 99 +
plugins/action-send-folder/tsconfig.json | 3 +
plugins/auth-apikey/package.json | 16 +
plugins/auth-apikey/src/index.ts | 54 +
plugins/auth-apikey/tsconfig.json | 3 +
plugins/auth-aws/README.md | 49 +
plugins/auth-aws/package.json | 22 +
plugins/auth-aws/src/index.ts | 89 +
plugins/auth-aws/tsconfig.json | 3 +
plugins/auth-basic/README.md | 44 +
plugins/auth-basic/package.json | 17 +
plugins/auth-basic/src/index.ts | 30 +
plugins/auth-basic/tests/index.test.ts | 87 +
plugins/auth-basic/tsconfig.json | 3 +
plugins/auth-bearer/README.md | 47 +
plugins/auth-bearer/package.json | 17 +
plugins/auth-bearer/src/index.ts | 39 +
plugins/auth-bearer/tests/index.test.ts | 67 +
plugins/auth-bearer/tsconfig.json | 3 +
plugins/auth-digest/README.md | 56 +
plugins/auth-digest/package.json | 17 +
plugins/auth-digest/src/digest.ts | 294 ++
plugins/auth-digest/src/index.ts | 75 +
plugins/auth-digest/tests/digest.test.ts | 390 ++
plugins/auth-digest/tests/index.test.ts | 375 ++
plugins/auth-digest/tsconfig.json | 3 +
plugins/auth-jwt/README.md | 54 +
plugins/auth-jwt/package.json | 22 +
plugins/auth-jwt/src/index.ts | 148 +
plugins/auth-jwt/tsconfig.json | 3 +
plugins/auth-ntlm/package.json | 20 +
plugins/auth-ntlm/src/index.ts | 94 +
plugins/auth-ntlm/src/modules.d.ts | 1 +
plugins/auth-ntlm/tests/index.test.ts | 89 +
plugins/auth-ntlm/tsconfig.json | 3 +
plugins/auth-oauth1/package.json | 20 +
plugins/auth-oauth1/src/index.ts | 217 ++
plugins/auth-oauth1/tests/plaintext.test.ts | 57 +
plugins/auth-oauth1/tsconfig.json | 3 +
plugins/auth-oauth2/README.md | 74 +
plugins/auth-oauth2/package.json | 23 +
plugins/auth-oauth2/src/callbackServer.ts | 345 ++
plugins/auth-oauth2/src/customParams.ts | 121 +
plugins/auth-oauth2/src/fetchAccessToken.ts | 104 +
.../src/getOrRefreshAccessToken.ts | 128 +
.../src/grants/authorizationCode.ts | 218 ++
.../src/grants/clientCredentials.ts | 175 +
plugins/auth-oauth2/src/grants/implicit.ts | 207 +
plugins/auth-oauth2/src/grants/password.ts | 69 +
plugins/auth-oauth2/src/index.ts | 680 ++++
plugins/auth-oauth2/src/store.ts | 86 +
plugins/auth-oauth2/src/util.ts | 112 +
.../tests/authorizationCode.test.ts | 137 +
.../auth-oauth2/tests/customParams.test.ts | 164 +
.../tests/fetchAccessToken.test.ts | 198 +
.../tests/getOrRefreshAccessToken.test.ts | 141 +
plugins/auth-oauth2/tests/store.test.ts | 56 +
plugins/auth-oauth2/tests/util.test.ts | 162 +
plugins/auth-oauth2/tsconfig.json | 3 +
plugins/filter-jsonpath/README.md | 59 +
plugins/filter-jsonpath/package.json | 24 +
plugins/filter-jsonpath/src/index.ts | 39 +
.../filter-jsonpath/tests/breadcrumbs.test.ts | 130 +
plugins/filter-jsonpath/tsconfig.json | 3 +
plugins/filter-xpath/package.json | 15 +
plugins/filter-xpath/src/index.ts | 28 +
plugins/filter-xpath/tsconfig.json | 3 +
plugins/importer-curl/package.json | 15 +
plugins/importer-curl/src/graphql.ts | 117 +
plugins/importer-curl/src/index.ts | 775 ++++
plugins/importer-curl/tests/graphql.test.ts | 146 +
plugins/importer-curl/tests/index.test.ts | 1026 +++++
plugins/importer-curl/tsconfig.json | 3 +
plugins/importer-insomnia/package.json | 15 +
plugins/importer-insomnia/src/common.ts | 147 +
plugins/importer-insomnia/src/index.ts | 37 +
plugins/importer-insomnia/src/v4.ts | 190 +
plugins/importer-insomnia/src/v5.ts | 285 ++
plugins/importer-insomnia/tests/body.test.ts | 91 +
.../tests/fixtures/basic.input.json | 187 +
.../tests/fixtures/basic.output.json | 144 +
.../fixtures/version-5-minimal.input.yaml | 74 +
.../fixtures/version-5-minimal.output.json | 128 +
.../tests/fixtures/version-5.input.yaml | 146 +
.../tests/fixtures/version-5.output.json | 203 +
plugins/importer-insomnia/tests/index.test.ts | 67 +
plugins/importer-insomnia/tsconfig.json | 3 +
plugins/importer-openapi/package.json | 16 +
plugins/importer-openapi/src/index.ts | 2268 +++++++++++
.../tests/__snapshots__/index.test.ts.snap | 2947 +++++++++++++++
.../tests/fixtures/petstore.yaml | 819 ++++
.../tests/fixtures/real-world/SOURCES.md | 8 +
.../tests/fixtures/real-world/apis-guru.yaml | 399 ++
.../tests/fixtures/real-world/httpbin.yaml | 1121 ++++++
.../tests/fixtures/real-world/nasa-apod.yaml | 69 +
.../tests/fixtures/real-world/xkcd.yaml | 78 +
plugins/importer-openapi/tests/index.test.ts | 2419 ++++++++++++
plugins/importer-openapi/tests/roundtrip.mjs | 306 ++
plugins/importer-openapi/tsconfig.json | 3 +
.../importer-postman-environment/package.json | 13 +
.../importer-postman-environment/src/index.ts | 135 +
.../tests/fixtures/environment.input.json | 27 +
.../tests/fixtures/environment.output.json | 35 +
.../tests/index.test.ts | 22 +
.../tsconfig.json | 3 +
plugins/importer-postman/package.json | 13 +
plugins/importer-postman/src/index.ts | 659 ++++
.../tests/fixtures/auth.input.json | 1032 +++++
.../tests/fixtures/auth.output.json | 421 +++
.../fixtures/dynamic-variables.input.json | 67 +
.../fixtures/dynamic-variables.output.json | 95 +
.../tests/fixtures/nested.input.json | 38 +
.../tests/fixtures/nested.output.json | 99 +
.../tests/fixtures/params.input.json | 126 +
.../tests/fixtures/params.output.json | 107 +
plugins/importer-postman/tests/index.test.ts | 201 +
plugins/importer-postman/tsconfig.json | 3 +
plugins/importer-yaak/package.json | 12 +
plugins/importer-yaak/src/index.ts | 96 +
plugins/importer-yaak/tests/index.test.ts | 179 +
plugins/importer-yaak/tsconfig.json | 3 +
.../template-function-1password/package.json | 19 +
.../template-function-1password/src/index.ts | 283 ++
.../template-function-1password/tsconfig.json | 3 +
plugins/template-function-cookie/package.json | 11 +
plugins/template-function-cookie/src/index.ts | 37 +
.../template-function-cookie/tsconfig.json | 3 +
plugins/template-function-ctx/package.json | 11 +
plugins/template-function-ctx/src/index.ts | 30 +
plugins/template-function-ctx/tsconfig.json | 3 +
plugins/template-function-encode/package.json | 11 +
plugins/template-function-encode/src/index.ts | 62 +
.../template-function-encode/tsconfig.json | 3 +
plugins/template-function-fs/package.json | 11 +
plugins/template-function-fs/src/index.ts | 51 +
plugins/template-function-fs/tsconfig.json | 3 +
plugins/template-function-hash/package.json | 11 +
plugins/template-function-hash/src/index.ts | 82 +
plugins/template-function-hash/tsconfig.json | 3 +
plugins/template-function-json/package.json | 20 +
plugins/template-function-json/src/index.ts | 154 +
.../tests/escape.test.ts | 50 +
plugins/template-function-json/tsconfig.json | 3 +
plugins/template-function-prompt/package.json | 14 +
plugins/template-function-prompt/src/index.ts | 195 +
.../template-function-prompt/tsconfig.json | 3 +
plugins/template-function-random/package.json | 11 +
plugins/template-function-random/src/index.ts | 44 +
.../template-function-random/tsconfig.json | 3 +
plugins/template-function-regex/package.json | 12 +
plugins/template-function-regex/src/index.ts | 76 +
.../tests/regex.test.ts | 196 +
plugins/template-function-regex/tsconfig.json | 3 +
.../template-function-request/package.json | 11 +
.../template-function-request/src/index.ts | 290 ++
.../template-function-request/tsconfig.json | 3 +
.../template-function-response/package.json | 14 +
.../template-function-response/src/index.ts | 335 ++
.../template-function-response/tsconfig.json | 3 +
.../template-function-timestamp/package.json | 16 +
.../template-function-timestamp/src/index.ts | 187 +
.../tests/formatDatetime.test.ts | 72 +
.../template-function-timestamp/tsconfig.json | 3 +
plugins/template-function-uuid/package.json | 14 +
plugins/template-function-uuid/src/index.ts | 81 +
.../template-function-uuid/tests/uuid.test.ts | 37 +
plugins/template-function-uuid/tsconfig.json | 3 +
plugins/template-function-xml/package.json | 17 +
plugins/template-function-xml/src/index.ts | 87 +
plugins/template-function-xml/tsconfig.json | 3 +
plugins/themes-yaak/package.json | 11 +
plugins/themes-yaak/src/index.ts | 116 +
plugins/themes-yaak/src/themes/andromeda.ts | 47 +
.../themes-yaak/src/themes/atom-one-dark.ts | 47 +
plugins/themes-yaak/src/themes/ayu.ts | 122 +
plugins/themes-yaak/src/themes/bluloco.ts | 77 +
plugins/themes-yaak/src/themes/catppuccin.ts | 165 +
plugins/themes-yaak/src/themes/cobalt2.ts | 47 +
plugins/themes-yaak/src/themes/dracula.ts | 29 +
plugins/themes-yaak/src/themes/everforest.ts | 77 +
plugins/themes-yaak/src/themes/fleet.ts | 173 +
.../themes-yaak/src/themes/github-dimmed.ts | 46 +
plugins/themes-yaak/src/themes/github.ts | 55 +
plugins/themes-yaak/src/themes/gruvbox.ts | 21 +
.../themes-yaak/src/themes/high-contrast.ts | 46 +
plugins/themes-yaak/src/themes/horizon.ts | 47 +
.../themes-yaak/src/themes/hotdog-stand.ts | 58 +
.../themes-yaak/src/themes/material-darker.ts | 39 +
.../themes-yaak/src/themes/material-ocean.ts | 43 +
.../src/themes/material-palenight.ts | 45 +
plugins/themes-yaak/src/themes/monokai-pro.ts | 217 ++
plugins/themes-yaak/src/themes/moonlight.ts | 28 +
plugins/themes-yaak/src/themes/night-owl.ts | 78 +
plugins/themes-yaak/src/themes/noctis.ts | 47 +
plugins/themes-yaak/src/themes/nord.ts | 85 +
.../themes-yaak/src/themes/one-dark-pro.ts | 44 +
plugins/themes-yaak/src/themes/panda.ts | 47 +
plugins/themes-yaak/src/themes/relaxing.ts | 18 +
plugins/themes-yaak/src/themes/rose-pine.ts | 111 +
.../src/themes/shades-of-purple.ts | 99 +
plugins/themes-yaak/src/themes/slack.ts | 47 +
plugins/themes-yaak/src/themes/solarized.ts | 77 +
.../themes-yaak/src/themes/synthwave-84.ts | 53 +
plugins/themes-yaak/src/themes/tokyo-night.ts | 121 +
plugins/themes-yaak/src/themes/triangle.ts | 44 +
plugins/themes-yaak/src/themes/vitesse.ts | 77 +
.../src/themes/winter-is-coming.ts | 47 +
plugins/themes-yaak/tsconfig.json | 3 +
rustfmt.toml | 8 +
scripts/.gitignore | 1 +
scripts/create-migration.cjs | 46 +
scripts/extract-archive.cjs | 77 +
scripts/git-hooks/post-checkout.mjs | 219 ++
scripts/install-wasm-pack.cjs | 18 +
scripts/publish-core-plugins.cjs | 19 +
scripts/replace-version.cjs | 15 +
scripts/run-build.mjs | 62 +
scripts/run-dev.mjs | 121 +
scripts/run-web.mjs | 120 +
scripts/run-workspaces-dev.mjs | 85 +
scripts/vendor-node.cjs | 114 +
scripts/vendor-plugins.cjs | 41 +
scripts/vendor-protoc.cjs | 114 +
tsconfig.json | 21 +
vite.config.ts | 35 +
1544 files changed, 164131 insertions(+)
create mode 100644 .claude-context.md
create mode 100644 .claude/rules.md
create mode 100644 .codex/skills/release-generate-release-notes/SKILL.md
create mode 100644 .codex/skills/yaak-changelog/SKILL.md
create mode 100644 .codex/skills/yaak-changelog/agents/openai.yaml
create mode 100644 .dockerignore
create mode 100644 .gitattributes
create mode 100644 .github/FUNDING.yml
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/dependabot.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/scripts/check-contribution-policy.js
create mode 100644 .github/scripts/preserve-release-artifacts.mjs
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/contribution-policy.yml
create mode 100644 .github/workflows/flathub.yml
create mode 100644 .github/workflows/release-api-npm.yml
create mode 100644 .github/workflows/release-app.yml
create mode 100644 .github/workflows/release-cli-npm.yml
create mode 100644 .github/workflows/release-web-image.yml
create mode 100644 .github/workflows/sponsors.yml
create mode 100644 .gitignore
create mode 100644 .node-version
create mode 100644 .npmrc
create mode 100644 .oxfmtrc.json
create mode 100755 .vite-hooks/post-checkout
create mode 100644 .vite-hooks/pre-commit
create mode 100644 .vscode/extensions.json
create mode 100644 .vscode/launch.json
create mode 100644 .vscode/settings.json
create mode 100644 AGENTS.md
create mode 100644 CONTRIBUTING.md
create mode 100644 Cargo.toml
create mode 100644 DEVELOPMENT.md
create mode 100644 Dockerfile.playground
create mode 100644 Dockerfile.web
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 apps/yaak-client/.gitignore
create mode 100644 apps/yaak-client/commands/commands.tsx
create mode 100644 apps/yaak-client/commands/createEnvironment.tsx
create mode 100644 apps/yaak-client/commands/deleteWebsocketConnections.ts
create mode 100644 apps/yaak-client/commands/importCurl.ts
create mode 100644 apps/yaak-client/commands/moveToWorkspace.tsx
create mode 100644 apps/yaak-client/commands/openFolderSettings.tsx
create mode 100644 apps/yaak-client/commands/openSettings.tsx
create mode 100644 apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
create mode 100644 apps/yaak-client/commands/openWorkspaceSettings.tsx
create mode 100644 apps/yaak-client/commands/switchWorkspace.tsx
create mode 100644 apps/yaak-client/components/BinaryFileEditor.tsx
create mode 100644 apps/yaak-client/components/CargoFeature.tsx
create mode 100644 apps/yaak-client/components/CloneGitRepositoryDialog.tsx
create mode 100644 apps/yaak-client/components/ColorIndicator.tsx
create mode 100644 apps/yaak-client/components/CommandPaletteDialog.tsx
create mode 100644 apps/yaak-client/components/CommercialUseBanner.tsx
create mode 100644 apps/yaak-client/components/ConfirmLargeRequestBody.tsx
create mode 100644 apps/yaak-client/components/ConfirmLargeResponse.tsx
create mode 100644 apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
create mode 100644 apps/yaak-client/components/ContextMenus.tsx
create mode 100644 apps/yaak-client/components/CookieDialog.tsx
create mode 100644 apps/yaak-client/components/CookieDropdown.tsx
create mode 100644 apps/yaak-client/components/CopyButton.tsx
create mode 100644 apps/yaak-client/components/CopyIconButton.tsx
create mode 100644 apps/yaak-client/components/CreateDropdown.tsx
create mode 100644 apps/yaak-client/components/CreateEnvironmentDialog.tsx
create mode 100644 apps/yaak-client/components/CreateWorkspaceDialog.tsx
create mode 100644 apps/yaak-client/components/Dialogs.tsx
create mode 100644 apps/yaak-client/components/DnsOverridesEditor.tsx
create mode 100644 apps/yaak-client/components/DropMarker.tsx
create mode 100644 apps/yaak-client/components/DynamicForm.tsx
create mode 100644 apps/yaak-client/components/EmptyStateText.tsx
create mode 100644 apps/yaak-client/components/EncryptionHelp.tsx
create mode 100644 apps/yaak-client/components/EnvironmentActionsDropdown.tsx
create mode 100644 apps/yaak-client/components/EnvironmentColorIndicator.tsx
create mode 100644 apps/yaak-client/components/EnvironmentColorPicker.tsx
create mode 100644 apps/yaak-client/components/EnvironmentEditDialog.tsx
create mode 100644 apps/yaak-client/components/EnvironmentEditor.tsx
create mode 100644 apps/yaak-client/components/EnvironmentSharableTooltip.tsx
create mode 100644 apps/yaak-client/components/ErrorBoundary.tsx
create mode 100644 apps/yaak-client/components/ExportDataDialog.tsx
create mode 100644 apps/yaak-client/components/FeedbackToast.tsx
create mode 100644 apps/yaak-client/components/FolderLayout.tsx
create mode 100644 apps/yaak-client/components/FolderSettingsDialog.tsx
create mode 100644 apps/yaak-client/components/FormMultipartEditor.tsx
create mode 100644 apps/yaak-client/components/FormUrlencodedEditor.tsx
create mode 100644 apps/yaak-client/components/GlobalHooks.tsx
create mode 100644 apps/yaak-client/components/GrpcConnectionLayout.tsx
create mode 100644 apps/yaak-client/components/GrpcEditor.tsx
create mode 100644 apps/yaak-client/components/GrpcProtoSelectionDialog.tsx
create mode 100644 apps/yaak-client/components/GrpcRequestPane.tsx
create mode 100644 apps/yaak-client/components/GrpcResponsePane.tsx
create mode 100644 apps/yaak-client/components/HeadersEditor.tsx
create mode 100644 apps/yaak-client/components/HttpAuthenticationEditor.tsx
create mode 100644 apps/yaak-client/components/HttpRequestLayout.tsx
create mode 100644 apps/yaak-client/components/HttpRequestPane.tsx
create mode 100644 apps/yaak-client/components/HttpResponsePane.tsx
create mode 100644 apps/yaak-client/components/HttpResponseTimeline.tsx
create mode 100644 apps/yaak-client/components/ImportCurl.tsx
create mode 100644 apps/yaak-client/components/ImportDataDialog.tsx
create mode 100644 apps/yaak-client/components/IsDev.tsx
create mode 100644 apps/yaak-client/components/JsonBodyEditor.tsx
create mode 100644 apps/yaak-client/components/KeyboardShortcutsDialog.tsx
create mode 100644 apps/yaak-client/components/LargeValueDialog.tsx
create mode 100644 apps/yaak-client/components/LicenseBadge.tsx
create mode 100644 apps/yaak-client/components/LocalImage.tsx
create mode 100644 apps/yaak-client/components/Markdown.tsx
create mode 100644 apps/yaak-client/components/MarkdownEditor.tsx
create mode 100644 apps/yaak-client/components/ModelSettingsEditor.tsx
create mode 100644 apps/yaak-client/components/MoveToWorkspaceDialog.tsx
create mode 100644 apps/yaak-client/components/Onboarding.tsx
create mode 100644 apps/yaak-client/components/Prose.css
create mode 100644 apps/yaak-client/components/Prose.tsx
create mode 100644 apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx
create mode 100644 apps/yaak-client/components/RecentHttpResponsesDropdown.tsx
create mode 100644 apps/yaak-client/components/RecentRequestsDropdown.tsx
create mode 100644 apps/yaak-client/components/RecentWebsocketConnectionsDropdown.tsx
create mode 100644 apps/yaak-client/components/RedirectToLatestWorkspace.tsx
create mode 100644 apps/yaak-client/components/RequestBodyViewer.tsx
create mode 100644 apps/yaak-client/components/RequestMethodDropdown.tsx
create mode 100644 apps/yaak-client/components/ResponseCookies.tsx
create mode 100644 apps/yaak-client/components/ResponseHeaders.tsx
create mode 100644 apps/yaak-client/components/ResponseInfo.tsx
create mode 100644 apps/yaak-client/components/RouteError.tsx
create mode 100644 apps/yaak-client/components/SelectFile.tsx
create mode 100644 apps/yaak-client/components/Settings/Settings.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsCertificates.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsGeneral.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsHotkeys.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsInterface.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsLicense.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsPlugins.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsProxy.tsx
create mode 100644 apps/yaak-client/components/Settings/SettingsTheme.tsx
create mode 100644 apps/yaak-client/components/SettingsDropdown.tsx
create mode 100644 apps/yaak-client/components/Sidebar.tsx
create mode 100644 apps/yaak-client/components/SidebarActions.tsx
create mode 100644 apps/yaak-client/components/SwitchWorkspaceDialog.tsx
create mode 100644 apps/yaak-client/components/SyncToFilesystemSetting.tsx
create mode 100644 apps/yaak-client/components/TemplateFunctionDialog.tsx
create mode 100644 apps/yaak-client/components/Toasts.tsx
create mode 100644 apps/yaak-client/components/UrlBar.tsx
create mode 100644 apps/yaak-client/components/UrlParameterEditor.tsx
create mode 100644 apps/yaak-client/components/WebsocketRequestLayout.tsx
create mode 100644 apps/yaak-client/components/WebsocketRequestPane.tsx
create mode 100644 apps/yaak-client/components/WebsocketResponsePane.tsx
create mode 100644 apps/yaak-client/components/Workspace.tsx
create mode 100644 apps/yaak-client/components/WorkspaceActionsDropdown.tsx
create mode 100644 apps/yaak-client/components/WorkspaceEncryptionSetting.tsx
create mode 100644 apps/yaak-client/components/WorkspaceHeader.tsx
create mode 100644 apps/yaak-client/components/WorkspaceSettingsDialog.tsx
create mode 100644 apps/yaak-client/components/core/Alert.tsx
create mode 100644 apps/yaak-client/components/core/AutoScroller.tsx
create mode 100644 apps/yaak-client/components/core/BulkPairEditor.test.ts
create mode 100644 apps/yaak-client/components/core/BulkPairEditor.tsx
create mode 100644 apps/yaak-client/components/core/Button.tsx
create mode 100644 apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
create mode 100644 apps/yaak-client/components/core/Checkbox.tsx
create mode 100644 apps/yaak-client/components/core/CheckboxTree.tsx
create mode 100644 apps/yaak-client/components/core/ColorPicker.tsx
create mode 100644 apps/yaak-client/components/core/Confirm.tsx
create mode 100644 apps/yaak-client/components/core/CountBadge.tsx
create mode 100644 apps/yaak-client/components/core/DetailsBanner.tsx
create mode 100644 apps/yaak-client/components/core/Dialog.tsx
create mode 100644 apps/yaak-client/components/core/DismissibleBanner.tsx
create mode 100644 apps/yaak-client/components/core/Dropdown.tsx
create mode 100644 apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts
create mode 100644 apps/yaak-client/components/core/Editor/DiffViewer.css
create mode 100644 apps/yaak-client/components/core/Editor/DiffViewer.tsx
create mode 100644 apps/yaak-client/components/core/Editor/Editor.css
create mode 100644 apps/yaak-client/components/core/Editor/Editor.tsx
create mode 100644 apps/yaak-client/components/core/Editor/LazyEditor.tsx
create mode 100644 apps/yaak-client/components/core/Editor/extensions.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/filter.grammar
create mode 100644 apps/yaak-client/components/core/Editor/filter/filter.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/filter.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/format.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/format.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/highlight.ts
create mode 100644 apps/yaak-client/components/core/Editor/filter/query.ts
create mode 100644 apps/yaak-client/components/core/Editor/genericCompletion.ts
create mode 100644 apps/yaak-client/components/core/Editor/hyperlink/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/json-lint.ts
create mode 100644 apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts
create mode 100644 apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/json/jsonPath.ts
create mode 100644 apps/yaak-client/components/core/Editor/largeValues.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/largeValues.ts
create mode 100644 apps/yaak-client/components/core/Editor/pairs/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/pairs/highlight.ts
create mode 100644 apps/yaak-client/components/core/Editor/pairs/pairs.grammar
create mode 100644 apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts
create mode 100644 apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/pairs/pairs.ts
create mode 100644 apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/searchMatchCount.ts
create mode 100644 apps/yaak-client/components/core/Editor/singleLine.ts
create mode 100644 apps/yaak-client/components/core/Editor/sniffValue.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/sniffValue.ts
create mode 100644 apps/yaak-client/components/core/Editor/text/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/text/text.grammar
create mode 100644 apps/yaak-client/components/core/Editor/text/text.terms.ts
create mode 100644 apps/yaak-client/components/core/Editor/text/text.ts
create mode 100644 apps/yaak-client/components/core/Editor/timeline/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/timeline/highlight.ts
create mode 100644 apps/yaak-client/components/core/Editor/timeline/timeline.grammar
create mode 100644 apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts
create mode 100644 apps/yaak-client/components/core/Editor/timeline/timeline.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/completion.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/completion.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/highlight.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/pathParameters.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/templateTags.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/twig.grammar
create mode 100644 apps/yaak-client/components/core/Editor/twig/twig.terms.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/twig.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/twig.ts
create mode 100644 apps/yaak-client/components/core/Editor/twig/util.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/completion.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/completion.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/extension.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/highlight.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/url.grammar
create mode 100644 apps/yaak-client/components/core/Editor/url/url.terms.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/url.test.ts
create mode 100644 apps/yaak-client/components/core/Editor/url/url.ts
create mode 100644 apps/yaak-client/components/core/EventViewer.tsx
create mode 100644 apps/yaak-client/components/core/EventViewerRow.tsx
create mode 100644 apps/yaak-client/components/core/Hotkey.tsx
create mode 100644 apps/yaak-client/components/core/HotkeyLabel.tsx
create mode 100644 apps/yaak-client/components/core/HotkeyList.test.tsx
create mode 100644 apps/yaak-client/components/core/HotkeyList.tsx
create mode 100644 apps/yaak-client/components/core/HttpMethodTag.tsx
create mode 100644 apps/yaak-client/components/core/HttpResponseDurationTag.tsx
create mode 100644 apps/yaak-client/components/core/HttpStatusTag.tsx
create mode 100644 apps/yaak-client/components/core/IconButton.tsx
create mode 100644 apps/yaak-client/components/core/IconTooltip.tsx
create mode 100644 apps/yaak-client/components/core/Input.tsx
create mode 100644 apps/yaak-client/components/core/JsonAttributeTree.tsx
create mode 100644 apps/yaak-client/components/core/KeyValueRow.tsx
create mode 100644 apps/yaak-client/components/core/Label.tsx
create mode 100644 apps/yaak-client/components/core/Link.tsx
create mode 100644 apps/yaak-client/components/core/PairEditor.tsx
create mode 100644 apps/yaak-client/components/core/PairEditor.util.tsx
create mode 100644 apps/yaak-client/components/core/PairOrBulkEditor.tsx
create mode 100644 apps/yaak-client/components/core/PillButton.tsx
create mode 100644 apps/yaak-client/components/core/PlainInput.tsx
create mode 100644 apps/yaak-client/components/core/Prompt.tsx
create mode 100644 apps/yaak-client/components/core/RadioCards.tsx
create mode 100644 apps/yaak-client/components/core/RadioDropdown.tsx
create mode 100644 apps/yaak-client/components/core/SegmentedControl.tsx
create mode 100644 apps/yaak-client/components/core/Select.tsx
create mode 100644 apps/yaak-client/components/core/Separator.tsx
create mode 100644 apps/yaak-client/components/core/SettingRow.tsx
create mode 100644 apps/yaak-client/components/core/SizeTag.tsx
create mode 100644 apps/yaak-client/components/core/Tabs/Tabs.tsx
create mode 100644 apps/yaak-client/components/core/Toast.tsx
create mode 100644 apps/yaak-client/components/core/Tooltip.tsx
create mode 100644 apps/yaak-client/components/core/WebsocketStatusTag.tsx
create mode 100644 apps/yaak-client/components/git/BranchSelectionDialog.tsx
create mode 100644 apps/yaak-client/components/git/FileHistoryDialog.tsx
create mode 100644 apps/yaak-client/components/git/GitCommitDialog.tsx
create mode 100644 apps/yaak-client/components/git/GitDropdown.tsx
create mode 100644 apps/yaak-client/components/git/GitRemotesDialog.tsx
create mode 100644 apps/yaak-client/components/git/HistoryDialog.tsx
create mode 100644 apps/yaak-client/components/git/callbacks.tsx
create mode 100644 apps/yaak-client/components/git/credentials.tsx
create mode 100644 apps/yaak-client/components/git/diverged.tsx
create mode 100644 apps/yaak-client/components/git/git-util.ts
create mode 100644 apps/yaak-client/components/git/showAddRemoteDialog.tsx
create mode 100644 apps/yaak-client/components/git/uncommitted.tsx
create mode 100644 apps/yaak-client/components/graphql/GraphQLDocsExplorer.tsx
create mode 100644 apps/yaak-client/components/graphql/GraphQLEditor.tsx
create mode 100644 apps/yaak-client/components/graphql/graphqlAtoms.ts
create mode 100644 apps/yaak-client/components/responseViewers/AudioViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/BinaryViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/CsvViewer.test.tsx
create mode 100644 apps/yaak-client/components/responseViewers/CsvViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/EventStreamViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/ImageViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/JsonViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/MultipartViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/PdfViewer.css
create mode 100644 apps/yaak-client/components/responseViewers/PdfViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/RecentFiltersDropdown.tsx
create mode 100644 apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx
create mode 100644 apps/yaak-client/components/responseViewers/SvgViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/TextViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/VideoViewer.tsx
create mode 100644 apps/yaak-client/components/responseViewers/WebPageViewer.tsx
create mode 100644 apps/yaak-client/font-size.ts
create mode 100644 apps/yaak-client/font.ts
create mode 100644 apps/yaak-client/hooks/useActiveCookieJar.ts
create mode 100644 apps/yaak-client/hooks/useActiveEnvironment.ts
create mode 100644 apps/yaak-client/hooks/useActiveEnvironmentVariables.ts
create mode 100644 apps/yaak-client/hooks/useActiveFolder.ts
create mode 100644 apps/yaak-client/hooks/useActiveFolderId.ts
create mode 100644 apps/yaak-client/hooks/useActiveRequest.ts
create mode 100644 apps/yaak-client/hooks/useActiveRequestId.ts
create mode 100644 apps/yaak-client/hooks/useActiveWorkspace.ts
create mode 100644 apps/yaak-client/hooks/useActiveWorkspaceChangedToast.tsx
create mode 100644 apps/yaak-client/hooks/useAllRequests.ts
create mode 100644 apps/yaak-client/hooks/useAuthTab.tsx
create mode 100644 apps/yaak-client/hooks/useCancelHttpResponse.ts
create mode 100644 apps/yaak-client/hooks/useCheckForUpdates.tsx
create mode 100644 apps/yaak-client/hooks/useClickOutside.ts
create mode 100644 apps/yaak-client/hooks/useCopyHttpResponse.ts
create mode 100644 apps/yaak-client/hooks/useCreateCookieJar.ts
create mode 100644 apps/yaak-client/hooks/useCreateDropdownItems.tsx
create mode 100644 apps/yaak-client/hooks/useCreateWorkspace.tsx
create mode 100644 apps/yaak-client/hooks/useDeleteGrpcConnections.ts
create mode 100644 apps/yaak-client/hooks/useDeleteHttpResponses.ts
create mode 100644 apps/yaak-client/hooks/useDeleteSendHistory.tsx
create mode 100644 apps/yaak-client/hooks/useEnvironmentValueVisibility.ts
create mode 100644 apps/yaak-client/hooks/useEnvironmentVariables.ts
create mode 100644 apps/yaak-client/hooks/useEnvironmentsBreakdown.ts
create mode 100644 apps/yaak-client/hooks/useEventViewerKeyboard.ts
create mode 100644 apps/yaak-client/hooks/useExportData.tsx
create mode 100644 apps/yaak-client/hooks/useFastMutation.ts
create mode 100644 apps/yaak-client/hooks/useFloatingSidebarHidden.ts
create mode 100644 apps/yaak-client/hooks/useFolderActions.ts
create mode 100644 apps/yaak-client/hooks/useFormatText.ts
create mode 100644 apps/yaak-client/hooks/useGraphQLSchemaFile.ts
create mode 100644 apps/yaak-client/hooks/useGrpc.ts
create mode 100644 apps/yaak-client/hooks/useGrpcProtoFiles.ts
create mode 100644 apps/yaak-client/hooks/useGrpcRequestActions.ts
create mode 100644 apps/yaak-client/hooks/useHeadersTab.tsx
create mode 100644 apps/yaak-client/hooks/useHotKey.ts
create mode 100644 apps/yaak-client/hooks/useHttpAuthentication.ts
create mode 100644 apps/yaak-client/hooks/useHttpAuthenticationConfig.ts
create mode 100644 apps/yaak-client/hooks/useHttpRequestActions.ts
create mode 100644 apps/yaak-client/hooks/useHttpRequestBody.ts
create mode 100644 apps/yaak-client/hooks/useHttpResponseEvents.ts
create mode 100644 apps/yaak-client/hooks/useInheritedAuthentication.ts
create mode 100644 apps/yaak-client/hooks/useInheritedHeaders.ts
create mode 100644 apps/yaak-client/hooks/useInstallPlugin.ts
create mode 100644 apps/yaak-client/hooks/useIntrospectGraphQL.ts
create mode 100644 apps/yaak-client/hooks/useIsEncryptionEnabled.ts
create mode 100644 apps/yaak-client/hooks/useKeyValue.ts
create mode 100644 apps/yaak-client/hooks/useKeyboardEvent.ts
create mode 100644 apps/yaak-client/hooks/useLatestGrpcConnection.ts
create mode 100644 apps/yaak-client/hooks/useLatestHttpResponse.ts
create mode 100644 apps/yaak-client/hooks/useModelAncestors.ts
create mode 100644 apps/yaak-client/hooks/useParentFolders.ts
create mode 100644 apps/yaak-client/hooks/usePinnedGrpcConnection.ts
create mode 100644 apps/yaak-client/hooks/usePinnedHttpResponse.ts
create mode 100644 apps/yaak-client/hooks/usePinnedWebsocketConnection.ts
create mode 100644 apps/yaak-client/hooks/usePlatformEvent.ts
create mode 100644 apps/yaak-client/hooks/usePluginInfo.ts
create mode 100644 apps/yaak-client/hooks/usePlugins.ts
create mode 100644 apps/yaak-client/hooks/usePreferredAppearance.ts
create mode 100644 apps/yaak-client/hooks/useRandomKey.ts
create mode 100644 apps/yaak-client/hooks/useRecentCookieJars.ts
create mode 100644 apps/yaak-client/hooks/useRecentEnvironments.ts
create mode 100644 apps/yaak-client/hooks/useRecentFilters.ts
create mode 100644 apps/yaak-client/hooks/useRecentRequests.ts
create mode 100644 apps/yaak-client/hooks/useRecentWorkspaces.ts
create mode 100644 apps/yaak-client/hooks/useRenderTemplate.ts
create mode 100644 apps/yaak-client/hooks/useRequestEditor.tsx
create mode 100644 apps/yaak-client/hooks/useRequestUpdateKey.ts
create mode 100644 apps/yaak-client/hooks/useResolvedAppearance.ts
create mode 100644 apps/yaak-client/hooks/useResolvedTheme.ts
create mode 100644 apps/yaak-client/hooks/useResponseBodyEventSource.ts
create mode 100644 apps/yaak-client/hooks/useResponseBodySseSummary.ts
create mode 100644 apps/yaak-client/hooks/useResponseBodyText.ts
create mode 100644 apps/yaak-client/hooks/useResponseBodyUrl.ts
create mode 100644 apps/yaak-client/hooks/useResponseFilter.ts
create mode 100644 apps/yaak-client/hooks/useResponseViewMode.ts
create mode 100644 apps/yaak-client/hooks/useSaveResponse.tsx
create mode 100644 apps/yaak-client/hooks/useScrollIntoView.ts
create mode 100644 apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts
create mode 100644 apps/yaak-client/hooks/useSendAnyHttpRequest.ts
create mode 100644 apps/yaak-client/hooks/useSendManyRequests.ts
create mode 100644 apps/yaak-client/hooks/useSidebarHidden.ts
create mode 100644 apps/yaak-client/hooks/useSidebarItemCollapsed.ts
create mode 100644 apps/yaak-client/hooks/useSidebarWidth.ts
create mode 100644 apps/yaak-client/hooks/useSseSummaryResultKeyPath.ts
create mode 100644 apps/yaak-client/hooks/useStateWithDeps.ts
create mode 100644 apps/yaak-client/hooks/useStoplightsVisible.ts
create mode 100644 apps/yaak-client/hooks/useSyncFontSizeSetting.ts
create mode 100644 apps/yaak-client/hooks/useSyncWorkspaceChildModels.ts
create mode 100644 apps/yaak-client/hooks/useSyncWorkspaceRequestTitle.ts
create mode 100644 apps/yaak-client/hooks/useSyncZoomSetting.ts
create mode 100644 apps/yaak-client/hooks/useTemplateFunctionConfig.ts
create mode 100644 apps/yaak-client/hooks/useTemplateFunctions.tsx
create mode 100644 apps/yaak-client/hooks/useTemplateTokensToString.ts
create mode 100644 apps/yaak-client/hooks/useTimelineViewMode.ts
create mode 100644 apps/yaak-client/hooks/useToggle.ts
create mode 100644 apps/yaak-client/hooks/useToggleCommandPalette.tsx
create mode 100644 apps/yaak-client/hooks/useWebsocketRequestActions.ts
create mode 100644 apps/yaak-client/hooks/useWindowFocus.ts
create mode 100644 apps/yaak-client/hooks/useWorkspaceActions.ts
create mode 100644 apps/yaak-client/hooks/useZoom.ts
create mode 100644 apps/yaak-client/index.html
create mode 100644 apps/yaak-client/init/git.ts
create mode 100644 apps/yaak-client/init/sync.ts
create mode 100644 apps/yaak-client/lib/alert.ts
create mode 100644 apps/yaak-client/lib/appInfo.ts
create mode 100644 apps/yaak-client/lib/atoms.ts
create mode 100644 apps/yaak-client/lib/atoms/atomWithKVStorage.ts
create mode 100644 apps/yaak-client/lib/capitalize.ts
create mode 100644 apps/yaak-client/lib/color.ts
create mode 100644 apps/yaak-client/lib/confirm.ts
create mode 100644 apps/yaak-client/lib/confirmWebProxy.test.tsx
create mode 100644 apps/yaak-client/lib/confirmWebProxy.tsx
create mode 100644 apps/yaak-client/lib/contentType.ts
create mode 100644 apps/yaak-client/lib/contextMenu.ts
create mode 100644 apps/yaak-client/lib/copy.ts
create mode 100644 apps/yaak-client/lib/createRequestAndNavigate.tsx
create mode 100644 apps/yaak-client/lib/data/acceptLanguages.ts
create mode 100644 apps/yaak-client/lib/data/cacheControl.ts
create mode 100644 apps/yaak-client/lib/data/charsets.ts
create mode 100644 apps/yaak-client/lib/data/connections.ts
create mode 100644 apps/yaak-client/lib/data/encodings.ts
create mode 100644 apps/yaak-client/lib/data/headerNames.ts
create mode 100644 apps/yaak-client/lib/data/headerValuePresets.ts
create mode 100644 apps/yaak-client/lib/data/mimetypes.ts
create mode 100644 apps/yaak-client/lib/data/userAgents.ts
create mode 100644 apps/yaak-client/lib/defaultHeaders.ts
create mode 100644 apps/yaak-client/lib/deleteModelWithConfirm.tsx
create mode 100644 apps/yaak-client/lib/dialog.ts
create mode 100644 apps/yaak-client/lib/diffYaml.ts
create mode 100644 apps/yaak-client/lib/dnd.ts
create mode 100644 apps/yaak-client/lib/docFingerprint.test.ts
create mode 100644 apps/yaak-client/lib/docFingerprint.ts
create mode 100644 apps/yaak-client/lib/duplicateRequestOrFolderAndNavigate.tsx
create mode 100644 apps/yaak-client/lib/editEnvironment.tsx
create mode 100644 apps/yaak-client/lib/encryption.ts
create mode 100644 apps/yaak-client/lib/featureFeedback.tsx
create mode 100644 apps/yaak-client/lib/featureFeedbackConstants.ts
create mode 100644 apps/yaak-client/lib/fireAndForget.ts
create mode 100644 apps/yaak-client/lib/formatters.ts
create mode 100644 apps/yaak-client/lib/generateId.ts
create mode 100644 apps/yaak-client/lib/getNodeText.ts
create mode 100644 apps/yaak-client/lib/gitWorktreeStatus.ts
create mode 100644 apps/yaak-client/lib/graphqlOperationNames.test.ts
create mode 100644 apps/yaak-client/lib/graphqlOperationNames.ts
create mode 100644 apps/yaak-client/lib/graphqlSchema.test.ts
create mode 100644 apps/yaak-client/lib/graphqlSchema.ts
create mode 100644 apps/yaak-client/lib/importData.tsx
create mode 100644 apps/yaak-client/lib/initGlobalListeners.tsx
create mode 100644 apps/yaak-client/lib/jotai.ts
create mode 100644 apps/yaak-client/lib/jsonComments.ts
create mode 100644 apps/yaak-client/lib/jsonSchemaExample.test.ts
create mode 100644 apps/yaak-client/lib/jsonSchemaExample.ts
create mode 100644 apps/yaak-client/lib/keyValueStore.ts
create mode 100644 apps/yaak-client/lib/largeValue.ts
create mode 100644 apps/yaak-client/lib/markdown.ts
create mode 100644 apps/yaak-client/lib/minPromiseMillis.ts
create mode 100644 apps/yaak-client/lib/model_util.test.ts
create mode 100644 apps/yaak-client/lib/model_util.ts
create mode 100644 apps/yaak-client/lib/onboarding.ts
create mode 100644 apps/yaak-client/lib/pathPlaceholders.test.ts
create mode 100644 apps/yaak-client/lib/pathPlaceholders.ts
create mode 100644 apps/yaak-client/lib/pluralize.ts
create mode 100644 apps/yaak-client/lib/prepareImportQuerystring.ts
create mode 100644 apps/yaak-client/lib/pricingUrl.ts
create mode 100644 apps/yaak-client/lib/prompt-form.tsx
create mode 100644 apps/yaak-client/lib/prompt.ts
create mode 100644 apps/yaak-client/lib/queryClient.ts
create mode 100644 apps/yaak-client/lib/renameModelWithPrompt.tsx
create mode 100644 apps/yaak-client/lib/requestBodyConversion.test.ts
create mode 100644 apps/yaak-client/lib/requestBodyConversion.ts
create mode 100644 apps/yaak-client/lib/requestSettings.ts
create mode 100644 apps/yaak-client/lib/resolvedModelName.ts
create mode 100644 apps/yaak-client/lib/responseBody.ts
create mode 100644 apps/yaak-client/lib/reveal.ts
create mode 100644 apps/yaak-client/lib/router.ts
create mode 100644 apps/yaak-client/lib/rpc.ts
create mode 100644 apps/yaak-client/lib/scopes.ts
create mode 100644 apps/yaak-client/lib/sendEphemeralRequest.ts
create mode 100644 apps/yaak-client/lib/setWorkspaceSearchParams.ts
create mode 100644 apps/yaak-client/lib/settings.ts
create mode 100644 apps/yaak-client/lib/setupOrConfigureEncryption.tsx
create mode 100644 apps/yaak-client/lib/showColorPicker.tsx
create mode 100644 apps/yaak-client/lib/sleep.ts
create mode 100644 apps/yaak-client/lib/themes.ts
create mode 100644 apps/yaak-client/lib/toast.tsx
create mode 100644 apps/yaak-client/lib/truncate.ts
create mode 100644 apps/yaak-client/main.css
create mode 100644 apps/yaak-client/main.tsx
create mode 100644 apps/yaak-client/modules.d.ts
create mode 100644 apps/yaak-client/package.json
create mode 100644 apps/yaak-client/postcss.config.cjs
create mode 100644 apps/yaak-client/react-augment.d.ts
create mode 100644 apps/yaak-client/routeTree.gen.ts
create mode 100644 apps/yaak-client/routes/__root.tsx
create mode 100644 apps/yaak-client/routes/index.tsx
create mode 100644 apps/yaak-client/routes/workspaces/$workspaceId/index.tsx
create mode 100644 apps/yaak-client/routes/workspaces/$workspaceId/requests/$requestId.tsx
create mode 100644 apps/yaak-client/routes/workspaces/$workspaceId/settings.tsx
create mode 100644 apps/yaak-client/routes/workspaces/index.tsx
create mode 100644 apps/yaak-client/theme.ts
create mode 100644 apps/yaak-client/tsconfig.json
create mode 100644 apps/yaak-client/tsconfig.node.json
create mode 100644 apps/yaak-client/tsr.config.json
create mode 100644 apps/yaak-client/vite-env.d.ts
create mode 100644 apps/yaak-client/vite.config.ts
create mode 100644 apps/yaak-client/web/og.html
create mode 100644 apps/yaak-proxy/components/ActionButton.tsx
create mode 100644 apps/yaak-proxy/components/ActionIconButton.tsx
create mode 100644 apps/yaak-proxy/components/ExchangesTable.tsx
create mode 100644 apps/yaak-proxy/components/ProxyLayout.tsx
create mode 100644 apps/yaak-proxy/components/Sidebar.tsx
create mode 100644 apps/yaak-proxy/font-size.ts
create mode 100644 apps/yaak-proxy/hooks/useActionMetadata.ts
create mode 100644 apps/yaak-proxy/hooks/useRpcEvent.ts
create mode 100644 apps/yaak-proxy/hooks/useRpcMutation.ts
create mode 100644 apps/yaak-proxy/hooks/useRpcQuery.ts
create mode 100644 apps/yaak-proxy/hooks/useRpcQueryWithEvent.ts
create mode 100644 apps/yaak-proxy/index.html
create mode 100644 apps/yaak-proxy/lib/fireAndForget.ts
create mode 100644 apps/yaak-proxy/lib/hotkeys.ts
create mode 100644 apps/yaak-proxy/lib/rpc.ts
create mode 100644 apps/yaak-proxy/lib/store.ts
create mode 100644 apps/yaak-proxy/lib/tauri.ts
create mode 100644 apps/yaak-proxy/lib/theme.ts
create mode 100644 apps/yaak-proxy/main.css
create mode 100644 apps/yaak-proxy/main.tsx
create mode 100644 apps/yaak-proxy/package.json
create mode 100644 apps/yaak-proxy/postcss.config.cjs
create mode 100644 apps/yaak-proxy/tsconfig.json
create mode 100644 apps/yaak-proxy/tsconfig.node.json
create mode 100644 apps/yaak-proxy/vite-env.d.ts
create mode 100644 apps/yaak-proxy/vite.config.ts
create mode 100644 crates-cli/yaak-cli/Cargo.toml
create mode 100644 crates-cli/yaak-cli/README.md
create mode 100644 crates-cli/yaak-cli/skills/use-yaak/SKILL.md
create mode 100644 crates-cli/yaak-cli/src/cli.rs
create mode 100644 crates-cli/yaak-cli/src/commands/agent.rs
create mode 100644 crates-cli/yaak-cli/src/commands/auth.rs
create mode 100644 crates-cli/yaak-cli/src/commands/cookie_jar.rs
create mode 100644 crates-cli/yaak-cli/src/commands/environment.rs
create mode 100644 crates-cli/yaak-cli/src/commands/folder.rs
create mode 100644 crates-cli/yaak-cli/src/commands/import_export.rs
create mode 100644 crates-cli/yaak-cli/src/commands/mod.rs
create mode 100644 crates-cli/yaak-cli/src/commands/plugin.rs
create mode 100644 crates-cli/yaak-cli/src/commands/request.rs
create mode 100644 crates-cli/yaak-cli/src/commands/response.rs
create mode 100644 crates-cli/yaak-cli/src/commands/send.rs
create mode 100644 crates-cli/yaak-cli/src/commands/template_function.rs
create mode 100644 crates-cli/yaak-cli/src/commands/workspace.rs
create mode 100644 crates-cli/yaak-cli/src/context.rs
create mode 100644 crates-cli/yaak-cli/src/main.rs
create mode 100644 crates-cli/yaak-cli/src/plugin_events.rs
create mode 100644 crates-cli/yaak-cli/src/ui.rs
create mode 100644 crates-cli/yaak-cli/src/utils/confirm.rs
create mode 100644 crates-cli/yaak-cli/src/utils/http.rs
create mode 100644 crates-cli/yaak-cli/src/utils/json.rs
create mode 100644 crates-cli/yaak-cli/src/utils/mod.rs
create mode 100644 crates-cli/yaak-cli/src/utils/schema.rs
create mode 100644 crates-cli/yaak-cli/src/utils/workspace.rs
create mode 100644 crates-cli/yaak-cli/src/version.rs
create mode 100644 crates-cli/yaak-cli/src/version_check.rs
create mode 100644 crates-cli/yaak-cli/tests/common/http_server.rs
create mode 100644 crates-cli/yaak-cli/tests/common/mod.rs
create mode 100644 crates-cli/yaak-cli/tests/environment_commands.rs
create mode 100644 crates-cli/yaak-cli/tests/folder_commands.rs
create mode 100644 crates-cli/yaak-cli/tests/import_export_commands.rs
create mode 100644 crates-cli/yaak-cli/tests/request_commands.rs
create mode 100644 crates-cli/yaak-cli/tests/send_commands.rs
create mode 100644 crates-cli/yaak-cli/tests/workspace_commands.rs
create mode 100644 crates-proxy/yaak-proxy-lib/Cargo.toml
create mode 100644 crates-proxy/yaak-proxy-lib/bindings/ModelChangeEvent.ts
create mode 100644 crates-proxy/yaak-proxy-lib/bindings/gen_models.ts
create mode 100644 crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts
create mode 100644 crates-proxy/yaak-proxy-lib/bindings/index.ts
create mode 100644 crates-proxy/yaak-proxy-lib/migrations/00000001_init.sql
create mode 100644 crates-proxy/yaak-proxy-lib/package.json
create mode 100644 crates-proxy/yaak-proxy-lib/src/actions.rs
create mode 100644 crates-proxy/yaak-proxy-lib/src/db.rs
create mode 100644 crates-proxy/yaak-proxy-lib/src/lib.rs
create mode 100644 crates-proxy/yaak-proxy-lib/src/models.rs
create mode 100644 crates-server/yaak-playground/Cargo.toml
create mode 100644 crates-server/yaak-playground/README.md
create mode 100644 crates-server/yaak-playground/src/auth.rs
create mode 100644 crates-server/yaak-playground/src/config.rs
create mode 100644 crates-server/yaak-playground/src/data.rs
create mode 100644 crates-server/yaak-playground/src/error.rs
create mode 100644 crates-server/yaak-playground/src/lib.rs
create mode 100644 crates-server/yaak-playground/src/limits.rs
create mode 100644 crates-server/yaak-playground/src/main.rs
create mode 100644 crates-server/yaak-playground/src/openapi.json
create mode 100644 crates-server/yaak-playground/src/resources.rs
create mode 100644 crates-server/yaak-playground/src/seed.json
create mode 100644 crates-server/yaak-playground/src/store.rs
create mode 100644 crates-server/yaak-playground/tests/api.rs
create mode 100644 crates-server/yaak-web/Cargo.toml
create mode 100644 crates-server/yaak-web/README.md
create mode 100644 crates-server/yaak-web/bindings/gen_models.ts
create mode 100644 crates-server/yaak-web/bindings/gen_web.ts
create mode 100644 crates-server/yaak-web/index.ts
create mode 100644 crates-server/yaak-web/package.json
create mode 100644 crates-server/yaak-web/src/config.rs
create mode 100644 crates-server/yaak-web/src/guard.rs
create mode 100644 crates-server/yaak-web/src/lib.rs
create mode 100644 crates-server/yaak-web/src/limits.rs
create mode 100644 crates-server/yaak-web/src/main.rs
create mode 100644 crates-server/yaak-web/src/send.rs
create mode 100644 crates-server/yaak-web/src/wire.rs
create mode 100644 crates-server/yaak-web/tests/logging.rs
create mode 100644 crates-server/yaak-web/tests/router.rs
create mode 100644 crates-tauri/yaak-app-client/.gitignore
create mode 100644 crates-tauri/yaak-app-client/Cargo.toml
create mode 100644 crates-tauri/yaak-app-client/bindings/gen_watch.ts
create mode 100644 crates-tauri/yaak-app-client/bindings/index.ts
create mode 100644 crates-tauri/yaak-app-client/bindings/plugins_ext.ts
create mode 100644 crates-tauri/yaak-app-client/build.rs
create mode 100644 crates-tauri/yaak-app-client/capabilities/default.json
create mode 100644 crates-tauri/yaak-app-client/icons/dev/128x128.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/128x128@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/32x32.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/64x64.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square107x107Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square142x142Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square150x150Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square284x284Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square30x30Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square310x310Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square44x44Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square71x71Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/Square89x89Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/StoreLogo.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-hdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-hdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-mdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-mdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/icon.icns
create mode 100644 crates-tauri/yaak-app-client/icons/dev/icon.ico
create mode 100644 crates-tauri/yaak-app-client/icons/dev/icon.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-20x20@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-20x20@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-20x20@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-20x20@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-29x29@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-29x29@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-29x29@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-29x29@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-40x40@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-40x40@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-40x40@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-40x40@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-512@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-60x60@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-60x60@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-76x76@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-76x76@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/dev/ios/AppIcon-83.5x83.5@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/icon-dev.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/128x128.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/128x128@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/32x32.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/64x64.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square107x107Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square142x142Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square150x150Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square284x284Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square30x30Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square310x310Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square44x44Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square71x71Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/Square89x89Logo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/StoreLogo.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-hdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-hdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-hdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-mdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-mdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-mdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxxhdpi/ic_launcher.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxxhdpi/ic_launcher_foreground.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/android/mipmap-xxxhdpi/ic_launcher_round.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/icon.ico
create mode 100644 crates-tauri/yaak-app-client/icons/release/icon.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-20x20@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-20x20@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-20x20@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-20x20@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-29x29@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-29x29@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-29x29@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-29x29@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-40x40@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-40x40@2x-1.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-40x40@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-40x40@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-60x60@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-60x60@3x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-76x76@1x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-76x76@2x.png
create mode 100644 crates-tauri/yaak-app-client/icons/release/ios/AppIcon-83.5x83.5@2x.png
create mode 100644 crates-tauri/yaak-app-client/index.ts
create mode 100644 crates-tauri/yaak-app-client/macos/entitlements.plist
create mode 100644 crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist
create mode 100644 crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist
create mode 100644 crates-tauri/yaak-app-client/package.json
create mode 100644 crates-tauri/yaak-app-client/src/encoding.rs
create mode 100644 crates-tauri/yaak-app-client/src/error.rs
create mode 100644 crates-tauri/yaak-app-client/src/feedback.rs
create mode 100644 crates-tauri/yaak-app-client/src/git_ext.rs
create mode 100644 crates-tauri/yaak-app-client/src/git_watcher.rs
create mode 100644 crates-tauri/yaak-app-client/src/grpc.rs
create mode 100644 crates-tauri/yaak-app-client/src/history.rs
create mode 100644 crates-tauri/yaak-app-client/src/http_request.rs
create mode 100644 crates-tauri/yaak-app-client/src/import.rs
create mode 100644 crates-tauri/yaak-app-client/src/lib.rs
create mode 100644 crates-tauri/yaak-app-client/src/main.rs
create mode 100644 crates-tauri/yaak-app-client/src/models_ext.rs
create mode 100644 crates-tauri/yaak-app-client/src/notifications.rs
create mode 100644 crates-tauri/yaak-app-client/src/plugin_events.rs
create mode 100644 crates-tauri/yaak-app-client/src/plugins_ext.rs
create mode 100644 crates-tauri/yaak-app-client/src/render.rs
create mode 100644 crates-tauri/yaak-app-client/src/restart.rs
create mode 100644 crates-tauri/yaak-app-client/src/rpc_ext.rs
create mode 100644 crates-tauri/yaak-app-client/src/sync_ext.rs
create mode 100644 crates-tauri/yaak-app-client/src/updates.rs
create mode 100644 crates-tauri/yaak-app-client/src/uri_scheme.rs
create mode 100644 crates-tauri/yaak-app-client/src/window_menu.rs
create mode 100644 crates-tauri/yaak-app-client/src/ws_ext.rs
create mode 100644 crates-tauri/yaak-app-client/static/greg.jpeg
create mode 100644 crates-tauri/yaak-app-client/tauri.conf.json
create mode 100644 crates-tauri/yaak-app-client/tauri.development.conf.json
create mode 100644 crates-tauri/yaak-app-client/tauri.linux.conf.json
create mode 100644 crates-tauri/yaak-app-client/tauri.release.conf.json
create mode 100644 crates-tauri/yaak-app-client/template.desktop
create mode 100644 crates-tauri/yaak-app-proxy/.gitignore
create mode 100644 crates-tauri/yaak-app-proxy/Cargo.toml
create mode 100644 crates-tauri/yaak-app-proxy/bindings/index.ts
create mode 100644 crates-tauri/yaak-app-proxy/build.rs
create mode 100644 crates-tauri/yaak-app-proxy/capabilities/default.json
create mode 100644 crates-tauri/yaak-app-proxy/package.json
create mode 100644 crates-tauri/yaak-app-proxy/src/lib.rs
create mode 100644 crates-tauri/yaak-app-proxy/src/main.rs
create mode 100644 crates-tauri/yaak-app-proxy/src/window_menu.rs
create mode 100644 crates-tauri/yaak-app-proxy/tauri.conf.json
create mode 100644 crates-tauri/yaak-app-proxy/tauri.development.conf.json
create mode 100644 crates-tauri/yaak-app-proxy/tauri.release.conf.json
create mode 100644 crates-tauri/yaak-app-proxy/template.desktop
create mode 100644 crates-tauri/yaak-fonts/Cargo.toml
create mode 100644 crates-tauri/yaak-fonts/bindings/gen_fonts.ts
create mode 100644 crates-tauri/yaak-fonts/build.rs
create mode 100644 crates-tauri/yaak-fonts/index.ts
create mode 100644 crates-tauri/yaak-fonts/package.json
create mode 100644 crates-tauri/yaak-fonts/permissions/default.toml
create mode 100644 crates-tauri/yaak-fonts/src/commands.rs
create mode 100644 crates-tauri/yaak-fonts/src/error.rs
create mode 100644 crates-tauri/yaak-fonts/src/lib.rs
create mode 100644 crates-tauri/yaak-license/Cargo.toml
create mode 100644 crates-tauri/yaak-license/bindings/gen_models.ts
create mode 100644 crates-tauri/yaak-license/bindings/license.ts
create mode 100644 crates-tauri/yaak-license/bindings/models.ts
create mode 100644 crates-tauri/yaak-license/build.rs
create mode 100644 crates-tauri/yaak-license/index.ts
create mode 100644 crates-tauri/yaak-license/package.json
create mode 100644 crates-tauri/yaak-license/permissions/default.toml
create mode 100644 crates-tauri/yaak-license/src/commands.rs
create mode 100644 crates-tauri/yaak-license/src/error.rs
create mode 100644 crates-tauri/yaak-license/src/lib.rs
create mode 100644 crates-tauri/yaak-license/src/license.rs
create mode 100644 crates-tauri/yaak-mac-window/Cargo.toml
create mode 100644 crates-tauri/yaak-mac-window/build.rs
create mode 100644 crates-tauri/yaak-mac-window/index.ts
create mode 100644 crates-tauri/yaak-mac-window/package.json
create mode 100644 crates-tauri/yaak-mac-window/permissions/default.toml
create mode 100644 crates-tauri/yaak-mac-window/src/commands.rs
create mode 100644 crates-tauri/yaak-mac-window/src/lib.rs
create mode 100644 crates-tauri/yaak-mac-window/src/mac.rs
create mode 100644 crates-tauri/yaak-system-appearance/Cargo.toml
create mode 100644 crates-tauri/yaak-system-appearance/src/lib.rs
create mode 100644 crates-tauri/yaak-tauri-utils/Cargo.toml
create mode 100644 crates-tauri/yaak-tauri-utils/src/lib.rs
create mode 100644 crates-tauri/yaak-tauri-utils/src/window.rs
create mode 100644 crates-tauri/yaak-window/Cargo.toml
create mode 100644 crates-tauri/yaak-window/src/lib.rs
create mode 100644 crates-tauri/yaak-window/src/window.rs
create mode 100644 crates-tauri/yaak-window/src/window_state.rs
create mode 100644 crates/common/yaak-database/Cargo.toml
create mode 100644 crates/common/yaak-database/src/connection_or_tx.rs
create mode 100644 crates/common/yaak-database/src/db_context.rs
create mode 100644 crates/common/yaak-database/src/error.rs
create mode 100644 crates/common/yaak-database/src/lib.rs
create mode 100644 crates/common/yaak-database/src/migrate.rs
create mode 100644 crates/common/yaak-database/src/pool.rs
create mode 100644 crates/common/yaak-database/src/traits.rs
create mode 100644 crates/common/yaak-database/src/update_source.rs
create mode 100644 crates/common/yaak-database/src/util.rs
create mode 100644 crates/common/yaak-rpc-schema/Cargo.toml
create mode 100644 crates/common/yaak-rpc-schema/README.md
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_api.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_events.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_git.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_grpc.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_models.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_search.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_sync.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/gen_util.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/parser.ts
create mode 100644 crates/common/yaak-rpc-schema/bindings/sse.ts
create mode 100644 crates/common/yaak-rpc-schema/index.ts
create mode 100644 crates/common/yaak-rpc-schema/package.json
create mode 100644 crates/common/yaak-rpc-schema/src/lib.rs
create mode 100644 crates/common/yaak-rpc/Cargo.toml
create mode 100644 crates/common/yaak-rpc/src/lib.rs
create mode 100644 crates/yaak-api/Cargo.toml
create mode 100644 crates/yaak-api/src/error.rs
create mode 100644 crates/yaak-api/src/lib.rs
create mode 100644 crates/yaak-commands/Cargo.toml
create mode 100644 crates/yaak-commands/src/actions.rs
create mode 100644 crates/yaak-commands/src/auth.rs
create mode 100644 crates/yaak-commands/src/data.rs
create mode 100644 crates/yaak-commands/src/encryption.rs
create mode 100644 crates/yaak-commands/src/error.rs
create mode 100644 crates/yaak-commands/src/host.rs
create mode 100644 crates/yaak-commands/src/lib.rs
create mode 100644 crates/yaak-commands/src/models.rs
create mode 100644 crates/yaak-commands/src/plugins.rs
create mode 100644 crates/yaak-commands/src/render.rs
create mode 100644 crates/yaak-commands/src/resolve.rs
create mode 100644 crates/yaak-commands/src/responses.rs
create mode 100644 crates/yaak-commands/src/templates.rs
create mode 100644 crates/yaak-commands/tests/test_host.rs
create mode 100644 crates/yaak-common/Cargo.toml
create mode 100644 crates/yaak-common/src/command.rs
create mode 100644 crates/yaak-common/src/lib.rs
create mode 100644 crates/yaak-common/src/platform.rs
create mode 100644 crates/yaak-common/src/serde.rs
create mode 100644 crates/yaak-core/Cargo.toml
create mode 100644 crates/yaak-core/src/context.rs
create mode 100644 crates/yaak-core/src/error.rs
create mode 100644 crates/yaak-core/src/lib.rs
create mode 100644 crates/yaak-crypto/Cargo.toml
create mode 100644 crates/yaak-crypto/index.ts
create mode 100644 crates/yaak-crypto/package.json
create mode 100644 crates/yaak-crypto/src/encryption.rs
create mode 100644 crates/yaak-crypto/src/error.rs
create mode 100644 crates/yaak-crypto/src/lib.rs
create mode 100644 crates/yaak-crypto/src/manager.rs
create mode 100644 crates/yaak-crypto/src/master_key.rs
create mode 100644 crates/yaak-crypto/src/workspace_key.rs
create mode 100644 crates/yaak-git/Cargo.toml
create mode 100644 crates/yaak-git/bindings/gen_git.ts
create mode 100644 crates/yaak-git/bindings/gen_models.ts
create mode 100644 crates/yaak-git/index.ts
create mode 100644 crates/yaak-git/package.json
create mode 100644 crates/yaak-git/src/add.rs
create mode 100644 crates/yaak-git/src/binary.rs
create mode 100644 crates/yaak-git/src/branch.rs
create mode 100644 crates/yaak-git/src/clone.rs
create mode 100644 crates/yaak-git/src/commit.rs
create mode 100644 crates/yaak-git/src/credential.rs
create mode 100644 crates/yaak-git/src/error.rs
create mode 100644 crates/yaak-git/src/fetch.rs
create mode 100644 crates/yaak-git/src/init.rs
create mode 100644 crates/yaak-git/src/lib.rs
create mode 100644 crates/yaak-git/src/log.rs
create mode 100644 crates/yaak-git/src/pull.rs
create mode 100644 crates/yaak-git/src/push.rs
create mode 100644 crates/yaak-git/src/remotes.rs
create mode 100644 crates/yaak-git/src/repository.rs
create mode 100644 crates/yaak-git/src/reset.rs
create mode 100644 crates/yaak-git/src/restore.rs
create mode 100644 crates/yaak-git/src/status.rs
create mode 100644 crates/yaak-git/src/unstage.rs
create mode 100644 crates/yaak-git/src/util.rs
create mode 100644 crates/yaak-grpc/Cargo.toml
create mode 100644 crates/yaak-grpc/bindings/gen_grpc.ts
create mode 100644 crates/yaak-grpc/src/any.rs
create mode 100644 crates/yaak-grpc/src/client.rs
create mode 100644 crates/yaak-grpc/src/codec.rs
create mode 100644 crates/yaak-grpc/src/error.rs
create mode 100644 crates/yaak-grpc/src/json_schema.rs
create mode 100644 crates/yaak-grpc/src/lib.rs
create mode 100644 crates/yaak-grpc/src/manager.rs
create mode 100644 crates/yaak-grpc/src/reflection.rs
create mode 100644 crates/yaak-grpc/src/transport.rs
create mode 100644 crates/yaak-http/Cargo.toml
create mode 100644 crates/yaak-http/src/chained_reader.rs
create mode 100644 crates/yaak-http/src/client.rs
create mode 100644 crates/yaak-http/src/cookies.rs
create mode 100644 crates/yaak-http/src/decompress.rs
create mode 100644 crates/yaak-http/src/dns.rs
create mode 100644 crates/yaak-http/src/error.rs
create mode 100644 crates/yaak-http/src/lib.rs
create mode 100644 crates/yaak-http/src/manager.rs
create mode 100644 crates/yaak-http/src/proto.rs
create mode 100644 crates/yaak-http/src/sender.rs
create mode 100644 crates/yaak-http/src/tee_reader.rs
create mode 100644 crates/yaak-http/src/transaction.rs
create mode 100644 crates/yaak-http/src/types.rs
create mode 100644 crates/yaak-http/tests/test.txt
create mode 100644 crates/yaak-lifecycle/Cargo.toml
create mode 100644 crates/yaak-lifecycle/src/lib.rs
create mode 100644 crates/yaak-models/Cargo.toml
create mode 100644 crates/yaak-models/bindings/ModelChangeEvent.ts
create mode 100644 crates/yaak-models/bindings/gen_models.ts
create mode 100644 crates/yaak-models/bindings/gen_util.ts
create mode 100644 crates/yaak-models/blob_migrations/00000000000000_init.sql
create mode 100644 crates/yaak-models/build.rs
create mode 100644 crates/yaak-models/guest-js/atoms.test.ts
create mode 100644 crates/yaak-models/guest-js/atoms.ts
create mode 100644 crates/yaak-models/guest-js/index.ts
create mode 100644 crates/yaak-models/guest-js/store.ts
create mode 100644 crates/yaak-models/guest-js/types.ts
create mode 100644 crates/yaak-models/guest-js/util.ts
create mode 100644 crates/yaak-models/migrations/20230225181302_init.sql
create mode 100644 crates/yaak-models/migrations/20230319042610_sort-priority.sql
create mode 100644 crates/yaak-models/migrations/20230330143214_request-auth.sql
create mode 100644 crates/yaak-models/migrations/20230413232435_response-body-blob.sql
create mode 100644 crates/yaak-models/migrations/20231022205109_environments.sql
create mode 100644 crates/yaak-models/migrations/20231028161007_variables.sql
create mode 100644 crates/yaak-models/migrations/20231103004111_workspace-variables.sql
create mode 100644 crates/yaak-models/migrations/20231103142807_folders.sql
create mode 100644 crates/yaak-models/migrations/20231112180500_body_object.sql
create mode 100644 crates/yaak-models/migrations/20231113183810_url_params.sql
create mode 100644 crates/yaak-models/migrations/20231122055216_remove_body.sql
create mode 100644 crates/yaak-models/migrations/20240111221224_settings.sql
create mode 100644 crates/yaak-models/migrations/20240115193751_workspace_settings.sql
create mode 100644 crates/yaak-models/migrations/20240118181105_channel_setting.sql
create mode 100644 crates/yaak-models/migrations/20240127013915_cookies.sql
create mode 100644 crates/yaak-models/migrations/20240128230717_more_response_attrs.sql
create mode 100644 crates/yaak-models/migrations/20240203164833_grpc.sql
create mode 100644 crates/yaak-models/migrations/20240522031045_theme-settings.sql
create mode 100644 crates/yaak-models/migrations/20240529143147_more-settings.sql
create mode 100644 crates/yaak-models/migrations/20240607151115_open-workspace-setting.sql
create mode 100644 crates/yaak-models/migrations/20240814013812_fix-env-model.sql
create mode 100644 crates/yaak-models/migrations/20240826184943_disable-telemetry.sql
create mode 100644 crates/yaak-models/migrations/20240829131004_plugins.sql
create mode 100644 crates/yaak-models/migrations/20241003134208_response-state.sql
create mode 100644 crates/yaak-models/migrations/20241012181547_proxy-setting.sql
create mode 100644 crates/yaak-models/migrations/20241217204951_docs.sql
create mode 100644 crates/yaak-models/migrations/20241219140051_base-environments.sql
create mode 100644 crates/yaak-models/migrations/20250102141937_sync.sql
create mode 100644 crates/yaak-models/migrations/20250108035425_editor-keymap.sql
create mode 100644 crates/yaak-models/migrations/20250108205117_workspace-meta.sql
create mode 100644 crates/yaak-models/migrations/20250114160022_remove-workspace-sync-setting.sql
create mode 100644 crates/yaak-models/migrations/20250123192023_plugin-kv.sql
create mode 100644 crates/yaak-models/migrations/20250128155623_websockets.sql
create mode 100644 crates/yaak-models/migrations/20250302041707_hide-window-controls.sql
create mode 100644 crates/yaak-models/migrations/20250326193143_key-value-id.sql
create mode 100644 crates/yaak-models/migrations/20250401122407_encrypted-key.sql
create mode 100644 crates/yaak-models/migrations/20250402144842_encryption-key-challenge.sql
create mode 100644 crates/yaak-models/migrations/20250424152740_remove-fks.sql
create mode 100644 crates/yaak-models/migrations/20250507140702_remove-ev-sync-states.sql
create mode 100644 crates/yaak-models/migrations/20250508161145_public-environments.sql
create mode 100644 crates/yaak-models/migrations/20250516182745_default-attrs.sql
create mode 100644 crates/yaak-models/migrations/20250530174021_graphql-introspection.sql
create mode 100644 crates/yaak-models/migrations/20250531193722_sync-state-index.sql
create mode 100644 crates/yaak-models/migrations/20250604102922_colored-methods-setting.sql
create mode 100644 crates/yaak-models/migrations/20250608150053_font-settings.sql
create mode 100644 crates/yaak-models/migrations/20250611120000_environment-color.sql
create mode 100644 crates/yaak-models/migrations/20250727190746_autoupdate_setting.sql
create mode 100644 crates/yaak-models/migrations/20250918141129_request-folder-environments.sql
create mode 100644 crates/yaak-models/migrations/20250929132954_dismiss-license-badge.sql
create mode 100644 crates/yaak-models/migrations/20251001082054_auto-download.sql
create mode 100644 crates/yaak-models/migrations/20251028060300_check_notifications_setting.sql
create mode 100644 crates/yaak-models/migrations/20251029062024_aws-auth-name.sql
create mode 100644 crates/yaak-models/migrations/20251031070515_environment-sort-priority.sql
create mode 100644 crates/yaak-models/migrations/20251202080000_use-native-titlebar.sql
create mode 100644 crates/yaak-models/migrations/20251209000000_client-certificates.sql
create mode 100644 crates/yaak-models/migrations/20251219074602_default-workspace-headers.sql
create mode 100644 crates/yaak-models/migrations/20251220000000_response-request-headers.sql
create mode 100644 crates/yaak-models/migrations/20251221000000_http-response-events.sql
create mode 100644 crates/yaak-models/migrations/20251221100000_request-content-length.sql
create mode 100644 crates/yaak-models/migrations/20260104000000_hotkeys.sql
create mode 100644 crates/yaak-models/migrations/20260111000000_dns-timing.sql
create mode 100644 crates/yaak-models/migrations/20260112000000_dns-overrides.sql
create mode 100644 crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql
create mode 100644 crates/yaak-models/migrations/20260216000000_model-changes.sql
create mode 100644 crates/yaak-models/migrations/20260217000000_remove-legacy-faker-plugin.sql
create mode 100644 crates/yaak-models/migrations/20260301000000_plugin-source-and-unique-directory.sql
create mode 100644 crates/yaak-models/migrations/20260302000000_inherited-request-settings.sql
create mode 100644 crates/yaak-models/migrations/20260303000000_protocol-tls-settings.sql
create mode 100644 crates/yaak-models/migrations/20260629000000_request-message-size.sql
create mode 100644 crates/yaak-models/migrations/20260704000000_prompt-feedback.sql
create mode 100644 crates/yaak-models/migrations/20260826000000_http-version.sql
create mode 100644 crates/yaak-models/migrations/20260901000000_import-sources.sql
create mode 100644 crates/yaak-models/migrations/20260902000000_import-source-content-hash.sql
create mode 100644 crates/yaak-models/package.json
create mode 100644 crates/yaak-models/src/blob_manager.rs
create mode 100644 crates/yaak-models/src/client_db.rs
create mode 100644 crates/yaak-models/src/connection_or_tx.rs
create mode 100644 crates/yaak-models/src/cookies.rs
create mode 100644 crates/yaak-models/src/error.rs
create mode 100644 crates/yaak-models/src/example.rs
create mode 100644 crates/yaak-models/src/example_workspace.json
create mode 100644 crates/yaak-models/src/export.rs
create mode 100644 crates/yaak-models/src/lib.rs
create mode 100644 crates/yaak-models/src/migrate.rs
create mode 100644 crates/yaak-models/src/models.rs
create mode 100644 crates/yaak-models/src/models_ops.rs
create mode 100644 crates/yaak-models/src/path_placeholders.rs
create mode 100644 crates/yaak-models/src/queries/any_request.rs
create mode 100644 crates/yaak-models/src/queries/batch.rs
create mode 100644 crates/yaak-models/src/queries/cookie_jars.rs
create mode 100644 crates/yaak-models/src/queries/duplicate_name.rs
create mode 100644 crates/yaak-models/src/queries/environments.rs
create mode 100644 crates/yaak-models/src/queries/folders.rs
create mode 100644 crates/yaak-models/src/queries/graphql_introspections.rs
create mode 100644 crates/yaak-models/src/queries/grpc_connections.rs
create mode 100644 crates/yaak-models/src/queries/grpc_events.rs
create mode 100644 crates/yaak-models/src/queries/grpc_requests.rs
create mode 100644 crates/yaak-models/src/queries/http_requests.rs
create mode 100644 crates/yaak-models/src/queries/http_response_events.rs
create mode 100644 crates/yaak-models/src/queries/http_responses.rs
create mode 100644 crates/yaak-models/src/queries/import_source_resources.rs
create mode 100644 crates/yaak-models/src/queries/import_sources.rs
create mode 100644 crates/yaak-models/src/queries/key_values.rs
create mode 100644 crates/yaak-models/src/queries/mod.rs
create mode 100644 crates/yaak-models/src/queries/model_changes.rs
create mode 100644 crates/yaak-models/src/queries/plugin_key_values.rs
create mode 100644 crates/yaak-models/src/queries/plugins.rs
create mode 100644 crates/yaak-models/src/queries/settings.rs
create mode 100644 crates/yaak-models/src/queries/sync_states.rs
create mode 100644 crates/yaak-models/src/queries/websocket_connections.rs
create mode 100644 crates/yaak-models/src/queries/websocket_events.rs
create mode 100644 crates/yaak-models/src/queries/websocket_requests.rs
create mode 100644 crates/yaak-models/src/queries/workspace_metas.rs
create mode 100644 crates/yaak-models/src/queries/workspaces.rs
create mode 100644 crates/yaak-models/src/query_manager.rs
create mode 100644 crates/yaak-models/src/render.rs
create mode 100644 crates/yaak-models/src/util.rs
create mode 100644 crates/yaak-plugins/Cargo.toml
create mode 100644 crates/yaak-plugins/bindings/gen_api.ts
create mode 100644 crates/yaak-plugins/bindings/gen_events.ts
create mode 100644 crates/yaak-plugins/bindings/gen_models.ts
create mode 100644 crates/yaak-plugins/bindings/gen_search.ts
create mode 100644 crates/yaak-plugins/bindings/serde_json/JsonValue.ts
create mode 100644 crates/yaak-plugins/index.ts
create mode 100644 crates/yaak-plugins/package.json
create mode 100644 crates/yaak-plugins/src/api.rs
create mode 100644 crates/yaak-plugins/src/checksum.rs
create mode 100644 crates/yaak-plugins/src/error.rs
create mode 100644 crates/yaak-plugins/src/events.rs
create mode 100644 crates/yaak-plugins/src/install.rs
create mode 100644 crates/yaak-plugins/src/lib.rs
create mode 100644 crates/yaak-plugins/src/manager.rs
create mode 100644 crates/yaak-plugins/src/native_template_functions.rs
create mode 100644 crates/yaak-plugins/src/nodejs.rs
create mode 100644 crates/yaak-plugins/src/plugin_handle.rs
create mode 100644 crates/yaak-plugins/src/plugin_meta.rs
create mode 100644 crates/yaak-plugins/src/server_ws.rs
create mode 100644 crates/yaak-plugins/src/template_callback.rs
create mode 100644 crates/yaak-plugins/src/util.rs
create mode 100644 crates/yaak-proxy/Cargo.toml
create mode 100644 crates/yaak-proxy/src/body.rs
create mode 100644 crates/yaak-proxy/src/cert.rs
create mode 100644 crates/yaak-proxy/src/connection.rs
create mode 100644 crates/yaak-proxy/src/lib.rs
create mode 100644 crates/yaak-proxy/src/request.rs
create mode 100644 crates/yaak-sse/Cargo.toml
create mode 100644 crates/yaak-sse/bindings/sse.ts
create mode 100644 crates/yaak-sse/index.ts
create mode 100644 crates/yaak-sse/package.json
create mode 100644 crates/yaak-sse/src/lib.rs
create mode 100644 crates/yaak-sse/src/sse.rs
create mode 100644 crates/yaak-sse/summary.test.ts
create mode 100644 crates/yaak-sse/summary.ts
create mode 100644 crates/yaak-sync/Cargo.toml
create mode 100644 crates/yaak-sync/bindings/gen_models.ts
create mode 100644 crates/yaak-sync/bindings/gen_sync.ts
create mode 100644 crates/yaak-sync/bindings/gen_watch.ts
create mode 100644 crates/yaak-sync/bindings/git.ts
create mode 100644 crates/yaak-sync/index.ts
create mode 100644 crates/yaak-sync/package.json
create mode 100644 crates/yaak-sync/src/error.rs
create mode 100644 crates/yaak-sync/src/lib.rs
create mode 100644 crates/yaak-sync/src/models.rs
create mode 100644 crates/yaak-sync/src/sync.rs
create mode 100644 crates/yaak-sync/src/watch.rs
create mode 100644 crates/yaak-templates/Cargo.toml
create mode 100644 crates/yaak-templates/bindings/parser.ts
create mode 100644 crates/yaak-templates/build-wasm.cjs
create mode 100644 crates/yaak-templates/index.ts
create mode 100644 crates/yaak-templates/package.json
create mode 100644 crates/yaak-templates/pkg/package.json
create mode 100644 crates/yaak-templates/pkg/yaak_templates.d.ts
create mode 100644 crates/yaak-templates/pkg/yaak_templates.js
create mode 100644 crates/yaak-templates/pkg/yaak_templates_bg.js
create mode 100644 crates/yaak-templates/pkg/yaak_templates_bg.wasm
create mode 100644 crates/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts
create mode 100644 crates/yaak-templates/src/error.rs
create mode 100644 crates/yaak-templates/src/escape.rs
create mode 100644 crates/yaak-templates/src/format_json.rs
create mode 100644 crates/yaak-templates/src/lib.rs
create mode 100644 crates/yaak-templates/src/parser.rs
create mode 100644 crates/yaak-templates/src/renderer.rs
create mode 100644 crates/yaak-templates/src/strip_json_comments.rs
create mode 100644 crates/yaak-templates/src/wasm.rs
create mode 100644 crates/yaak-tls/Cargo.toml
create mode 100644 crates/yaak-tls/src/error.rs
create mode 100644 crates/yaak-tls/src/lib.rs
create mode 100644 crates/yaak-wasm/Cargo.toml
create mode 100644 crates/yaak-wasm/build-wasm.cjs
create mode 100644 crates/yaak-wasm/index.ts
create mode 100644 crates/yaak-wasm/package.json
create mode 100644 crates/yaak-wasm/pkg/package.json
create mode 100644 crates/yaak-wasm/pkg/yaak_wasm.d.ts
create mode 100644 crates/yaak-wasm/pkg/yaak_wasm.js
create mode 100644 crates/yaak-wasm/pkg/yaak_wasm_bg.js
create mode 100644 crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts
create mode 100644 crates/yaak-wasm/src/lib.rs
create mode 100644 crates/yaak-ws/Cargo.toml
create mode 100644 crates/yaak-ws/index.ts
create mode 100644 crates/yaak-ws/package.json
create mode 100644 crates/yaak-ws/src/connect.rs
create mode 100644 crates/yaak-ws/src/error.rs
create mode 100644 crates/yaak-ws/src/lib.rs
create mode 100644 crates/yaak-ws/src/manager.rs
create mode 100644 crates/yaak-ws/src/render.rs
create mode 100644 crates/yaak/Cargo.toml
create mode 100644 crates/yaak/src/error.rs
create mode 100644 crates/yaak/src/import.rs
create mode 100644 crates/yaak/src/lib.rs
create mode 100644 crates/yaak/src/plugin_events.rs
create mode 100644 crates/yaak/src/response_body.rs
create mode 100644 crates/yaak/src/send.rs
create mode 100644 flatpak/app.yaak.Yaak.metainfo.xml
create mode 100644 flatpak/fix-lockfile.mjs
create mode 100755 flatpak/generate-sources.sh
create mode 100755 flatpak/update-manifest.sh
create mode 100644 greptile.json
create mode 100644 npm/cli-darwin-arm64/bin/.gitkeep
create mode 100644 npm/cli-darwin-arm64/package.json
create mode 100644 npm/cli-darwin-x64/bin/.gitkeep
create mode 100644 npm/cli-darwin-x64/package.json
create mode 100644 npm/cli-linux-arm64/bin/.gitkeep
create mode 100644 npm/cli-linux-arm64/package.json
create mode 100644 npm/cli-linux-x64/bin/.gitkeep
create mode 100644 npm/cli-linux-x64/package.json
create mode 100644 npm/cli-win32-arm64/bin/.gitkeep
create mode 100644 npm/cli-win32-arm64/package.json
create mode 100644 npm/cli-win32-x64/bin/.gitkeep
create mode 100644 npm/cli-win32-x64/package.json
create mode 100644 npm/cli/.gitignore
create mode 100755 npm/cli/bin/cli.js
create mode 100644 npm/cli/common.js
create mode 100644 npm/cli/index.js
create mode 100644 npm/cli/install.js
create mode 100644 npm/cli/package.json
create mode 100644 npm/cli/prepublish.js
create mode 100644 npm/prepare-publish.js
create mode 100644 package.json
create mode 100644 packages/common-lib/debounce.test.ts
create mode 100644 packages/common-lib/debounce.ts
create mode 100644 packages/common-lib/eagerDebounceAsync.ts
create mode 100644 packages/common-lib/formatSize.ts
create mode 100644 packages/common-lib/index.ts
create mode 100644 packages/common-lib/jsonPath.ts
create mode 100644 packages/common-lib/package.json
create mode 100644 packages/common-lib/templateFunction.ts
create mode 100644 packages/model-store/package.json
create mode 100644 packages/model-store/src/index.ts
create mode 100644 packages/platform/package.json
create mode 100644 packages/platform/src/capabilities.ts
create mode 100644 packages/platform/src/index.ts
create mode 100644 packages/platform/src/index.web.ts
create mode 100644 packages/platform/src/registry.ts
create mode 100644 packages/platform/src/tauri/index.ts
create mode 100644 packages/platform/src/types.ts
create mode 100644 packages/platform/src/web/README.md
create mode 100644 packages/platform/src/web/commands.ts
create mode 100644 packages/platform/src/web/connection.ts
create mode 100644 packages/platform/src/web/errors.ts
create mode 100644 packages/platform/src/web/ids.ts
create mode 100644 packages/platform/src/web/index.ts
create mode 100644 packages/platform/src/web/protocol.ts
create mode 100644 packages/platform/src/web/send.ts
create mode 100644 packages/platform/src/web/server.ts
create mode 100644 packages/platform/src/web/storage.ts
create mode 100644 packages/platform/src/web/worker.ts
create mode 100644 packages/platform/tsconfig.json
create mode 100644 packages/plugin-runtime-types/.gitignore
create mode 100644 packages/plugin-runtime-types/README.md
create mode 100644 packages/plugin-runtime-types/package.json
create mode 100644 packages/plugin-runtime-types/src/bindings/gen_api.ts
create mode 100644 packages/plugin-runtime-types/src/bindings/gen_events.ts
create mode 100644 packages/plugin-runtime-types/src/bindings/gen_models.ts
create mode 100644 packages/plugin-runtime-types/src/bindings/gen_search.ts
create mode 100644 packages/plugin-runtime-types/src/bindings/serde_json/JsonValue.ts
create mode 100644 packages/plugin-runtime-types/src/helpers.ts
create mode 100644 packages/plugin-runtime-types/src/index.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/Context.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/FilterPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/FolderActionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/GrpcRequestActionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/HttpRequestActionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/ThemePlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/WebsocketRequestActionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/WorkspaceActionPlugin.ts
create mode 100644 packages/plugin-runtime-types/src/plugins/index.ts
create mode 100644 packages/plugin-runtime-types/src/themes/index.ts
create mode 100644 packages/plugin-runtime-types/tsconfig.json
create mode 100644 packages/plugin-runtime/.node-version
create mode 100644 packages/plugin-runtime/package.json
create mode 100644 packages/plugin-runtime/src/EventChannel.ts
create mode 100644 packages/plugin-runtime/src/PluginHandle.ts
create mode 100644 packages/plugin-runtime/src/PluginInstance.ts
create mode 100644 packages/plugin-runtime/src/common.ts
create mode 100644 packages/plugin-runtime/src/index.ts
create mode 100644 packages/plugin-runtime/src/interceptStdout.ts
create mode 100644 packages/plugin-runtime/src/metadata.ts
create mode 100644 packages/plugin-runtime/src/migrations.ts
create mode 100644 packages/plugin-runtime/src/responseBody.ts
create mode 100644 packages/plugin-runtime/tests/common.test.ts
create mode 100644 packages/plugin-runtime/tests/responseBody.test.ts
create mode 100644 packages/plugin-runtime/tests/sendHttpRequest.test.ts
create mode 100644 packages/plugin-runtime/tsconfig.json
create mode 100644 packages/tailwind-config/index.css
create mode 100644 packages/tailwind-config/package.json
create mode 100644 packages/theme/package.json
create mode 100644 packages/theme/src/appearance.ts
create mode 100644 packages/theme/src/defaultThemes.ts
create mode 100644 packages/theme/src/index.ts
create mode 100644 packages/theme/src/window.ts
create mode 100644 packages/theme/src/yaakColor.ts
create mode 100644 packages/theme/tsconfig.json
create mode 100644 packages/ui/package.json
create mode 100644 packages/ui/src/components/Banner.tsx
create mode 100644 packages/ui/src/components/Button.tsx
create mode 100644 packages/ui/src/components/DropMarker.tsx
create mode 100644 packages/ui/src/components/FormattedError.tsx
create mode 100644 packages/ui/src/components/HeaderSize.tsx
create mode 100644 packages/ui/src/components/Heading.tsx
create mode 100644 packages/ui/src/components/Icon.tsx
create mode 100644 packages/ui/src/components/IconButton.tsx
create mode 100644 packages/ui/src/components/InlineCode.tsx
create mode 100644 packages/ui/src/components/LoadingIcon.tsx
create mode 100644 packages/ui/src/components/Overlay.tsx
create mode 100644 packages/ui/src/components/Portal.tsx
create mode 100644 packages/ui/src/components/ResizeHandle.tsx
create mode 100644 packages/ui/src/components/SidebarLayout.tsx
create mode 100644 packages/ui/src/components/SplitLayout.tsx
create mode 100644 packages/ui/src/components/Stacks.tsx
create mode 100644 packages/ui/src/components/Table.tsx
create mode 100644 packages/ui/src/components/WindowControls.tsx
create mode 100644 packages/ui/src/components/tree/Tree.tsx
create mode 100644 packages/ui/src/components/tree/TreeDragOverlay.tsx
create mode 100644 packages/ui/src/components/tree/TreeDropMarker.tsx
create mode 100644 packages/ui/src/components/tree/TreeIndentGuide.tsx
create mode 100644 packages/ui/src/components/tree/TreeItem.tsx
create mode 100644 packages/ui/src/components/tree/TreeItemList.tsx
create mode 100644 packages/ui/src/components/tree/atoms.ts
create mode 100644 packages/ui/src/components/tree/common.ts
create mode 100644 packages/ui/src/components/tree/context.ts
create mode 100644 packages/ui/src/components/tree/keyboard.test.ts
create mode 100644 packages/ui/src/components/tree/keyboard.ts
create mode 100644 packages/ui/src/components/tree/useSelectableItems.ts
create mode 100644 packages/ui/src/hooks/useContainerSize.ts
create mode 100644 packages/ui/src/hooks/useDebouncedState.ts
create mode 100644 packages/ui/src/hooks/useDebouncedValue.ts
create mode 100644 packages/ui/src/hooks/useIsFullscreen.ts
create mode 100644 packages/ui/src/hooks/usePortal.ts
create mode 100644 packages/ui/src/hooks/useTimedBoolean.ts
create mode 100644 packages/ui/src/index.ts
create mode 100644 packages/ui/src/lib/clamp.ts
create mode 100644 packages/ui/src/lib/constants.ts
create mode 100644 packages/ui/src/lib/dnd.ts
create mode 100644 packages/ui/src/lib/minPromiseMillis.ts
create mode 100644 packages/ui/tsconfig.json
create mode 100644 plugins-external/.gitignore
create mode 100644 plugins-external/faker/README.md
create mode 100755 plugins-external/faker/package.json
create mode 100755 plugins-external/faker/src/index.ts
create mode 100644 plugins-external/faker/tests/__snapshots__/init.test.ts.snap
create mode 100644 plugins-external/faker/tests/init.test.ts
create mode 100755 plugins-external/faker/tsconfig.json
create mode 100644 plugins-external/httpsnippet/README.md
create mode 100644 plugins-external/httpsnippet/package.json
create mode 100644 plugins-external/httpsnippet/src/index.ts
create mode 100644 plugins-external/mcp-server/README.md
create mode 100644 plugins-external/mcp-server/package.json
create mode 100644 plugins-external/mcp-server/src/index.ts
create mode 100644 plugins-external/mcp-server/src/server.ts
create mode 100644 plugins-external/mcp-server/src/tools/folder.ts
create mode 100644 plugins-external/mcp-server/src/tools/helpers.ts
create mode 100644 plugins-external/mcp-server/src/tools/httpRequest.test.ts
create mode 100644 plugins-external/mcp-server/src/tools/httpRequest.ts
create mode 100644 plugins-external/mcp-server/src/tools/schemas.ts
create mode 100644 plugins-external/mcp-server/src/tools/toast.ts
create mode 100644 plugins-external/mcp-server/src/tools/window.ts
create mode 100644 plugins-external/mcp-server/src/tools/workspace.ts
create mode 100644 plugins-external/mcp-server/src/types.ts
create mode 100644 plugins-external/mcp-server/tsconfig.json
create mode 100644 plugins/.gitignore
create mode 100644 plugins/action-copy-curl/README.md
create mode 100644 plugins/action-copy-curl/package.json
create mode 100644 plugins/action-copy-curl/src/index.ts
create mode 100644 plugins/action-copy-curl/tests/index.test.ts
create mode 100644 plugins/action-copy-curl/tsconfig.json
create mode 100644 plugins/action-copy-grpcurl/README.md
create mode 100644 plugins/action-copy-grpcurl/package.json
create mode 100644 plugins/action-copy-grpcurl/src/index.ts
create mode 100644 plugins/action-copy-grpcurl/tests/index.test.ts
create mode 100644 plugins/action-copy-grpcurl/tsconfig.json
create mode 100644 plugins/action-send-folder/package.json
create mode 100644 plugins/action-send-folder/src/index.ts
create mode 100644 plugins/action-send-folder/tsconfig.json
create mode 100644 plugins/auth-apikey/package.json
create mode 100644 plugins/auth-apikey/src/index.ts
create mode 100644 plugins/auth-apikey/tsconfig.json
create mode 100644 plugins/auth-aws/README.md
create mode 100644 plugins/auth-aws/package.json
create mode 100644 plugins/auth-aws/src/index.ts
create mode 100644 plugins/auth-aws/tsconfig.json
create mode 100644 plugins/auth-basic/README.md
create mode 100644 plugins/auth-basic/package.json
create mode 100644 plugins/auth-basic/src/index.ts
create mode 100644 plugins/auth-basic/tests/index.test.ts
create mode 100644 plugins/auth-basic/tsconfig.json
create mode 100644 plugins/auth-bearer/README.md
create mode 100644 plugins/auth-bearer/package.json
create mode 100644 plugins/auth-bearer/src/index.ts
create mode 100644 plugins/auth-bearer/tests/index.test.ts
create mode 100644 plugins/auth-bearer/tsconfig.json
create mode 100644 plugins/auth-digest/README.md
create mode 100644 plugins/auth-digest/package.json
create mode 100644 plugins/auth-digest/src/digest.ts
create mode 100644 plugins/auth-digest/src/index.ts
create mode 100644 plugins/auth-digest/tests/digest.test.ts
create mode 100644 plugins/auth-digest/tests/index.test.ts
create mode 100644 plugins/auth-digest/tsconfig.json
create mode 100644 plugins/auth-jwt/README.md
create mode 100644 plugins/auth-jwt/package.json
create mode 100644 plugins/auth-jwt/src/index.ts
create mode 100644 plugins/auth-jwt/tsconfig.json
create mode 100644 plugins/auth-ntlm/package.json
create mode 100644 plugins/auth-ntlm/src/index.ts
create mode 100644 plugins/auth-ntlm/src/modules.d.ts
create mode 100644 plugins/auth-ntlm/tests/index.test.ts
create mode 100644 plugins/auth-ntlm/tsconfig.json
create mode 100644 plugins/auth-oauth1/package.json
create mode 100644 plugins/auth-oauth1/src/index.ts
create mode 100644 plugins/auth-oauth1/tests/plaintext.test.ts
create mode 100644 plugins/auth-oauth1/tsconfig.json
create mode 100644 plugins/auth-oauth2/README.md
create mode 100644 plugins/auth-oauth2/package.json
create mode 100644 plugins/auth-oauth2/src/callbackServer.ts
create mode 100644 plugins/auth-oauth2/src/customParams.ts
create mode 100644 plugins/auth-oauth2/src/fetchAccessToken.ts
create mode 100644 plugins/auth-oauth2/src/getOrRefreshAccessToken.ts
create mode 100644 plugins/auth-oauth2/src/grants/authorizationCode.ts
create mode 100644 plugins/auth-oauth2/src/grants/clientCredentials.ts
create mode 100644 plugins/auth-oauth2/src/grants/implicit.ts
create mode 100644 plugins/auth-oauth2/src/grants/password.ts
create mode 100644 plugins/auth-oauth2/src/index.ts
create mode 100644 plugins/auth-oauth2/src/store.ts
create mode 100644 plugins/auth-oauth2/src/util.ts
create mode 100644 plugins/auth-oauth2/tests/authorizationCode.test.ts
create mode 100644 plugins/auth-oauth2/tests/customParams.test.ts
create mode 100644 plugins/auth-oauth2/tests/fetchAccessToken.test.ts
create mode 100644 plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts
create mode 100644 plugins/auth-oauth2/tests/store.test.ts
create mode 100644 plugins/auth-oauth2/tests/util.test.ts
create mode 100644 plugins/auth-oauth2/tsconfig.json
create mode 100644 plugins/filter-jsonpath/README.md
create mode 100644 plugins/filter-jsonpath/package.json
create mode 100644 plugins/filter-jsonpath/src/index.ts
create mode 100644 plugins/filter-jsonpath/tests/breadcrumbs.test.ts
create mode 100644 plugins/filter-jsonpath/tsconfig.json
create mode 100644 plugins/filter-xpath/package.json
create mode 100644 plugins/filter-xpath/src/index.ts
create mode 100644 plugins/filter-xpath/tsconfig.json
create mode 100644 plugins/importer-curl/package.json
create mode 100644 plugins/importer-curl/src/graphql.ts
create mode 100644 plugins/importer-curl/src/index.ts
create mode 100644 plugins/importer-curl/tests/graphql.test.ts
create mode 100644 plugins/importer-curl/tests/index.test.ts
create mode 100644 plugins/importer-curl/tsconfig.json
create mode 100644 plugins/importer-insomnia/package.json
create mode 100644 plugins/importer-insomnia/src/common.ts
create mode 100644 plugins/importer-insomnia/src/index.ts
create mode 100644 plugins/importer-insomnia/src/v4.ts
create mode 100644 plugins/importer-insomnia/src/v5.ts
create mode 100644 plugins/importer-insomnia/tests/body.test.ts
create mode 100644 plugins/importer-insomnia/tests/fixtures/basic.input.json
create mode 100644 plugins/importer-insomnia/tests/fixtures/basic.output.json
create mode 100644 plugins/importer-insomnia/tests/fixtures/version-5-minimal.input.yaml
create mode 100644 plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json
create mode 100644 plugins/importer-insomnia/tests/fixtures/version-5.input.yaml
create mode 100644 plugins/importer-insomnia/tests/fixtures/version-5.output.json
create mode 100644 plugins/importer-insomnia/tests/index.test.ts
create mode 100644 plugins/importer-insomnia/tsconfig.json
create mode 100644 plugins/importer-openapi/package.json
create mode 100644 plugins/importer-openapi/src/index.ts
create mode 100644 plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap
create mode 100644 plugins/importer-openapi/tests/fixtures/petstore.yaml
create mode 100644 plugins/importer-openapi/tests/fixtures/real-world/SOURCES.md
create mode 100644 plugins/importer-openapi/tests/fixtures/real-world/apis-guru.yaml
create mode 100644 plugins/importer-openapi/tests/fixtures/real-world/httpbin.yaml
create mode 100644 plugins/importer-openapi/tests/fixtures/real-world/nasa-apod.yaml
create mode 100644 plugins/importer-openapi/tests/fixtures/real-world/xkcd.yaml
create mode 100644 plugins/importer-openapi/tests/index.test.ts
create mode 100644 plugins/importer-openapi/tests/roundtrip.mjs
create mode 100644 plugins/importer-openapi/tsconfig.json
create mode 100644 plugins/importer-postman-environment/package.json
create mode 100644 plugins/importer-postman-environment/src/index.ts
create mode 100644 plugins/importer-postman-environment/tests/fixtures/environment.input.json
create mode 100644 plugins/importer-postman-environment/tests/fixtures/environment.output.json
create mode 100644 plugins/importer-postman-environment/tests/index.test.ts
create mode 100644 plugins/importer-postman-environment/tsconfig.json
create mode 100644 plugins/importer-postman/package.json
create mode 100644 plugins/importer-postman/src/index.ts
create mode 100644 plugins/importer-postman/tests/fixtures/auth.input.json
create mode 100644 plugins/importer-postman/tests/fixtures/auth.output.json
create mode 100644 plugins/importer-postman/tests/fixtures/dynamic-variables.input.json
create mode 100644 plugins/importer-postman/tests/fixtures/dynamic-variables.output.json
create mode 100644 plugins/importer-postman/tests/fixtures/nested.input.json
create mode 100644 plugins/importer-postman/tests/fixtures/nested.output.json
create mode 100644 plugins/importer-postman/tests/fixtures/params.input.json
create mode 100644 plugins/importer-postman/tests/fixtures/params.output.json
create mode 100644 plugins/importer-postman/tests/index.test.ts
create mode 100644 plugins/importer-postman/tsconfig.json
create mode 100644 plugins/importer-yaak/package.json
create mode 100644 plugins/importer-yaak/src/index.ts
create mode 100644 plugins/importer-yaak/tests/index.test.ts
create mode 100644 plugins/importer-yaak/tsconfig.json
create mode 100644 plugins/template-function-1password/package.json
create mode 100644 plugins/template-function-1password/src/index.ts
create mode 100644 plugins/template-function-1password/tsconfig.json
create mode 100644 plugins/template-function-cookie/package.json
create mode 100644 plugins/template-function-cookie/src/index.ts
create mode 100644 plugins/template-function-cookie/tsconfig.json
create mode 100644 plugins/template-function-ctx/package.json
create mode 100644 plugins/template-function-ctx/src/index.ts
create mode 100644 plugins/template-function-ctx/tsconfig.json
create mode 100644 plugins/template-function-encode/package.json
create mode 100644 plugins/template-function-encode/src/index.ts
create mode 100644 plugins/template-function-encode/tsconfig.json
create mode 100644 plugins/template-function-fs/package.json
create mode 100644 plugins/template-function-fs/src/index.ts
create mode 100644 plugins/template-function-fs/tsconfig.json
create mode 100755 plugins/template-function-hash/package.json
create mode 100755 plugins/template-function-hash/src/index.ts
create mode 100644 plugins/template-function-hash/tsconfig.json
create mode 100755 plugins/template-function-json/package.json
create mode 100755 plugins/template-function-json/src/index.ts
create mode 100644 plugins/template-function-json/tests/escape.test.ts
create mode 100644 plugins/template-function-json/tsconfig.json
create mode 100644 plugins/template-function-prompt/package.json
create mode 100644 plugins/template-function-prompt/src/index.ts
create mode 100644 plugins/template-function-prompt/tsconfig.json
create mode 100644 plugins/template-function-random/package.json
create mode 100644 plugins/template-function-random/src/index.ts
create mode 100644 plugins/template-function-random/tsconfig.json
create mode 100644 plugins/template-function-regex/package.json
create mode 100644 plugins/template-function-regex/src/index.ts
create mode 100644 plugins/template-function-regex/tests/regex.test.ts
create mode 100644 plugins/template-function-regex/tsconfig.json
create mode 100755 plugins/template-function-request/package.json
create mode 100755 plugins/template-function-request/src/index.ts
create mode 100644 plugins/template-function-request/tsconfig.json
create mode 100644 plugins/template-function-response/package.json
create mode 100644 plugins/template-function-response/src/index.ts
create mode 100644 plugins/template-function-response/tsconfig.json
create mode 100755 plugins/template-function-timestamp/package.json
create mode 100755 plugins/template-function-timestamp/src/index.ts
create mode 100644 plugins/template-function-timestamp/tests/formatDatetime.test.ts
create mode 100644 plugins/template-function-timestamp/tsconfig.json
create mode 100644 plugins/template-function-uuid/package.json
create mode 100644 plugins/template-function-uuid/src/index.ts
create mode 100644 plugins/template-function-uuid/tests/uuid.test.ts
create mode 100644 plugins/template-function-uuid/tsconfig.json
create mode 100644 plugins/template-function-xml/package.json
create mode 100755 plugins/template-function-xml/src/index.ts
create mode 100644 plugins/template-function-xml/tsconfig.json
create mode 100644 plugins/themes-yaak/package.json
create mode 100644 plugins/themes-yaak/src/index.ts
create mode 100644 plugins/themes-yaak/src/themes/andromeda.ts
create mode 100644 plugins/themes-yaak/src/themes/atom-one-dark.ts
create mode 100644 plugins/themes-yaak/src/themes/ayu.ts
create mode 100644 plugins/themes-yaak/src/themes/bluloco.ts
create mode 100644 plugins/themes-yaak/src/themes/catppuccin.ts
create mode 100644 plugins/themes-yaak/src/themes/cobalt2.ts
create mode 100644 plugins/themes-yaak/src/themes/dracula.ts
create mode 100644 plugins/themes-yaak/src/themes/everforest.ts
create mode 100644 plugins/themes-yaak/src/themes/fleet.ts
create mode 100644 plugins/themes-yaak/src/themes/github-dimmed.ts
create mode 100644 plugins/themes-yaak/src/themes/github.ts
create mode 100644 plugins/themes-yaak/src/themes/gruvbox.ts
create mode 100644 plugins/themes-yaak/src/themes/high-contrast.ts
create mode 100644 plugins/themes-yaak/src/themes/horizon.ts
create mode 100644 plugins/themes-yaak/src/themes/hotdog-stand.ts
create mode 100644 plugins/themes-yaak/src/themes/material-darker.ts
create mode 100644 plugins/themes-yaak/src/themes/material-ocean.ts
create mode 100644 plugins/themes-yaak/src/themes/material-palenight.ts
create mode 100644 plugins/themes-yaak/src/themes/monokai-pro.ts
create mode 100644 plugins/themes-yaak/src/themes/moonlight.ts
create mode 100644 plugins/themes-yaak/src/themes/night-owl.ts
create mode 100644 plugins/themes-yaak/src/themes/noctis.ts
create mode 100644 plugins/themes-yaak/src/themes/nord.ts
create mode 100644 plugins/themes-yaak/src/themes/one-dark-pro.ts
create mode 100644 plugins/themes-yaak/src/themes/panda.ts
create mode 100644 plugins/themes-yaak/src/themes/relaxing.ts
create mode 100644 plugins/themes-yaak/src/themes/rose-pine.ts
create mode 100644 plugins/themes-yaak/src/themes/shades-of-purple.ts
create mode 100644 plugins/themes-yaak/src/themes/slack.ts
create mode 100644 plugins/themes-yaak/src/themes/solarized.ts
create mode 100644 plugins/themes-yaak/src/themes/synthwave-84.ts
create mode 100644 plugins/themes-yaak/src/themes/tokyo-night.ts
create mode 100644 plugins/themes-yaak/src/themes/triangle.ts
create mode 100644 plugins/themes-yaak/src/themes/vitesse.ts
create mode 100644 plugins/themes-yaak/src/themes/winter-is-coming.ts
create mode 100644 plugins/themes-yaak/tsconfig.json
create mode 100644 rustfmt.toml
create mode 100644 scripts/.gitignore
create mode 100644 scripts/create-migration.cjs
create mode 100644 scripts/extract-archive.cjs
create mode 100644 scripts/git-hooks/post-checkout.mjs
create mode 100644 scripts/install-wasm-pack.cjs
create mode 100644 scripts/publish-core-plugins.cjs
create mode 100644 scripts/replace-version.cjs
create mode 100644 scripts/run-build.mjs
create mode 100644 scripts/run-dev.mjs
create mode 100644 scripts/run-web.mjs
create mode 100644 scripts/run-workspaces-dev.mjs
create mode 100644 scripts/vendor-node.cjs
create mode 100644 scripts/vendor-plugins.cjs
create mode 100644 scripts/vendor-protoc.cjs
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
diff --git a/.claude-context.md b/.claude-context.md
new file mode 100644
index 0000000..97e20f6
--- /dev/null
+++ b/.claude-context.md
@@ -0,0 +1,83 @@
+# Claude Context: Detaching Tauri from Yaak
+
+## Goal
+
+Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
+
+## Project Structure
+
+```
+crates/ # Core crates - should NOT depend on Tauri
+crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
+crates-cli/ # CLI crate (yaak-cli)
+```
+
+## Completed Work
+
+### 1. Folder Restructure
+
+- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
+- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
+- Created `crates-cli/yaak-cli/` for the standalone CLI
+
+### 2. Decoupled Crates (no longer depend on Tauri)
+
+- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
+- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
+- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
+- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
+- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
+
+### 3. CLI Implementation
+
+- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
+- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
+- Uses same database as Tauri app via `yaak_models::init_standalone()`
+
+## Remaining Work
+
+### Crates Still Depending on Tauri (in `crates/`)
+
+1. **yaak-git** (3 files) - Moderate complexity
+2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
+3. **yaak-sync** (4 files) - Moderate complexity
+4. **yaak-ws** (5 files) - Moderate complexity
+
+### Pattern for Decoupling
+
+1. Remove Tauri plugin `init()` function from the crate
+2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
+3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
+4. Initialize managers in yaak-app's `.setup()` block
+5. Remove `tauri` from Cargo.toml dependencies
+6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
+7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
+
+## Key Files
+
+- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
+- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
+- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
+- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
+- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
+
+## Git Branch
+
+Working on `detach-tauri` branch.
+
+## Recent Commits
+
+```
+c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
+df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
+481e0273 Remove Tauri dependencies from yaak-http and yaak-common
+10568ac3 Add HTTP request sending to yaak-cli
+bcb7d600 Add yaak-cli stub with basic database access
+e718a5f1 Refactor models_ext to use init_standalone from yaak-models
+```
+
+## Testing
+
+- Run `cargo check -p ` to verify a crate builds without Tauri
+- Run `npm run client:dev` to test the Tauri app still works
+- Run `cargo run -p yaak-cli -- --help` to test the CLI
diff --git a/.claude/rules.md b/.claude/rules.md
new file mode 100644
index 0000000..aa0fb39
--- /dev/null
+++ b/.claude/rules.md
@@ -0,0 +1,27 @@
+# Project Rules
+
+## General Development
+
+- **NEVER** commit or push without explicit confirmation
+
+## Build and Lint
+
+- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
+- Run `npm run bootstrap` after changing plugin runtime or MCP server code
+
+## Plugin System
+
+### Backend Constraints
+
+- Always use `UpdateSource::Plugin` when calling database methods from plugin events
+- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
+- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
+
+### MCP Server
+
+- MCP server has **no active window context** - cannot call `window.workspaceId()`
+- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
+
+## Rust Type Generation
+
+- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
diff --git a/.codex/skills/release-generate-release-notes/SKILL.md b/.codex/skills/release-generate-release-notes/SKILL.md
new file mode 100644
index 0000000..f5df99c
--- /dev/null
+++ b/.codex/skills/release-generate-release-notes/SKILL.md
@@ -0,0 +1,52 @@
+---
+name: release-generate-release-notes
+description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
+---
+
+# Generate Release Notes
+
+Generate formatted markdown release notes for a Yaak tag.
+
+## Workflow
+
+1. Determine target tag.
+2. Determine previous comparable tag:
+ - Beta tag: compare against previous beta (if the root version is the same) or stable tag.
+ - Stable tag: compare against previous stable tag.
+3. Collect commits in range:
+ - `git log --oneline ..`
+4. For linked PRs, fetch metadata:
+ - `gh pr view --json number,title,body,author,url`
+5. Extract useful details:
+ - Feedback URLs (`feedback.yaak.app`)
+ - Contributor GitHub handles from `author.login`
+ - Plugin install links or other notable context
+6. Format notes using Yaak style:
+ - Changelog badge at top
+ - Bulleted items with PR links where available
+ - Contributor handles for external PRs
+ - Feedback links where available
+ - Full changelog compare link at bottom
+
+## Formatting Rules
+
+- Wrap final notes in a markdown code fence.
+- Keep a blank line before and after the code fence.
+- Output the markdown code block last.
+- Append contributor attribution to PR-backed bullets for non-`@gschier` authors, using `by [@handle](https://github.com/handle)`.
+- Do not append `by @gschier` for PRs authored by `@gschier`.
+- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
+
+## Release Creation Prompt
+
+After producing notes, ask whether to create a draft GitHub release.
+
+If confirmed and release does not yet exist, run:
+
+`gh release create --draft --prerelease --title "Release " --notes ''`
+
+If a draft release for the tag already exists, update it instead:
+
+`gh release edit --title "Release " --notes-file `
+
+Use title format `Release `, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.
diff --git a/.codex/skills/yaak-changelog/SKILL.md b/.codex/skills/yaak-changelog/SKILL.md
new file mode 100644
index 0000000..48cd860
--- /dev/null
+++ b/.codex/skills/yaak-changelog/SKILL.md
@@ -0,0 +1,104 @@
+---
+name: yaak-changelog
+description: Create or edit Yaak changelogs. Beta and draft prerelease changelogs live only in the GitHub release body; stable release changelogs live in `src/content/changelog/YYYYMMDD_VERSION/`. Use when Codex needs to update a beta GitHub release body, generate a stable website changelog from beta releases, update `_release.yaml` or `_intro.md`, expand major entries into markdown files, or preserve Yaak's changelog writing style.
+---
+
+# Yaak Changelog
+
+Use this skill to create Yaak changelogs in the correct place:
+
+- Beta or draft prerelease changelogs live only on the GitHub release.
+- Stable release changelogs live in website files under `src/content/changelog/YYYYMMDD_VERSION/`.
+
+## Workflow
+
+1. Identify the target release.
+- If the target tag contains `-beta` or the GitHub release is a draft prerelease, update the GitHub release body only. Do not create or edit `src/content/changelog/` files for beta releases.
+- For a new stable changelog, gather all beta release notes for that version since the previous stable release, then create or edit website changelog files.
+- Prefer `gh api` or GitHub release pages. If network access is restricted, request permission before querying GitHub.
+- Extract PR numbers and `yaak.app/feedback` URLs while fetching. If most bullets do not include PR references, fetch again with a more specific prompt.
+- Fetch PR authors when generating or revising release notes. Include contributor attribution for non-`@gschier` PR authors.
+
+2. Parse release bullets.
+- Treat each release-note bullet as one changelog entry.
+- Skip dependency-only, generated, build-only, test-only, CI-only, and internal maintenance bullets unless they have a clear user-facing impact that can be described in user terms.
+- For stable website changelogs, skip bullets prefixed with `[beta-only]`.
+- Preserve the entry wording closely. Remove wrapping quotes from titles.
+- Map categories to `feature`, `fix`, `improvement`, or `breaking`.
+- Convert `#NNN` into `https://github.com/mountain-loop/yaak/pull/NNN`.
+
+3. For beta or draft prerelease changelogs, update the GitHub release.
+- Keep the changelog in the GitHub release body. Do not create a website changelog directory.
+- Do not add a changelog badge or link to `yaak.app/changelog/VERSION` for beta releases.
+- Prefer concise bullets with PR links and feedback links when available.
+- When a bullet has a feedback URL, wrap the changelog item text itself in the feedback link, then put the PR link after it. Example: `- [Fixed request history timestamps](https://yaak.app/feedback/posts/request-history-time-stamp) in [#492](https://github.com/mountain-loop/yaak/pull/492)`.
+- Append `by [@handle](https://github.com/handle)` to PR-backed bullets authored by external contributors. Do not append `by @gschier` for `@gschier` PRs.
+- Include a `**Full Changelog**` comparison link using the previous beta tag when it exists, or the previous stable tag for `beta.1`.
+- Use `gh release edit TAG --repo mountain-loop/yaak --notes-file ...` or the GitHub release API to update the draft/prerelease body.
+- Stop after verifying the GitHub release body. The website checks below do not apply.
+
+4. For stable website changelogs, create or edit the release directory.
+- Path format: `src/content/changelog/YYYYMMDD_VERSION/`.
+- For a new release, use today's date for `YYYYMMDD`.
+- For an existing release, keep the original directory date.
+- Do not create changelog directories for beta releases.
+
+5. Write `_release.yaml`.
+- Include `draft`, optional `title`, `summary`, `image`, `youtube`, and `entries`.
+- Keep minor items as quick entries without `content`.
+- Use `content` only when an entry needs its own markdown section.
+
+```yaml
+title: "What's New in 2026.1.0"
+summary: "Brief overview of the most important additions and fixes"
+draft: true
+entries:
+ - title: "Request debugging"
+ category: feature
+ pr: "https://github.com/mountain-loop/yaak/pull/123"
+ feedback: "https://feedback.yaak.app/p/request-debugging"
+ content: "request-debugging.md"
+ - title: "Fix broken cookie clearing"
+ category: fix
+ pr: "https://github.com/mountain-loop/yaak/pull/124"
+```
+
+6. Expand major entries.
+- Expand 3 to 6 major items when enough context exists.
+- Create slugified markdown files and reference them with `content`.
+- Read the related PR before writing expanded content.
+- Add emoji prefixes only for expanded entry titles if it helps distinguish major sections.
+
+7. Handle images.
+- Reuse screenshots from PRs when they exist.
+- Convert GitHub private attachment URLs to `https://github.com/user-attachments/assets/UUID` before upload.
+- Upload with `go run cmd/yaakadmin/main.go upload "URL"` when the environment permits it.
+- If no real image is available, use a placeholder with real alt text and a caption.
+
+8. Write `_intro.md`.
+- Add a short overview paragraph at the top of the release.
+- Focus on the major themes across the release instead of repeating every bullet.
+
+9. Follow Yaak writing style.
+- Be direct and factual. Avoid hype.
+- State what changed and how to use it.
+- Keep paragraphs short.
+- Use backticks for code symbols, settings, and literal values.
+- Use bold sparingly for the most important phrase in a section.
+
+## File Rules
+
+- Beta releases must not create or edit files in `src/content/changelog/`.
+- Main files are `_release.yaml` and optional `_intro.md`.
+- Expanded entry files are regular markdown files such as `request-debugging.md`.
+- `entries[].content` must match an existing markdown filename in the same directory.
+- Images for changelog pages live under `static/changelog/VERSION/` when committed to the repo.
+
+## Checks
+
+- For beta releases, verify `gh release view TAG --repo mountain-loop/yaak --json body,tagName,isDraft,isPrerelease` and ensure no website changelog files were created.
+- For beta releases, verify feedback-backed bullets use the feedback URL as the link target for the whole item text, not as a separate trailing `Feedback:` link.
+- For stable releases, ensure each user-facing source bullet becomes exactly one changelog entry unless it is `[beta-only]` or dependency-only/internal maintenance.
+- Ensure most entries include `pr` when the source release notes provide one.
+- For stable releases, ensure every referenced `content` file exists.
+- If the user wants stable website verification, run the site and inspect `/changelog/VERSION` and `/rss.xml`.
diff --git a/.codex/skills/yaak-changelog/agents/openai.yaml b/.codex/skills/yaak-changelog/agents/openai.yaml
new file mode 100644
index 0000000..d4d46b3
--- /dev/null
+++ b/.codex/skills/yaak-changelog/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "Yaak Changelog"
+ short_description: "Generate Yaak changelog releases"
+ default_prompt: "Use $yaak-changelog to create or update a Yaak changelog release from GitHub release notes."
+
+policy:
+ allow_implicit_invocation: true
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..50e4b2f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+**/node_modules
+dist
+**/dist
+target
+**/target
+.claude
+vendored
+**/vendored
+*.log
+.git
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..67ab542
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
+crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
+**/bindings/* linguist-generated=true
+crates/yaak-templates/pkg/* linguist-generated=true
+
+# Ensure consistent line endings for test files that check exact content
+crates/yaak-http/tests/test.txt text eol=lf
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..c1cde08
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: gschier
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..9b77ea7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,40 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ""
+labels: ""
+assignees: ""
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+
+- OS: [e.g. iOS]
+- Browser [e.g. chrome, safari]
+- Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+
+- Device: [e.g. iPhone6]
+- OS: [e.g. iOS8.1]
+- Browser [e.g. stock browser, safari]
+- Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..c9e2ff5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Bugs, Feedback, Feature Requests, and Questions
+ url: https://feedback.yaak.app
+ about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable."
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0a86c6a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,55 @@
+version: 2
+
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ ignore:
+ # Build tooling majors are opted into, not queued. These never reach a
+ # user, so the cost of being a release behind is low and the cost of a
+ # surprise is a broken build. Note this also suppresses security PRs for
+ # these, so Dependabot alerts are the backstop.
+ - dependency-name: typescript
+ update-types: ["version-update:semver-major"]
+ - dependency-name: vitest
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@types/*"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "vite-plugin-*"
+ update-types: ["version-update:semver-major"]
+ # 0.8 dropped stateExtensions and updateSchema from the package root and
+ # exposes no subpath to reach them, which GrpcEditor needs. Unpin once
+ # that editor moves to the jsonSchema helper.
+ - dependency-name: codemirror-json-schema
+ groups:
+ npm-production:
+ dependency-type: production
+ update-types:
+ - minor
+ - patch
+ npm-development:
+ dependency-type: development
+ update-types:
+ - minor
+ - patch
+
+ - package-ecosystem: cargo
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ ignore:
+ # Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
+ # does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
+ - dependency-name: oxc_resolver
+ groups:
+ cargo:
+ patterns:
+ - "*"
+ update-types:
+ - minor
+ - patch
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..49d7b7e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,22 @@
+## Summary
+
+
+
+## Submission
+
+
+
+- [ ] This PR is a bug fix.
+- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
+- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
+- [ ] I tested this change locally.
+- [ ] I added or updated tests, or tests are not reasonable for this change.
+- [ ] I added screenshots or recordings, or this change does not affect the UI.
+
+Explicit permission feedback item (required if not a bug fix):
+
+
+
+## Related
+
+
diff --git a/.github/scripts/check-contribution-policy.js b/.github/scripts/check-contribution-policy.js
new file mode 100644
index 0000000..2c7dc57
--- /dev/null
+++ b/.github/scripts/check-contribution-policy.js
@@ -0,0 +1,881 @@
+const fs = require("node:fs");
+
+const COMMENT_MARKER = "";
+
+const MAINTAINER_LOGINS = new Set(["gschier"]);
+const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
+const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
+const REVIEWER_LOGIN = "gschier";
+
+const LARGE_DIFF_CHANGED_FILES = 20;
+const LARGE_DIFF_CHANGED_LINES = 800;
+const SUMMARY_TITLE_MAX_LENGTH = 80;
+const MIN_AUTOMATIC_PR_NUMBER = 494;
+
+const LABELS = {
+ inScope: {
+ name: "contribution: in scope",
+ color: "0E8A16",
+ description: "Community PR appears to be in scope for maintainer review.",
+ },
+ outOfScope: {
+ name: "contribution: out of scope",
+ color: "B60205",
+ description: "Community PR does not match Yaak's contribution policy.",
+ },
+ explicitPermission: {
+ name: "contribution: explicit permission",
+ color: "5319E7",
+ description:
+ "Community PR links feedback where @gschier explicitly allowed the work.",
+ },
+ missingTemplate: {
+ name: "contribution: missing template",
+ color: "D93F0B",
+ description:
+ "Community PR is missing enough of the pull request template to review.",
+ },
+ policyUnmet: {
+ name: "contribution: policy unmet",
+ color: "B60205",
+ description:
+ "Community PR does not currently satisfy the contribution policy.",
+ },
+ needsScopeReview: {
+ name: "contribution: needs scope review",
+ color: "FBCA04",
+ description:
+ "Community PR may be broader than Yaak's bug-fix contribution policy.",
+ },
+};
+
+const MANAGED_LABEL_NAMES = [
+ ...new Set(Object.values(LABELS).map((label) => label.name)),
+];
+
+// Each checkbox lists its current label first, followed by legacy labels still
+// accepted from PRs opened against older versions of the template.
+const CHECKBOXES = {
+ bugFix: ["This PR is a bug fix."],
+ explicitPermission: [
+ "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
+ ],
+ readContributing: [
+ "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
+ ],
+ testedLocally: ["I tested this change locally."],
+ testsUpdated: [
+ "I added or updated tests, or tests are not reasonable for this change.",
+ "I added or updated tests when reasonable.",
+ ],
+ screenshotsAdded: [
+ "I added screenshots or recordings, or this change does not affect the UI.",
+ "I added screenshots or recordings for UI changes when reasonable.",
+ ],
+};
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function normalizeBody(body) {
+ return (body || "").replace(/\r\n/g, "\n");
+}
+
+function stripComments(value) {
+ return value.replace(//g, "").trim();
+}
+
+function getSection(body, heading) {
+ const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
+ const match = pattern.exec(body);
+
+ if (match == null) {
+ return null;
+ }
+
+ const rest = body.slice(match.index + match[0].length);
+ const nextHeadingIndex = rest.search(/^##\s+/m);
+ return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
+}
+
+function hasMeaningfulText(value) {
+ return stripComments(value || "").length > 0;
+}
+
+function normalizeCheckboxLabel(label) {
+ return label
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/`/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function checkboxState(body, labels) {
+ const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
+
+ for (const line of body.split("\n")) {
+ const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
+
+ if (match == null) {
+ continue;
+ }
+
+ if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
+ return match[1].toLowerCase() === "x";
+ }
+ }
+
+ return null;
+}
+
+function findFeedbackUrl(body) {
+ return (
+ body.match(
+ /https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
+ )?.[0] ?? null
+ );
+}
+
+function getLabelNames(pr) {
+ return new Set((pr.labels || []).map((label) => label.name));
+}
+
+function analyzePullRequest(pr) {
+ const body = normalizeBody(pr.body);
+ const labelNames = getLabelNames(pr);
+ const states = Object.fromEntries(
+ Object.entries(CHECKBOXES).map(([key, label]) => [
+ key,
+ checkboxState(body, label),
+ ]),
+ );
+ const sectionCount = ["Summary", "Submission", "Related"].filter(
+ (heading) => getSection(body, heading) != null,
+ ).length;
+ const checkboxCount = Object.values(states).filter(
+ (state) => state != null,
+ ).length;
+ const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
+ const blockers = [];
+ const totalChangedLines =
+ Number(pr.additions || 0) + Number(pr.deletions || 0);
+ const changedFiles = Number(pr.changed_files || 0);
+ const largeDiff =
+ changedFiles > LARGE_DIFF_CHANGED_FILES ||
+ totalChangedLines > LARGE_DIFF_CHANGED_LINES;
+
+ if (labelNames.has(LABELS.outOfScope.name)) {
+ return {
+ blockers: [
+ {
+ label: LABELS.outOfScope.name,
+ message: "Marked out of scope by maintainer label.",
+ },
+ ],
+ changedFiles,
+ desiredLabels: [LABELS.outOfScope.name],
+ largeDiff,
+ status: "out_of_scope",
+ templateUsed,
+ totalChangedLines,
+ };
+ }
+
+ if (labelNames.has(LABELS.inScope.name)) {
+ return {
+ blockers: [],
+ changedFiles,
+ desiredLabels: [LABELS.inScope.name],
+ largeDiff,
+ status: "in_scope",
+ templateUsed,
+ totalChangedLines,
+ };
+ }
+
+ if (labelNames.has(LABELS.explicitPermission.name)) {
+ return {
+ blockers: [],
+ changedFiles,
+ desiredLabels: [LABELS.explicitPermission.name],
+ largeDiff,
+ status: "in_scope",
+ templateUsed,
+ totalChangedLines,
+ };
+ }
+
+ if (!templateUsed) {
+ blockers.push({
+ label: LABELS.missingTemplate.name,
+ message:
+ "Update the PR description with the repository pull request template.",
+ });
+ } else {
+ const summary = getSection(body, "Summary");
+ const hasSummary = hasMeaningfulText(summary);
+ const feedbackUrl = findFeedbackUrl(body);
+ const bugFix = states.bugFix === true;
+ const explicitPermission = states.explicitPermission === true;
+
+ if (!hasSummary) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Add a short summary describing the bug fix or permitted change.",
+ });
+ }
+
+ if (bugFix && explicitPermission) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
+ });
+ } else if (!bugFix && !explicitPermission) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
+ });
+ } else if (explicitPermission && feedbackUrl == null) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Link the feedback item where @gschier explicitly gave you permission to work on this.",
+ });
+ }
+
+ if (states.readContributing !== true) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message: "Confirm that `CONTRIBUTING.md` was read and followed.",
+ });
+ }
+
+ if (states.testedLocally !== true) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message: "Confirm that the change was tested locally.",
+ });
+ }
+
+ if (states.testsUpdated !== true) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
+ });
+ }
+
+ if (states.screenshotsAdded !== true) {
+ blockers.push({
+ label: LABELS.policyUnmet.name,
+ message:
+ "Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
+ });
+ }
+ }
+
+ const desiredLabels = new Set();
+
+ if (blockers.length === 0) {
+ desiredLabels.add(
+ largeDiff
+ ? LABELS.needsScopeReview.name
+ : states.explicitPermission
+ ? LABELS.explicitPermission.name
+ : LABELS.inScope.name,
+ );
+ } else if (
+ blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
+ ) {
+ desiredLabels.add(LABELS.missingTemplate.name);
+ } else {
+ desiredLabels.add(LABELS.policyUnmet.name);
+ }
+
+ return {
+ blockers,
+ changedFiles,
+ desiredLabels: [...desiredLabels],
+ largeDiff,
+ status: blockers.length === 0 ? "in_scope" : "blocked",
+ templateUsed,
+ totalChangedLines,
+ };
+}
+
+function buildBlockingComment(analysis) {
+ const lines = [
+ COMMENT_MARKER,
+ "Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
+ "",
+ "This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
+ "",
+ ...analysis.blockers.map((blocker) => `- ${blocker.message}`),
+ ];
+
+ if (!analysis.templateUsed) {
+ lines.push(
+ "",
+ "You can copy this template into the PR description and keep any existing context that is still useful.",
+ "",
+ "",
+ "PR description template ",
+ "",
+ "```md",
+ getPullRequestTemplate(),
+ "```",
+ "",
+ " ",
+ );
+ }
+
+ if (analysis.largeDiff) {
+ lines.push(
+ "",
+ `This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
+ );
+ }
+
+ return lines.join("\n");
+}
+
+function getPullRequestTemplate() {
+ return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
+}
+
+function buildInScopeComment() {
+ return [
+ COMMENT_MARKER,
+ "Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
+ "",
+ "This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
+ ].join("\n");
+}
+
+function buildOutOfScopeComment() {
+ return [
+ COMMENT_MARKER,
+ "Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
+ "",
+ "Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
+ "",
+ "If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
+ ].join("\n");
+}
+
+function buildPolicyComment(analysis) {
+ if (analysis.status === "out_of_scope") {
+ return buildOutOfScopeComment();
+ }
+
+ if (analysis.blockers.length > 0) {
+ return buildBlockingComment(analysis);
+ }
+
+ return buildInScopeComment();
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function truncateTitle(title) {
+ if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
+ return title;
+ }
+
+ return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
+}
+
+function escapeTableText(value) {
+ return escapeHtml(value).replace(/\n/g, " ");
+}
+
+function summarizeResult({ pr, analysis, skipped, skipReason }) {
+ const comment =
+ analysis == null
+ ? "None"
+ : buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
+ const summary = {
+ blocked: analysis?.blockers.length > 0,
+ comment,
+ details: "None",
+ labels:
+ analysis?.desiredLabels.length > 0
+ ? analysis.desiredLabels.join(", ")
+ : "None",
+ number: pr.number,
+ prLink: `#${pr.number} `,
+ status: "In scope",
+ title: escapeHtml(truncateTitle(pr.title)),
+ };
+
+ if (skipped) {
+ return {
+ ...summary,
+ blocked: false,
+ comment: "None",
+ details: escapeHtml(skipReason),
+ labels: "None",
+ status: "Skipped",
+ };
+ }
+
+ if (summary.blocked) {
+ return {
+ ...summary,
+ comment: escapeTableText(summary.comment),
+ details: escapeHtml(
+ analysis.blockers.map((blocker) => blocker.message).join("; "),
+ ),
+ labels: escapeHtml(summary.labels),
+ status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
+ };
+ }
+
+ return {
+ ...summary,
+ comment: escapeTableText(summary.comment),
+ labels: escapeHtml(summary.labels),
+ };
+}
+
+async function isOfficialMaintainer({ github, owner, repo, pr }) {
+ if (MAINTAINER_LOGINS.has(pr.user.login)) {
+ return true;
+ }
+
+ if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
+ return true;
+ }
+
+ try {
+ const response = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username: pr.user.login,
+ });
+
+ return MAINTAINER_PERMISSIONS.has(response.data.permission);
+ } catch (error) {
+ if (error.status === 404) {
+ return false;
+ }
+
+ throw error;
+ }
+}
+
+async function ensureManagedLabels({ github, owner, repo }) {
+ for (const label of Object.values(LABELS)) {
+ try {
+ await github.rest.issues.getLabel({
+ owner,
+ repo,
+ name: label.name,
+ });
+ } catch (error) {
+ if (error.status !== 404) {
+ throw error;
+ }
+
+ await github.rest.issues.createLabel({
+ owner,
+ repo,
+ name: label.name,
+ color: label.color,
+ description: label.description,
+ });
+ }
+ }
+}
+
+async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
+ const desired = new Set(desiredLabels);
+
+ await ensureManagedLabels({ github, owner, repo });
+
+ for (const labelName of MANAGED_LABEL_NAMES) {
+ if (desired.has(labelName)) {
+ continue;
+ }
+
+ try {
+ await github.rest.issues.removeLabel({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ name: labelName,
+ });
+ } catch (error) {
+ if (error.status !== 404) {
+ throw error;
+ }
+ }
+ }
+
+ if (desired.size > 0) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ labels: [...desired],
+ });
+ }
+}
+
+async function findPolicyComment({ github, owner, repo, issueNumber }) {
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+
+ return comments.find(
+ (comment) =>
+ comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
+ );
+}
+
+async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
+ const existingComment = await findPolicyComment({
+ github,
+ owner,
+ repo,
+ issueNumber,
+ });
+
+ if (existingComment == null) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ body,
+ });
+ return;
+ }
+
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existingComment.id,
+ body,
+ });
+}
+
+async function deletePolicyComment({ github, owner, repo, issueNumber }) {
+ const existingComment = await findPolicyComment({
+ github,
+ owner,
+ repo,
+ issueNumber,
+ });
+
+ if (existingComment == null) {
+ return;
+ }
+
+ await github.rest.issues.deleteComment({
+ owner,
+ repo,
+ comment_id: existingComment.id,
+ });
+}
+
+async function requestMaintainerReview({ github, owner, repo, pr }) {
+ if (pr.user.login === REVIEWER_LOGIN) {
+ return;
+ }
+
+ try {
+ await github.rest.pulls.requestReviewers({
+ owner,
+ repo,
+ pull_number: pr.number,
+ reviewers: [REVIEWER_LOGIN],
+ });
+ } catch (error) {
+ if (error.status === 422) {
+ return;
+ }
+
+ throw error;
+ }
+}
+
+async function checkPullRequest({
+ github,
+ core,
+ owner,
+ repo,
+ pullNumber,
+ dryRun,
+ minimumAutomaticPullNumber,
+}) {
+ const response = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: pullNumber,
+ });
+ const pr = response.data;
+ const issueNumber = pr.number;
+
+ if (pr.user.type === "Bot") {
+ core.notice(
+ `Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
+ );
+ return {
+ blocked: false,
+ number: pr.number,
+ summary: summarizeResult({
+ pr,
+ skipped: true,
+ skipReason: `bot @${pr.user.login}`,
+ }),
+ skipped: true,
+ };
+ }
+
+ if (
+ minimumAutomaticPullNumber != null &&
+ pr.number < minimumAutomaticPullNumber
+ ) {
+ core.notice(
+ `Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
+ );
+ return {
+ blocked: false,
+ number: pr.number,
+ summary: summarizeResult({
+ pr,
+ skipped: true,
+ skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
+ }),
+ skipped: true,
+ };
+ }
+
+ if (pr.draft) {
+ core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
+ return {
+ blocked: false,
+ number: pr.number,
+ summary: summarizeResult({
+ pr,
+ skipped: true,
+ skipReason: "draft",
+ }),
+ skipped: true,
+ };
+ }
+
+ if (await isOfficialMaintainer({ github, owner, repo, pr })) {
+ core.notice(
+ `Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
+ );
+ if (!dryRun) {
+ await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
+ await deletePolicyComment({ github, owner, repo, issueNumber });
+ }
+ return {
+ blocked: false,
+ number: pr.number,
+ summary: summarizeResult({
+ pr,
+ skipped: true,
+ skipReason: `maintainer @${pr.user.login}`,
+ }),
+ skipped: true,
+ };
+ }
+
+ const analysis = analyzePullRequest(pr);
+
+ if (dryRun) {
+ const summary = summarizeResult({ pr, analysis });
+ core.notice(
+ `[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
+ );
+ return {
+ blocked: analysis.blockers.length > 0,
+ number: pr.number,
+ summary,
+ skipped: false,
+ };
+ }
+
+ await syncLabels({
+ github,
+ owner,
+ repo,
+ issueNumber,
+ desiredLabels: analysis.desiredLabels,
+ });
+
+ if (analysis.blockers.length > 0) {
+ await upsertPolicyComment({
+ github,
+ owner,
+ repo,
+ issueNumber,
+ body: buildPolicyComment(analysis),
+ });
+ return {
+ blocked: true,
+ number: pr.number,
+ summary: summarizeResult({ pr, analysis }),
+ skipped: false,
+ };
+ }
+
+ await upsertPolicyComment({
+ github,
+ owner,
+ repo,
+ issueNumber,
+ body: buildPolicyComment(analysis),
+ });
+ await requestMaintainerReview({ github, owner, repo, pr });
+ core.notice(`Contribution policy check passed for PR #${pr.number}.`);
+ return {
+ blocked: false,
+ number: pr.number,
+ summary: summarizeResult({ pr, analysis }),
+ skipped: false,
+ };
+}
+
+async function listOpenPullRequests({ github, owner, repo }) {
+ return github.paginate(github.rest.pulls.list, {
+ owner,
+ repo,
+ state: "open",
+ per_page: 100,
+ });
+}
+
+function getManualPullRequestNumbers({ context, core }) {
+ const value = String(context.payload.inputs?.pr || "all").trim();
+
+ if (value.toLowerCase() === "all") {
+ return null;
+ }
+
+ const pullNumber = Number(value);
+
+ if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
+ core.setFailed('The "pr" input must be "all" or a positive PR number.');
+ return [];
+ }
+
+ return [pullNumber];
+}
+
+async function run({ github, context, core }) {
+ const { owner, repo } = context.repo;
+ const payloadPr = context.payload.pull_request;
+ const dryRunInput = context.payload.inputs?.dry_run;
+ const dryRun =
+ context.eventName === "workflow_dispatch" &&
+ dryRunInput !== false &&
+ dryRunInput !== "false";
+ const minimumAutomaticPullNumber =
+ payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
+ let pullNumbers;
+
+ if (payloadPr != null) {
+ pullNumbers = [payloadPr.number];
+ } else {
+ pullNumbers = getManualPullRequestNumbers({ context, core });
+ }
+
+ if (pullNumbers?.length === 0) {
+ return;
+ }
+
+ const pullRequests =
+ pullNumbers == null
+ ? await listOpenPullRequests({ github, owner, repo })
+ : pullNumbers.map((number) => ({ number }));
+ const results = [];
+
+ if (dryRun) {
+ core.notice(
+ `Running contribution policy in dry-run mode for ${
+ pullNumbers == null
+ ? "all open PRs"
+ : pullNumbers.map((number) => `#${number}`).join(", ")
+ }.`,
+ );
+ }
+
+ for (const pr of pullRequests) {
+ results.push(
+ await checkPullRequest({
+ github,
+ core,
+ owner,
+ repo,
+ pullNumber: pr.number,
+ dryRun,
+ minimumAutomaticPullNumber,
+ }),
+ );
+ }
+
+ await core.summary
+ .addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
+ .addTable([
+ [
+ { data: "PR", header: true },
+ { data: "Title", header: true },
+ { data: "Status", header: true },
+ { data: "Labels", header: true },
+ { data: "Details", header: true },
+ { data: "Comment", header: true },
+ ],
+ ...results.map((result) => [
+ result.summary.prLink,
+ result.summary.title,
+ result.summary.status,
+ result.summary.labels,
+ result.summary.details,
+ result.summary.comment,
+ ]),
+ ])
+ .write();
+
+ const blockedPullRequests = results.filter((result) => result.blocked);
+
+ if (blockedPullRequests.length > 0) {
+ if (dryRun) {
+ core.warning(
+ `Dry run found contribution policy failures for PR(s): ${blockedPullRequests
+ .map((result) => `#${result.number}`)
+ .join(", ")}`,
+ );
+ return;
+ }
+
+ core.setFailed(
+ `Contribution policy failed for PR(s): ${blockedPullRequests
+ .map((result) => `#${result.number}`)
+ .join(", ")}`,
+ );
+ }
+}
+
+module.exports = {
+ analyzePullRequest,
+ run,
+};
diff --git a/.github/scripts/preserve-release-artifacts.mjs b/.github/scripts/preserve-release-artifacts.mjs
new file mode 100644
index 0000000..e64cfe9
--- /dev/null
+++ b/.github/scripts/preserve-release-artifacts.mjs
@@ -0,0 +1,79 @@
+// Retain only distributable bundle files, never the target tree or signing material.
+import { createHash } from "node:crypto";
+import {
+ copyFileSync,
+ createReadStream,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ writeFileSync,
+} from "node:fs";
+import { join, relative } from "node:path";
+
+const env = process.env;
+const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
+const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
+const extensions =
+ /\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
+const files = [];
+
+function snapshot(directory) {
+ if (!existsSync(directory)) return;
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const source = join(directory, entry.name);
+ // Only descend through the bundler's immediate output folders. This excludes
+ // .app trees and the extracted CEF deb contents (which contain executables).
+ if (entry.isDirectory() && directory === bundle) {
+ snapshot(source);
+ } else if (
+ entry.isFile() &&
+ (extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
+ ) {
+ // Match tauri-action's default macOS updater asset naming exactly.
+ const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
+ const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
+ mkdirSync(destination, { recursive: true });
+ const retained = join(destination, assetName);
+ // The second snapshot must not overwrite the original user installer/signature.
+ if (!existsSync(retained)) copyFileSync(source, retained);
+ files.push({ source: relative(".", source), assetName });
+ }
+ }
+}
+
+snapshot(bundle);
+if (existsSync(destination)) {
+ const assets = readdirSync(destination)
+ .filter((name) => name !== "provenance.json")
+ .sort();
+ const checksums = [];
+ for (const assetName of assets) {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
+ checksums.push({ assetName, sha256: hash.digest("hex") });
+ }
+ writeFileSync(
+ join(destination, "provenance.json"),
+ JSON.stringify(
+ {
+ repository: env.GITHUB_REPOSITORY,
+ tag: env.GITHUB_REF_NAME,
+ sha: env.GITHUB_SHA,
+ runId: env.GITHUB_RUN_ID,
+ runAttempt: env.GITHUB_RUN_ATTEMPT,
+ workflowRef: env.GITHUB_WORKFLOW_REF,
+ workflowSha: env.GITHUB_WORKFLOW_SHA,
+ tauriAction: "tauri-apps/tauri-action@v0",
+ platform: env.RELEASE_PLATFORM,
+ runtime: env.RELEASE_RUNTIME,
+ arch: env.RELEASE_ARCH,
+ target: env.RELEASE_TARGET,
+ args: env.RELEASE_ARGS,
+ assets: checksums,
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+}
+console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dd4f908
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,37 @@
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+name: Lint and Test
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Lint/Test
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: voidzero-dev/setup-vp@v1
+ with:
+ node-version: "24"
+ cache: true
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: ci
+ cache-on-failure: true
+
+ - run: vp install
+ - run: npm run bootstrap
+ - run: npm run lint
+ - name: Run JS Tests
+ run: vp test
+ - name: Run Rust Tests
+ run: cargo test --all --features yaak-app-client/wry
+ - name: OpenAPI import round-trip
+ run: |
+ cargo build -p yaak-cli
+ node plugins/importer-openapi/tests/roundtrip.mjs
diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml
new file mode 100644
index 0000000..0567c9f
--- /dev/null
+++ b/.github/workflows/contribution-policy.yml
@@ -0,0 +1,48 @@
+name: Contribution Policy
+
+on:
+ workflow_dispatch:
+ inputs:
+ pr:
+ description: PR number or all
+ required: true
+ default: all
+ type: string
+ dry_run:
+ description: Dry run
+ required: true
+ default: true
+ type: boolean
+ pull_request_target:
+ types:
+ - opened
+ - edited
+ - reopened
+ - synchronize
+ - ready_for_review
+ - labeled
+ - unlabeled
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ check:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Check contribution policy
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout policy script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 1
+
+ - name: Check contribution policy
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { run } = require("./.github/scripts/check-contribution-policy.js");
+ await run({ github, context, core });
diff --git a/.github/workflows/flathub.yml b/.github/workflows/flathub.yml
new file mode 100644
index 0000000..ce015ba
--- /dev/null
+++ b/.github/workflows/flathub.yml
@@ -0,0 +1,56 @@
+name: Update Flathub
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag to publish to Flathub
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ update-flathub:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Update Flathub manifest
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout app repo
+ uses: actions/checkout@v4
+
+ - name: Checkout Flathub repo
+ uses: actions/checkout@v4
+ with:
+ repository: flathub/app.yaak.Yaak
+ token: ${{ secrets.FLATHUB_TOKEN }}
+ path: flathub-repo
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+
+ - name: Install source generators
+ run: |
+ pip install flatpak-node-generator tomlkit aiohttp
+ git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
+
+ - name: Run update-manifest.sh
+ run: bash flatpak/update-manifest.sh "${{ inputs.tag }}" flathub-repo
+
+ - name: Commit and push to Flathub
+ working-directory: flathub-repo
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git diff --cached --quiet && echo "No changes to commit" && exit 0
+ git commit -m "Update to ${{ inputs.tag }}"
+ git push
diff --git a/.github/workflows/release-api-npm.yml b/.github/workflows/release-api-npm.yml
new file mode 100644
index 0000000..cc0f5c3
--- /dev/null
+++ b/.github/workflows/release-api-npm.yml
@@ -0,0 +1,60 @@
+name: Release API to NPM
+
+on:
+ push:
+ tags: [yaak-api-*]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: API version to publish (for example 0.9.0 or v0.9.0)
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ publish-npm:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Publish @yaakapp/api
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ registry-url: https://registry.npmjs.org
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Set @yaakapp/api version
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME#yaak-api-}"
+ fi
+ VERSION="${VERSION#v}"
+ echo "Preparing @yaakapp/api version: $VERSION"
+ cd packages/plugin-runtime-types
+ npm version "$VERSION" --no-git-tag-version --allow-same-version
+
+ - name: Build @yaakapp/api
+ working-directory: packages/plugin-runtime-types
+ run: npm run build
+
+ - name: Publish @yaakapp/api
+ working-directory: packages/plugin-runtime-types
+ run: npm publish --provenance --access public
diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml
new file mode 100644
index 0000000..a9bb6c4
--- /dev/null
+++ b/.github/workflows/release-app.yml
@@ -0,0 +1,292 @@
+name: Release App Artifacts
+on:
+ push:
+ tags: [v*]
+
+jobs:
+ build-artifacts:
+ if: github.repository == 'mountain-loop/yaak'
+ permissions:
+ contents: write
+
+ name: Build
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: "macos-latest" # for Arm-based Macs (M1 and above).
+ args: '--target aarch64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "arm64"
+ os: "macos"
+ runtime: "wry"
+ targets: "aarch64-apple-darwin"
+ - platform: "macos-latest" # for Intel-based Macs.
+ args: '--target x86_64-apple-darwin --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "x64"
+ os: "macos"
+ runtime: "wry"
+ targets: "x86_64-apple-darwin"
+ - platform: "ubuntu-22.04"
+ args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "x64"
+ os: "ubuntu"
+ runtime: "wry"
+ targets: ""
+ - platform: "ubuntu-22.04-arm"
+ args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "arm64"
+ os: "ubuntu"
+ runtime: "wry"
+ targets: ""
+ - platform: "ubuntu-22.04"
+ args: >-
+ --bundles deb
+ --config ./tauri.release.conf.json
+ --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
+ yaak_arch: "x64"
+ os: "ubuntu"
+ runtime: "cef"
+ targets: ""
+ - platform: "ubuntu-22.04-arm"
+ args: >-
+ --bundles deb
+ --config ./tauri.release.conf.json
+ --config '{"productName":"yaak-cef","mainBinaryName":"yaak-cef","identifier":"app.yaak.desktop.cef","build":{"features":["license","cef"]},"bundle":{"createUpdaterArtifacts":false}}'
+ yaak_arch: "arm64"
+ os: "ubuntu"
+ runtime: "cef"
+ targets: ""
+ - platform: "windows-latest"
+ args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "x64"
+ os: "windows"
+ runtime: "wry"
+ targets: ""
+ # Windows ARM64
+ - platform: "windows-latest"
+ args: '--target aarch64-pc-windows-msvc --config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
+ yaak_arch: "arm64"
+ os: "windows"
+ runtime: "wry"
+ targets: "aarch64-pc-windows-msvc"
+ runs-on: ${{ matrix.platform }}
+ timeout-minutes: 360
+ steps:
+ - name: Checkout yaakapp/app
+ uses: actions/checkout@v4
+
+ - name: Setup Vite+
+ uses: voidzero-dev/setup-vp@v1
+ with:
+ node-version: "24"
+ cache: true
+
+ - name: install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.targets }}
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: ci
+ cache-on-failure: true
+
+ - name: Cache CEF (Linux only)
+ if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/tauri-cef
+ key: cef-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }}
+
+ - name: install dependencies (Linux only)
+ if: matrix.os == 'ubuntu'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
+ # crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
+ # uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
+ # 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
+ # through to host glibc headers ("bits/libc-header-start.h" not found).
+ # clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
+ # apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
+ # this compiler, so the shipped binary keeps 22.04's glibc floor.
+ wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
+ chmod +x /tmp/llvm.sh
+ sudo /tmp/llvm.sh 18
+ echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
+ echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
+
+ - name: Install Protoc for plugin-runtime
+ uses: arduino/setup-protoc@v3
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Install trusted-signing-cli (Windows only)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $dir = "$env:USERPROFILE\trusted-signing"
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ $url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
+ $exe = Join-Path $dir "trusted-signing-cli.exe"
+ Invoke-WebRequest -Uri $url -OutFile $exe
+ echo $dir >> $env:GITHUB_PATH
+ & $exe --version
+
+ - run: vp install
+ - run: npm run bootstrap
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+ - run: npm run lint
+ - name: Run JS Tests
+ run: vp test
+ - name: Run Rust Tests
+ run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
+
+ - name: Set version
+ run: npm run replace-version
+ env:
+ YAAK_VERSION: ${{ github.ref_name }}
+
+ - name: Sign vendored binaries (macOS only)
+ if: matrix.os == 'macos'
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ run: |
+ # Create keychain
+ KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
+ security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
+ security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
+
+ # Import certificate
+ echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
+ security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
+ security list-keychain -d user -s $KEYCHAIN_PATH
+
+ # Sign vendored binaries with hardened runtime and their specific entitlements
+ codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
+ codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
+
+ - name: Build and upload Tauri bundles
+ uses: tauri-apps/tauri-action@v0
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+
+ ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+
+ # Apple signing stuff
+ APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
+ APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
+
+ # Windows signing stuff
+ AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
+ AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
+ with:
+ # Also retries builds; upload retries alone cannot clean up starter assets.
+ retryAttempts: 3
+ tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
+ tagName: "v__VERSION__"
+ releaseName: "Release __VERSION__"
+ releaseBody: ""
+ releaseDraft: true
+ prerelease: true
+ projectPath: ./crates-tauri/yaak-app-client
+ args: "${{ matrix.args }}"
+
+ # Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
+ - name: Snapshot Tauri bundles
+ if: ${{ !cancelled() }}
+ run: node .github/scripts/preserve-release-artifacts.mjs
+ env:
+ RELEASE_TARGET: ${{ matrix.targets }}
+ RELEASE_PLATFORM: ${{ matrix.platform }}
+ RELEASE_RUNTIME: ${{ matrix.runtime }}
+ RELEASE_ARCH: ${{ matrix.yaak_arch }}
+ RELEASE_ARGS: ${{ matrix.args }}
+
+ - name: Build and upload CEF tarball from deb (Linux only)
+ if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ deb=$(find target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
+ version="${GITHUB_REF_NAME#v}"
+ extract_dir="target/release/bundle/deb/yaak-cef-linux-${{ matrix.yaak_arch }}"
+ tarball="target/release/bundle/deb/yaak-cef_${version}_linux_${{ matrix.yaak_arch }}.tar.gz"
+ rm -rf "$extract_dir"
+ mkdir -p "$extract_dir"
+ dpkg-deb -x "$deb" "$extract_dir"
+ tar -C "$extract_dir" -czf "$tarball" .
+ for attempt in 1 2 3 4; do
+ if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
+ break
+ fi
+ if [ "$attempt" -eq 4 ]; then exit 1; fi
+ sleep 10
+ done
+
+ # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
+ - name: Build and upload machine-wide installer (Windows only)
+ if: matrix.os == 'windows'
+ shell: pwsh
+ env:
+ YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+ run: |
+ Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
+ Push-Location crates-tauri/yaak-app-client
+ npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ Pop-Location
+ $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
+ $setupSig = "$($setup.FullName).sig"
+ $dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
+ $destSig = "$dest.sig"
+ Copy-Item $setup.FullName $dest
+ Copy-Item $setupSig $destSig
+ foreach ($asset in @($dest, $destSig)) {
+ for ($attempt = 1; $attempt -le 4; $attempt++) {
+ gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
+ if ($LASTEXITCODE -eq 0) { break }
+ if ($attempt -eq 4) { exit $LASTEXITCODE }
+ Start-Sleep -Seconds 10
+ }
+ }
+
+ - name: Snapshot additional release bundles
+ if: ${{ !cancelled() }}
+ run: node .github/scripts/preserve-release-artifacts.mjs
+ env:
+ RELEASE_TARGET: ${{ matrix.targets }}
+ RELEASE_PLATFORM: ${{ matrix.platform }}
+ RELEASE_RUNTIME: ${{ matrix.runtime }}
+ RELEASE_ARCH: ${{ matrix.yaak_arch }}
+ RELEASE_ARGS: ${{ matrix.args }}
+
+ - name: Retain release bundles for recovery
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/yaak-release-artifacts/
+ retention-days: 7
+ if-no-files-found: warn
diff --git a/.github/workflows/release-cli-npm.yml b/.github/workflows/release-cli-npm.yml
new file mode 100644
index 0000000..8c45739
--- /dev/null
+++ b/.github/workflows/release-cli-npm.yml
@@ -0,0 +1,219 @@
+name: Release CLI to NPM
+
+on:
+ push:
+ tags: [v*]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: CLI version to publish (for example 0.4.0 or v0.4.0)
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ prepare-vendored-assets:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Prepare vendored plugin assets
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build plugin assets
+ env:
+ SKIP_WASM_BUILD: "1"
+ run: |
+ npm run build
+ npm run vendor:vendor-plugins
+
+ - name: Upload vendored assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: vendored-assets
+ path: |
+ crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs
+ crates-tauri/yaak-app-client/vendored/plugins
+ if-no-files-found: error
+
+ build-binaries:
+ name: Build ${{ matrix.pkg }}
+ needs: prepare-vendored-assets
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - pkg: cli-darwin-arm64
+ runner: macos-latest
+ target: aarch64-apple-darwin
+ binary: yaak
+ - pkg: cli-darwin-x64
+ runner: macos-latest
+ target: x86_64-apple-darwin
+ binary: yaak
+ - pkg: cli-linux-arm64
+ runner: ubuntu-22.04-arm
+ target: aarch64-unknown-linux-gnu
+ binary: yaak
+ - pkg: cli-linux-x64
+ runner: ubuntu-22.04
+ target: x86_64-unknown-linux-gnu
+ binary: yaak
+ - pkg: cli-win32-arm64
+ runner: windows-latest
+ target: aarch64-pc-windows-msvc
+ binary: yaak.exe
+ - pkg: cli-win32-x64
+ runner: windows-latest
+ target: x86_64-pc-windows-msvc
+ binary: yaak.exe
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Restore Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: release-cli-npm
+ cache-on-failure: true
+
+ - name: Install Linux build dependencies
+ if: startsWith(matrix.runner, 'ubuntu')
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y pkg-config libdbus-1-dev
+
+ - name: Download vendored assets
+ uses: actions/download-artifact@v4
+ with:
+ name: vendored-assets
+ path: crates-tauri/yaak-app-client/vendored
+
+ - name: Set CLI build version
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME}"
+ fi
+ VERSION="${VERSION#v}"
+ echo "Building yaak version: $VERSION"
+ echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
+
+ - name: Build yaak
+ run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
+
+ - name: Stage binary artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p "npm/dist/${{ matrix.pkg }}"
+ cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
+
+ - name: Upload binary artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.pkg }}
+ path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
+ if-no-files-found: error
+
+ publish-npm:
+ name: Publish @yaakapp/cli packages
+ needs: build-binaries
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ registry-url: https://registry.npmjs.org
+
+ - name: Download binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: cli-*
+ path: npm/dist
+ merge-multiple: false
+
+ - name: Prepare npm packages
+ shell: bash
+ env:
+ WORKFLOW_VERSION: ${{ inputs.version }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="$WORKFLOW_VERSION"
+ else
+ VERSION="${GITHUB_REF_NAME}"
+ fi
+ VERSION="${VERSION#v}"
+ if [[ "$VERSION" == *-* ]]; then
+ PRERELEASE="${VERSION#*-}"
+ NPM_TAG="${PRERELEASE%%.*}"
+ else
+ NPM_TAG="latest"
+ fi
+ echo "Preparing CLI npm packages for version: $VERSION"
+ echo "Publishing with npm dist-tag: $NPM_TAG"
+ echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
+ YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
+
+ - name: Publish @yaakapp/cli-darwin-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-darwin-arm64
+
+ - name: Publish @yaakapp/cli-darwin-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-darwin-x64
+
+ - name: Publish @yaakapp/cli-linux-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-linux-arm64
+
+ - name: Publish @yaakapp/cli-linux-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-linux-x64
+
+ - name: Publish @yaakapp/cli-win32-arm64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-win32-arm64
+
+ - name: Publish @yaakapp/cli-win32-x64
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli-win32-x64
+
+ - name: Publish @yaakapp/cli
+ run: npm publish --provenance --access public --tag "$NPM_TAG"
+ working-directory: npm/cli
diff --git a/.github/workflows/release-web-image.yml b/.github/workflows/release-web-image.yml
new file mode 100644
index 0000000..eee30e0
--- /dev/null
+++ b/.github/workflows/release-web-image.yml
@@ -0,0 +1,123 @@
+name: Release web image
+
+# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
+# One image per architecture on its own native runner (emulating a Rust release build is hours),
+# joined into one multi-arch tag at the end.
+
+on:
+ push:
+ tags: [v*]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
+ required: false
+ type: string
+
+permissions:
+ contents: read
+ packages: write
+
+env:
+ IMAGE: ghcr.io/mountain-loop/yaak-web
+
+jobs:
+ build:
+ if: github.repository == 'mountain-loop/yaak'
+ name: Build ${{ matrix.platform }}
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-22.04
+ arch: amd64
+ - platform: linux/arm64
+ runner: ubuntu-22.04-arm
+ arch: arm64
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.web
+ platforms: ${{ matrix.platform }}
+ outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
+
+ - name: Export digest
+ run: |
+ mkdir -p "${{ runner.temp }}/digests"
+ digest="${{ steps.build.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ uses: actions/upload-artifact@v4
+ with:
+ name: digest-${{ matrix.arch }}
+ path: ${{ runner.temp }}/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ publish:
+ name: Publish manifest
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Download digests
+ uses: actions/download-artifact@v4
+ with:
+ path: ${{ runner.temp }}/digests
+ pattern: digest-*
+ merge-multiple: true
+
+ - name: Set up Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # `latest` follows a release tag, and a manual run that names a version — the way to
+ # publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
+ - name: Tags
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE }}
+ flavor: latest=false
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
+ type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
+ type=ref,event=branch
+ type=sha,format=short
+
+ - name: Create and push the manifest
+ working-directory: ${{ runner.temp }}/digests
+ run: |
+ docker buildx imagetools create \
+ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.IMAGE }}@sha256:%s ' *)
+
+ - name: Inspect
+ run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml
new file mode 100644
index 0000000..a68a005
--- /dev/null
+++ b/.github/workflows/sponsors.yml
@@ -0,0 +1,45 @@
+name: Generate Sponsors README
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: 30 15 * * 0-6
+permissions:
+ contents: write
+jobs:
+ deploy:
+ if: github.repository == 'mountain-loop/yaak'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout 🛎️
+ uses: actions/checkout@v2
+
+ - name: Generate Sponsors
+ uses: JamesIves/github-sponsors-readme-action@v1
+ with:
+ token: ${{ secrets.SPONSORS_PAT }}
+ file: "README.md"
+ maximum: 1999
+ template: ' '
+ active-only: false
+ include-private: true
+ marker: "sponsors-base"
+
+ - name: Generate Sponsors
+ uses: JamesIves/github-sponsors-readme-action@v1
+ with:
+ token: ${{ secrets.SPONSORS_PAT }}
+ file: "README.md"
+ minimum: 2000
+ template: ' '
+ active-only: false
+ include-private: true
+ marker: "sponsors-premium"
+
+ # ⚠️ Note: You can use any deployment step here to automatically push the README
+ # changes back to your branch.
+ - name: Commit Changes
+ uses: JamesIves/github-pages-deploy-action@v4
+ with:
+ branch: main
+ force: false
+ folder: "."
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1beb88f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+!.vscode/launch.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.eslintcache
+out
+
+*.sqlite
+*.sqlite-*
+
+.cargo
+
+.tmp
+tmp
+.zed
+codebook.toml
+target
+
+# Per-worktree Tauri config (generated by post-checkout hook)
+crates-tauri/yaak-app-client/tauri.worktree.conf.json
+crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
+
+# Tauri auto-generated permission files
+**/permissions/autogenerated
+**/permissions/schemas
+
+# Flatpak build artifacts
+flatpak-repo/
+.flatpak-builder/
+flatpak/flatpak-builder-tools/
+flatpak/cargo-sources.json
+flatpak/node-sources.json
+
+# Local Codex desktop env state
+.codex/environments/environment.toml
+
+# Claude Code local settings
+.claude/settings.local.json
+.claude/worktrees/
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..d845d9d
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+24.14.0
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..dc68961
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies
+legacy-peer-deps=true
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
new file mode 100644
index 0000000..682f3b9
--- /dev/null
+++ b/.oxfmtrc.json
@@ -0,0 +1,8 @@
+{
+ "printWidth": 100,
+ "ignorePatterns": [
+ "**/bindings/**",
+ "crates/yaak-templates/pkg/**",
+ "apps/yaak-client/routeTree.gen.ts"
+ ]
+}
diff --git a/.vite-hooks/post-checkout b/.vite-hooks/post-checkout
new file mode 100755
index 0000000..8ca7227
--- /dev/null
+++ b/.vite-hooks/post-checkout
@@ -0,0 +1 @@
+node scripts/git-hooks/post-checkout.mjs "$@"
diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit
new file mode 100644
index 0000000..e0686b6
--- /dev/null
+++ b/.vite-hooks/pre-commit
@@ -0,0 +1,2 @@
+vp lint
+vp staged
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..b2b5eb7
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "rust-lang.rust-analyzer",
+ "bradlc.vscode-tailwindcss",
+ "VoidZero.vite-plus-extension-pack"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..daca8f7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Dev App",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "start"]
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Build App",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "start"]
+ },
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Bootstrap",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "bootstrap"]
+ }
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..b806535
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,8 @@
+{
+ "editor.defaultFormatter": "oxc.oxc-vscode",
+ "editor.formatOnSave": true,
+ "editor.formatOnSaveMode": "file",
+ "editor.codeActionsOnSave": {
+ "source.fixAll.oxc": "explicit"
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f0ed185
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,2 @@
+- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging.
+- Do not commit, push, or tag without explicit approval
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d8b23a8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,15 @@
+# Contributing to Yaak
+
+Yaak accepts community pull requests for:
+
+- Bug fixes
+
+Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
+
+## Approval for Non-Bugfix Changes
+
+If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
+
+## Development Setup
+
+For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..1e877ca
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,118 @@
+[workspace]
+resolver = "2"
+members = [
+ "crates/yaak",
+ "crates/yaak-commands",
+ # Common/foundation crates
+ "crates/common/yaak-database",
+ "crates/common/yaak-rpc",
+ "crates/common/yaak-rpc-schema",
+ # Shared crates (no Tauri dependency)
+ "crates/yaak-core",
+ "crates/yaak-common",
+ "crates/yaak-crypto",
+ "crates/yaak-git",
+ "crates/yaak-grpc",
+ "crates/yaak-http",
+ "crates/yaak-lifecycle",
+ "crates/yaak-models",
+ "crates/yaak-plugins",
+ "crates/yaak-sse",
+ "crates/yaak-sync",
+ "crates/yaak-templates",
+ "crates/yaak-tls",
+ "crates/yaak-ws",
+ "crates/yaak-wasm",
+ "crates/yaak-api",
+ "crates/yaak-proxy",
+ # Proxy-specific crates
+ "crates-proxy/yaak-proxy-lib",
+ # Server crates (the browser tier's hosted send executor)
+ "crates-server/yaak-web",
+ "crates-server/yaak-playground",
+ # CLI crates
+ "crates-cli/yaak-cli",
+ # Tauri-specific crates
+ "crates-tauri/yaak-app-client",
+ "crates-tauri/yaak-app-proxy",
+ "crates-tauri/yaak-fonts",
+ "crates-tauri/yaak-license",
+ "crates-tauri/yaak-mac-window",
+ "crates-tauri/yaak-system-appearance",
+ "crates-tauri/yaak-tauri-utils",
+ "crates-tauri/yaak-window",
+]
+
+[workspace.dependencies]
+chrono = "0.4.42"
+hex = "0.4.3"
+keyring = "3.6.3"
+log = "0.4.29"
+reqwest = { version = "0.13.5", default-features = false, features = [
+ # reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
+ # rest of the old default set and stay on native-tls.
+ "charset",
+ "http2",
+ "system-proxy",
+ "native-tls",
+] }
+rustls = { version = "0.23.34", default-features = false }
+rustls-platform-verifier = "0.6.2"
+schemars = { version = "0.8.22", features = ["chrono"] }
+serde = "1.0.228"
+serde_json = "1.0.145"
+sha2 = "0.10.9"
+tauri = { version = "2.11.1", default-features = false, features = [
+ "common-controls-v6",
+ "compression",
+ "dynamic-acl",
+] }
+tauri-plugin = "2.6.1"
+tauri-plugin-dialog = { version = "2.7.1", default-features = false }
+tauri-plugin-shell = "2.3.5"
+thiserror = "2.0.17"
+tokio = "1.48.0"
+ts-rs = "11.1.0"
+
+# Internal crates - common/foundation
+yaak-database = { path = "crates/common/yaak-database" }
+yaak-rpc = { path = "crates/common/yaak-rpc" }
+yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
+
+# Internal crates - shared
+yaak-core = { path = "crates/yaak-core" }
+yaak = { path = "crates/yaak" }
+yaak-commands = { path = "crates/yaak-commands" }
+yaak-common = { path = "crates/yaak-common" }
+yaak-crypto = { path = "crates/yaak-crypto" }
+yaak-git = { path = "crates/yaak-git" }
+yaak-grpc = { path = "crates/yaak-grpc" }
+yaak-http = { path = "crates/yaak-http" }
+yaak-lifecycle = { path = "crates/yaak-lifecycle" }
+yaak-models = { path = "crates/yaak-models" }
+yaak-plugins = { path = "crates/yaak-plugins" }
+yaak-sse = { path = "crates/yaak-sse" }
+yaak-sync = { path = "crates/yaak-sync" }
+yaak-templates = { path = "crates/yaak-templates" }
+yaak-tls = { path = "crates/yaak-tls" }
+yaak-ws = { path = "crates/yaak-ws" }
+yaak-api = { path = "crates/yaak-api" }
+yaak-proxy = { path = "crates/yaak-proxy" }
+
+# Internal crates - proxy
+yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
+
+# Internal crates - Tauri-specific
+yaak-fonts = { path = "crates-tauri/yaak-fonts" }
+yaak-license = { path = "crates-tauri/yaak-license" }
+yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
+yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
+yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
+yaak-window = { path = "crates-tauri/yaak-window" }
+
+[profile.release]
+strip = false
+
+[patch.crates-io]
+tauri = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
+tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "d9bc695c18d9a25baec21d8a5f36d72e3a14ee53" }
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..df43cae
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,105 @@
+# Developer Setup
+
+Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
+uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
+by a Node.js sidecar that communicates to the app over gRPC.
+
+Because of the moving parts, there are a few setup steps required before development can
+begin.
+
+## Prerequisites
+
+Make sure you have the following tools installed:
+
+- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)
+- [Rust](https://www.rust-lang.org/tools/install)
+- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)
+
+Check the installations with the following commands:
+
+```shell
+node -v
+npm -v
+vp --version
+rustc --version
+```
+
+Install the NPM dependencies:
+
+```shell
+npm install
+```
+
+Run the `bootstrap` command to do some initial setup:
+
+```shell
+npm run bootstrap
+```
+
+## Run the App
+
+After bootstrapping, start the app in development mode:
+
+```shell
+npm start
+```
+
+## Run the App in a Browser
+
+The client can also run as a plain web page, with no Tauri and no local process
+behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
+
+```shell
+YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
+```
+
+That flag picks the browser host in `packages/platform/src/web/`, which answers
+commands from an IndexedDB database the page owns instead of from the Rust
+engine. Data persists across reloads and is shared between tabs on the same
+origin. Sending HTTP is not available yet — the Send button reports that and
+everything else about the request is still saved. `packages/platform/src/web/README.md`
+lists which commands the browser host implements and which it declines.
+
+Desktop builds are unaffected: without the flag the platform package installs
+the Tauri host exactly as before.
+
+## SQLite Migrations
+
+New migrations can be created from the `src-tauri/` directory:
+
+```shell
+npm run migration
+```
+
+Rerun the app to apply the migrations.
+
+_Note: For safety, development builds use a separate database location from production builds._
+
+## Lezer Grammar Generation
+
+```sh
+# Example
+lezer-generator components/core/Editor//.grammar > components/core/Editor//.ts
+```
+
+## Linting and Formatting
+
+This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).
+
+- Lint the entire repo:
+
+```sh
+npm run lint
+```
+
+- Format code:
+
+```sh
+npm run format
+```
+
+Notes:
+
+- A pre-commit hook runs `vp lint` automatically on commit.
+- Some workspace packages also run `tsc --noEmit` for type-checking.
+- VS Code users should install the recommended extensions for format-on-save support.
diff --git a/Dockerfile.playground b/Dockerfile.playground
new file mode 100644
index 0000000..e631eb1
--- /dev/null
+++ b/Dockerfile.playground
@@ -0,0 +1,15 @@
+# syntax=docker/dockerfile:1
+# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
+
+FROM rust:1-bookworm AS build
+WORKDIR /app
+COPY . .
+RUN cargo build --release -p yaak-playground
+
+FROM debian:bookworm-slim
+COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
+ENV HOST=0.0.0.0
+ENV PORT=8080
+EXPOSE 8080
+USER nobody
+CMD ["yaak-playground"]
diff --git a/Dockerfile.web b/Dockerfile.web
new file mode 100644
index 0000000..460b5a1
--- /dev/null
+++ b/Dockerfile.web
@@ -0,0 +1,49 @@
+# syntax=docker/dockerfile:1
+# Yaak in a browser, whole: the web client and the server that executes its sends, in one
+# image serving both from one origin.
+#
+# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
+#
+# See crates-server/yaak-web/README.md for the knobs.
+
+FROM node:22-slim AS web
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY . .
+# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
+# the build context — it is ignored, and in a worktree it is a pointer file anyway.
+RUN git init -q && git add -A \
+ && git -c user.email=build@yaak.app -c user.name=build commit -qm build
+# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
+# only to build a bundle for a deployment whose server lives somewhere else.
+ARG VITE_YAAK_WEB_URL=""
+ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
+ENV YAAK_TARGET=web
+# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
+# backend, which this image has no reason to carry.
+ENV SKIP_WASM_BUILD=1
+RUN npm ci
+RUN node_modules/.bin/vp -C apps/yaak-client build
+
+FROM rust:1-bookworm AS server
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
+COPY . .
+RUN cargo build --release -p yaak-web
+
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
+COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
+COPY --from=web /app/dist/apps/yaak-client /srv
+# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
+# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
+ENV HOST=0.0.0.0
+ENV PORT=8080
+EXPOSE 8080
+USER nobody
+# Overriding the command (dropping --serve) leaves the stateless send executor:
+# docker run ghcr.io/mountain-loop/yaak-web yaak-web
+CMD ["yaak-web", "--serve", "/srv"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ed6c1c3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Yaak
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7778b21
--- /dev/null
+++ b/README.md
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+ 💫 Yaak ➟ Desktop API Client 💫
+
+
+
+ A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.
+
+
+ Development is funded by community-purchased licenses . You can also become a sponsor to have your logo appear below. 💖
+
+
+
+
+
+
+
+
+
+
+
+
+## Install
+
+Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
+
+## Features
+
+Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
+Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
+
+### 🌐 Work with any API
+
+- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
+- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
+- Filter and inspect responses with JSONPath or XPath.
+
+### 🔐 Stay secure
+
+- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
+- Secure sensitive values with encrypted secrets.
+- Store secrets in your OS keychain.
+
+### ☁️ Organize & collaborate
+
+- Group requests into workspaces and nested folders.
+- Use environment variables to switch between dev, staging, and prod.
+- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
+
+### 🧩 Extend & customize
+
+- Insert dynamic values like UUIDs or timestamps with template tags.
+- Pick from built-in themes or build your own.
+- Create plugins to extend authentication, template tags, or the UI.
+
+## Contribution Policy
+
+> [!IMPORTANT]
+> Community PRs are currently limited to bug fixes.
+> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
+> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
+
+## Useful Resources
+
+- [Feedback and Bug Reports](https://feedback.yaak.app)
+- [Documentation](https://yaak.app/docs)
+- [Yaak vs Postman](https://yaak.app/alternatives/postman)
+- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
+- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
+
+## License
+
+The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
+
+The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
diff --git a/apps/yaak-client/.gitignore b/apps/yaak-client/.gitignore
new file mode 100644
index 0000000..371c82b
--- /dev/null
+++ b/apps/yaak-client/.gitignore
@@ -0,0 +1,2 @@
+vite.config.d.ts
+vite.config.js
diff --git a/apps/yaak-client/commands/commands.tsx b/apps/yaak-client/commands/commands.tsx
new file mode 100644
index 0000000..40618a7
--- /dev/null
+++ b/apps/yaak-client/commands/commands.tsx
@@ -0,0 +1,170 @@
+import { createWorkspaceModel, type Folder, modelTypeLabel } from "@yaakapp-internal/models";
+import { applySync, calculateSync } from "@yaakapp-internal/sync";
+import { Button } from "../components/core/Button";
+import {
+ Banner,
+ InlineCode,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ TruncatedWideTableCell,
+} from "@yaakapp-internal/ui";
+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";
+
+export const createFolder = createFastMutation<
+ string | null,
+ void,
+ Partial>
+>({
+ mutationKey: ["create_folder"],
+ mutationFn: async (patch) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) {
+ throw new Error("Cannot create folder when there's no active workspace");
+ }
+
+ if (!patch.name) {
+ const name = await showPrompt({
+ id: "new-folder",
+ label: "Name",
+ defaultValue: "Folder",
+ title: "New Folder",
+ confirmText: "Create",
+ placeholder: "Name",
+ });
+ if (name == null) return null;
+
+ patch.name = name;
+ }
+
+ patch.sortPriority = patch.sortPriority || -Date.now();
+ const id = await createWorkspaceModel({ model: "folder", workspaceId, ...patch });
+ return id;
+ },
+});
+
+export const syncWorkspace = createFastMutation<
+ void,
+ void,
+ { workspaceId: string; syncDir: string; force?: boolean }
+>({
+ mutationKey: [],
+ mutationFn: async ({ workspaceId, syncDir, force }) => {
+ const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
+ if (ops.length === 0) {
+ console.log("Nothing to sync", workspaceId, syncDir);
+ return;
+ }
+ console.log("Syncing workspace", workspaceId, syncDir, ops);
+
+ const dbOps = ops.filter((o) => o.type.startsWith("db"));
+
+ if (dbOps.length === 0) {
+ await applySync(workspaceId, syncDir, ops);
+ return;
+ }
+
+ const isDeletingWorkspace = ops.some(
+ (o) => o.type === "dbDelete" && o.model.model === "workspace",
+ );
+
+ console.log("Directory changes detected", { dbOps, ops });
+
+ if (force) {
+ await applySync(workspaceId, syncDir, ops);
+ return;
+ }
+
+ showDialog({
+ id: "commit-sync",
+ title: "Changes Detected",
+ size: "md",
+ render: ({ hide }) => (
+
+ ),
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/createEnvironment.tsx b/apps/yaak-client/commands/createEnvironment.tsx
new file mode 100644
index 0000000..80f68e1
--- /dev/null
+++ b/apps/yaak-client/commands/createEnvironment.tsx
@@ -0,0 +1,51 @@
+import type { Environment } from "@yaakapp-internal/models";
+import { CreateEnvironmentDialog } from "../components/CreateEnvironmentDialog";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
+
+export const createSubEnvironmentAndActivate = createFastMutation<
+ string | null,
+ unknown,
+ Environment | null
+>({
+ mutationKey: ["create_environment"],
+ mutationFn: async (baseEnvironment) => {
+ if (baseEnvironment == null) {
+ throw new Error("No base environment passed");
+ }
+
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) {
+ throw new Error("Cannot create environment when no active workspace");
+ }
+
+ return new Promise((resolve) => {
+ showDialog({
+ id: "new-environment",
+ title: "New Environment",
+ description: "Create multiple environments with different sets of variables",
+ size: "sm",
+ onClose: () => resolve(null),
+ render: ({ hide }) => (
+ {
+ resolve(id);
+ }}
+ />
+ ),
+ });
+ });
+ },
+ onSuccess: async (environmentId) => {
+ if (environmentId == null) {
+ return; // Was not created
+ }
+
+ setWorkspaceSearchParams({ environment_id: environmentId });
+ },
+});
diff --git a/apps/yaak-client/commands/deleteWebsocketConnections.ts b/apps/yaak-client/commands/deleteWebsocketConnections.ts
new file mode 100644
index 0000000..e2ce134
--- /dev/null
+++ b/apps/yaak-client/commands/deleteWebsocketConnections.ts
@@ -0,0 +1,8 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from "@yaakapp-internal/ws";
+import { createFastMutation } from "../hooks/useFastMutation";
+
+export const deleteWebsocketConnections = createFastMutation({
+ mutationKey: ["delete_websocket_connections"],
+ mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
+});
diff --git a/apps/yaak-client/commands/importCurl.ts b/apps/yaak-client/commands/importCurl.ts
new file mode 100644
index 0000000..37973c3
--- /dev/null
+++ b/apps/yaak-client/commands/importCurl.ts
@@ -0,0 +1,52 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import { patchModelById } from "@yaakapp-internal/models";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
+import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
+import { jotaiStore } from "../lib/jotai";
+import { rpc } from "../lib/rpc";
+import { showToast } from "../lib/toast";
+
+export function looksLikeCurl(text: string) {
+ return text.trim().startsWith("curl ");
+}
+
+export const importCurl = createFastMutation<
+ void,
+ string,
+ { overwriteRequestId?: string; command: string }
+>({
+ mutationKey: ["import_curl"],
+ mutationFn: async ({ overwriteRequestId, command }) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
+ command,
+ workspaceId,
+ });
+
+ let verb: string;
+ if (overwriteRequestId == null) {
+ verb = "Created";
+ await createRequestAndNavigate(importedRequest);
+ } else {
+ verb = "Updated";
+ await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
+ ...importedRequest,
+ id: r.id,
+ createdAt: r.createdAt,
+ workspaceId: r.workspaceId,
+ folderId: r.folderId,
+ name: r.name,
+ sortPriority: r.sortPriority,
+ }));
+
+ setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
+ }
+
+ showToast({
+ color: "success",
+ message: `${verb} request from Curl`,
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/moveToWorkspace.tsx b/apps/yaak-client/commands/moveToWorkspace.tsx
new file mode 100644
index 0000000..3428a58
--- /dev/null
+++ b/apps/yaak-client/commands/moveToWorkspace.tsx
@@ -0,0 +1,33 @@
+import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
+
+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";
+
+export const moveToWorkspace = createFastMutation({
+ mutationKey: ["move_workspace"],
+ mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
+ const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (activeWorkspaceId == null) return;
+ if (requests.length === 0) return;
+
+ const title =
+ requests.length === 1 ? "Move Request" : `Move ${pluralizeCount("Request", requests.length)}`;
+
+ showDialog({
+ id: "change-workspace",
+ title,
+ size: "sm",
+ render: ({ hide }) => (
+
+ ),
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openFolderSettings.tsx b/apps/yaak-client/commands/openFolderSettings.tsx
new file mode 100644
index 0000000..e08ad57
--- /dev/null
+++ b/apps/yaak-client/commands/openFolderSettings.tsx
@@ -0,0 +1,17 @@
+import { getModel } from "@yaakapp-internal/models";
+import type { FolderSettingsTab } from "../components/FolderSettingsDialog";
+import { FolderSettingsDialog } from "../components/FolderSettingsDialog";
+import { showDialog } from "../lib/dialog";
+
+export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
+ const folder = getModel("folder", folderId);
+ if (folder == null) return;
+ showDialog({
+ id: "folder-settings",
+ title: null,
+ size: "lg",
+ className: "h-200",
+ noPadding: true,
+ render: () => ,
+ });
+}
diff --git a/apps/yaak-client/commands/openSettings.tsx b/apps/yaak-client/commands/openSettings.tsx
new file mode 100644
index 0000000..7d7e103
--- /dev/null
+++ b/apps/yaak-client/commands/openSettings.tsx
@@ -0,0 +1,48 @@
+import { platform } from "@yaakapp-internal/platform";
+import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { router } from "../lib/router";
+import { rpc } from "../lib/rpc";
+
+export const openSettings = createFastMutation({
+ mutationKey: ["open_settings"],
+ mutationFn: async (tab) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) return;
+
+ // Settings is its own window where the host has windows to give. Where it
+ // doesn't — a browser tab — it's a dialog like any other, so opening it
+ // doesn't take you away from the request you were working on.
+ if (!platform.capabilities.multiWindow) {
+ // Imported here so Settings stays out of the startup bundle, the way the
+ // route that renders it on desktop already keeps it
+ const { default: Settings } = await import("../components/Settings/Settings");
+ showDialog({
+ id: "settings",
+ size: "md",
+ className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
+ noPadding: true,
+ noScroll: true,
+ // Keyed so opening a specific tab while the dialog is already up moves to it
+ render: ({ hide }) => ,
+ });
+ return;
+ }
+
+ const location = router.buildLocation({
+ to: "/workspaces/$workspaceId/settings",
+ params: { workspaceId },
+ search: { tab: (tab ?? undefined) as SettingsTab | undefined },
+ });
+
+ await rpc("cmd_new_child_window", {
+ url: location.href,
+ label: "settings",
+ title: "Yaak Settings",
+ innerSize: [750, 600],
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
new file mode 100644
index 0000000..9c5b6d2
--- /dev/null
+++ b/apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
@@ -0,0 +1,27 @@
+import { applySync, calculateSyncFsOnly } from "@yaakapp-internal/sync";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { showSimpleAlert } from "../lib/alert";
+import { router } from "../lib/router";
+
+export const openWorkspaceFromSyncDir = createFastMutation({
+ mutationKey: [],
+ mutationFn: async (dir) => {
+ const ops = await calculateSyncFsOnly(dir);
+
+ const workspace = ops
+ .map((o) => (o.type === "dbCreate" && o.fs.model.type === "workspace" ? o.fs.model : null))
+ .filter((m) => m)[0];
+
+ if (workspace == null) {
+ showSimpleAlert("Failed to Open", "No workspace found in directory");
+ return;
+ }
+
+ await applySync(workspace.id, dir, ops);
+
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId: workspace.id },
+ });
+ },
+});
diff --git a/apps/yaak-client/commands/openWorkspaceSettings.tsx b/apps/yaak-client/commands/openWorkspaceSettings.tsx
new file mode 100644
index 0000000..1af368f
--- /dev/null
+++ b/apps/yaak-client/commands/openWorkspaceSettings.tsx
@@ -0,0 +1,10 @@
+import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
+import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { jotaiStore } from "../lib/jotai";
+
+export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId == null) return;
+ WorkspaceSettingsDialog.show(workspaceId, tab);
+}
diff --git a/apps/yaak-client/commands/switchWorkspace.tsx b/apps/yaak-client/commands/switchWorkspace.tsx
new file mode 100644
index 0000000..9891d57
--- /dev/null
+++ b/apps/yaak-client/commands/switchWorkspace.tsx
@@ -0,0 +1,46 @@
+import { platform } from "@yaakapp-internal/platform";
+import { createFastMutation } from "../hooks/useFastMutation";
+import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
+import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { getRecentRequests } from "../hooks/useRecentRequests";
+import { router } from "../lib/router";
+import { rpc } from "../lib/rpc";
+
+export const switchWorkspace = createFastMutation<
+ void,
+ unknown,
+ {
+ workspaceId: string;
+ inNewWindow: boolean;
+ }
+>({
+ mutationKey: ["open_workspace"],
+ mutationFn: async ({ workspaceId, inNewWindow }) => {
+ const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
+ const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
+ const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
+ const search = {
+ environment_id: environmentId,
+ cookie_jar_id: cookieJarId,
+ request_id: requestId,
+ };
+
+ // A host without windows opens the workspace here instead. Refusing would
+ // strand the user on the workspace they were trying to leave.
+ if (inNewWindow && platform.capabilities.multiWindow) {
+ const location = router.buildLocation({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId },
+ search,
+ });
+ await rpc("cmd_new_main_window", { url: location.href });
+ return;
+ }
+
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId },
+ search,
+ });
+ },
+});
diff --git a/apps/yaak-client/components/BinaryFileEditor.tsx b/apps/yaak-client/components/BinaryFileEditor.tsx
new file mode 100644
index 0000000..ccab5fa
--- /dev/null
+++ b/apps/yaak-client/components/BinaryFileEditor.tsx
@@ -0,0 +1,63 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
+import mime from "mime";
+import { useKeyValue } from "../hooks/useKeyValue";
+import { Button } from "./core/Button";
+import { SelectFile } from "./SelectFile";
+
+type Props = {
+ requestId: string;
+ contentType: string | null;
+ body: HttpRequest["body"];
+ onChange: (body: HttpRequest["body"]) => void;
+ onChangeContentType: (contentType: string | null) => void;
+};
+
+export function BinaryFileEditor({
+ contentType,
+ body,
+ onChange,
+ onChangeContentType,
+ requestId,
+}: Props) {
+ const ignoreContentType = useKeyValue({
+ namespace: "global",
+ key: ["ignore_content_type", requestId],
+ fallback: false,
+ });
+
+ const handleChange = async ({ filePath }: { filePath: string | null }) => {
+ await ignoreContentType.set(false);
+ onChange({ filePath: filePath ?? undefined });
+ };
+
+ const filePath = typeof body.filePath === "string" ? body.filePath : null;
+ const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
+
+ return (
+
+
+ {filePath != null && mimeType !== contentType && !ignoreContentType.value && (
+
+
+
Set Content-Type header
+
{mimeType} for current request?
+
+
+ ignoreContentType.set(true)}>
+ Ignore
+
+ onChangeContentType(mimeType)}
+ >
+ Set Header
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/CargoFeature.tsx b/apps/yaak-client/components/CargoFeature.tsx
new file mode 100644
index 0000000..7a5d1fe
--- /dev/null
+++ b/apps/yaak-client/components/CargoFeature.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+import { appInfo } from "../lib/appInfo";
+
+interface Props {
+ children: ReactNode;
+ feature: "updater" | "license";
+}
+
+const featureMap: Record = {
+ updater: appInfo.featureUpdater,
+ license: appInfo.featureLicense,
+};
+
+export function CargoFeature({ children, feature }: Props) {
+ if (featureMap[feature]) {
+ return <>{children}>;
+ }
+ return null;
+}
diff --git a/apps/yaak-client/components/CloneGitRepositoryDialog.tsx b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx
new file mode 100644
index 0000000..1602110
--- /dev/null
+++ b/apps/yaak-client/components/CloneGitRepositoryDialog.tsx
@@ -0,0 +1,163 @@
+import { gitClone } from "@yaakapp-internal/git";
+import { Banner, VStack } from "@yaakapp-internal/ui";
+import { useState } from "react";
+import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
+import { appInfo } from "../lib/appInfo";
+import { CommercialUseBanner } from "./CommercialUseBanner";
+import { showErrorToast } from "../lib/toast";
+import { Button } from "./core/Button";
+import { Checkbox } from "./core/Checkbox";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import { promptCredentials } from "./git/credentials";
+import { platform } from "@yaakapp-internal/platform";
+
+interface Props {
+ hide: () => void;
+}
+
+// Detect path separator from an existing path (defaults to /)
+function getPathSeparator(path: string): string {
+ return path.includes("\\") ? "\\" : "/";
+}
+
+export function CloneGitRepositoryDialog({ hide }: Props) {
+ const [url, setUrl] = useState("");
+ const [baseDirectory, setBaseDirectory] = useState(appInfo.defaultProjectDir);
+ const [directoryOverride, setDirectoryOverride] = useState(null);
+ const [hasSubdirectory, setHasSubdirectory] = useState(false);
+ const [subdirectory, setSubdirectory] = useState("");
+ const [isCloning, setIsCloning] = useState(false);
+ const [error, setError] = useState(null);
+
+ const repoName = extractRepoName(url);
+ const sep = getPathSeparator(baseDirectory);
+ const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
+ const directory = directoryOverride ?? computedDirectory;
+ const workspaceDirectory =
+ hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
+
+ const handleSelectDirectory = async () => {
+ const dir = await platform.dialog.open({
+ title: "Select Directory",
+ directory: true,
+ multiple: false,
+ });
+ if (dir != null) {
+ setBaseDirectory(dir);
+ setDirectoryOverride(null);
+ }
+ };
+
+ const handleClone = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!url || !directory) return;
+
+ setIsCloning(true);
+ setError(null);
+
+ try {
+ const result = await gitClone(url, directory, promptCredentials);
+
+ if (result.type === "needs_credentials") {
+ setError(
+ result.error ?? "Authentication failed. Please check your credentials and try again.",
+ );
+ return;
+ }
+
+ // Open the workspace from the cloned directory (or subdirectory)
+ await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
+
+ hide();
+ } catch (err) {
+ setError(String(err));
+ showErrorToast({
+ id: "git-clone-error",
+ title: "Clone Failed",
+ message: String(err),
+ });
+ } finally {
+ setIsCloning(false);
+ }
+ };
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {hasSubdirectory && (
+
+ )}
+
+
+ {isCloning ? "Cloning..." : "Clone Repository"}
+
+
+ );
+}
+
+function extractRepoName(url: string): string {
+ // Handle various Git URL formats:
+ // https://github.com/user/repo.git
+ // git@github.com:user/repo.git
+ // https://github.com/user/repo
+ const match = url.match(/\/([^/]+?)(\.git)?$/);
+ if (match?.[1]) {
+ return match[1];
+ }
+ // Fallback for SSH-style URLs
+ const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
+ if (sshMatch?.[1]) {
+ return sshMatch[1];
+ }
+ return "";
+}
diff --git a/apps/yaak-client/components/ColorIndicator.tsx b/apps/yaak-client/components/ColorIndicator.tsx
new file mode 100644
index 0000000..c5ed3a1
--- /dev/null
+++ b/apps/yaak-client/components/ColorIndicator.tsx
@@ -0,0 +1,28 @@
+import classNames from "classnames";
+import type { CSSProperties } from "react";
+
+interface Props {
+ color: string | null;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function ColorIndicator({ color, onClick, className }: Props) {
+ const style: CSSProperties = { backgroundColor: color ?? undefined };
+ const finalClassName = classNames(
+ className,
+ "inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
+ );
+
+ if (onClick) {
+ return (
+
+ );
+ }
+ return ;
+}
diff --git a/apps/yaak-client/components/CommandPaletteDialog.tsx b/apps/yaak-client/components/CommandPaletteDialog.tsx
new file mode 100644
index 0000000..f6f3359
--- /dev/null
+++ b/apps/yaak-client/components/CommandPaletteDialog.tsx
@@ -0,0 +1,507 @@
+import { workspacesAtom } from "@yaakapp-internal/models";
+import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { fuzzyFilter } from "fuzzbunny";
+import { useAtomValue } from "jotai";
+import {
+ Fragment,
+ type KeyboardEvent,
+ type ReactNode,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { createFolder } from "../commands/commands";
+import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
+import { openSettings } from "../commands/openSettings";
+import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
+import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
+import { useActiveRequest } from "../hooks/useActiveRequest";
+import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
+import { useAllRequests } from "../hooks/useAllRequests";
+import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
+import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
+import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
+import type { HotkeyAction } from "../hooks/useHotKey";
+import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
+import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { useRecentRequests } from "../hooks/useRecentRequests";
+import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
+import { useScrollIntoView } from "../hooks/useScrollIntoView";
+import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { appInfo } from "../lib/appInfo";
+import { copyToClipboard } from "../lib/copy";
+import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+import { editEnvironment } from "../lib/editEnvironment";
+import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
+import {
+ resolvedModelNameWithFolders,
+ resolvedModelNameWithFoldersArray,
+} from "../lib/resolvedModelName";
+import { router } from "../lib/router";
+import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
+import { CookieDialog } from "./CookieDialog";
+import { Button } from "./core/Button";
+import { Hotkey } from "./core/Hotkey";
+import { HttpMethodTag } from "./core/HttpMethodTag";
+import { PlainInput } from "./core/PlainInput";
+
+interface CommandPaletteGroup {
+ key: string;
+ label: ReactNode;
+ items: CommandPaletteItem[];
+}
+
+type CommandPaletteItem = {
+ key: string;
+ onSelect: () => void;
+ action?: HotkeyAction;
+} & ({ searchText: string; label: ReactNode } | { label: string });
+
+const MAX_PER_GROUP = 8;
+
+export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
+ const [command, setCommand] = useDebouncedState("", 150);
+ const [selectedItemKey, setSelectedItemKey] = useState(null);
+ const activeEnvironment = useActiveEnvironment();
+ const httpRequestActions = useHttpRequestActions();
+ const grpcRequestActions = useGrpcRequestActions();
+ const workspaceId = useAtomValue(activeWorkspaceIdAtom);
+ const workspaces = useAtomValue(workspacesAtom);
+ const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
+ const createWorkspace = useCreateWorkspace();
+ const recentEnvironments = useRecentEnvironments();
+ const recentWorkspaces = useRecentWorkspaces();
+ const requests = useAllRequests();
+ const activeRequest = useActiveRequest();
+ const activeCookieJar = useActiveCookieJar();
+ const [recentRequests] = useRecentRequests();
+ const [, setSidebarHidden] = useSidebarHidden();
+ const { mutate: sendRequest } = useSendAnyHttpRequest();
+
+ const handleSetCommand = (command: string) => {
+ setCommand(command);
+ setSelectedItemKey(null);
+ };
+
+ const workspaceCommands = useMemo(() => {
+ if (workspaceId == null) return [];
+
+ const commands: CommandPaletteItem[] = [
+ {
+ key: "settings.open",
+ label: "Open Settings",
+ action: "settings.show",
+ onSelect: () => openSettings.mutate(null),
+ },
+ {
+ key: "workspace_settings.open",
+ label: "Open Workspace Settings",
+ action: "workspace_settings.show",
+ onSelect: () => openWorkspaceSettings(),
+ },
+ {
+ key: "app.create",
+ label: "Create Workspace",
+ onSelect: createWorkspace,
+ },
+ {
+ key: "model.create",
+ label: "Create HTTP Request",
+ onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
+ },
+ {
+ key: "grpc_request.create",
+ label: "Create GRPC Request",
+ onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
+ },
+ {
+ key: "websocket_request.create",
+ label: "Create Websocket Request",
+ onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
+ },
+ {
+ key: "folder.create",
+ label: "Create Folder",
+ onSelect: () => createFolder.mutate({}),
+ },
+ {
+ key: "cookies.show",
+ label: "Show Cookies",
+ action: "cookies_editor.show",
+ onSelect: async () => {
+ CookieDialog.show(activeCookieJar?.id ?? null);
+ },
+ },
+ {
+ key: "environment.edit",
+ label: "Edit Environment",
+ action: "environment_editor.toggle",
+ onSelect: () => editEnvironment(activeEnvironment),
+ },
+ {
+ key: "environment.create",
+ label: "Create Environment",
+ onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
+ },
+ {
+ key: "sidebar.toggle",
+ label: "Toggle Sidebar",
+ action: "sidebar.focus",
+ onSelect: () => setSidebarHidden((h) => !h),
+ },
+ ];
+
+ if (activeRequest?.model === "http_request") {
+ commands.push({
+ key: "request.send",
+ action: "request.send",
+ label: "Send Request",
+ onSelect: () => sendRequest(activeRequest.id),
+ });
+ if (appInfo.cliVersion != null) {
+ commands.push({
+ key: "request.copy_cli_send",
+ searchText: `copy cli send yaak request send ${activeRequest.id}`,
+ label: "Copy CLI Send Command",
+ onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
+ });
+ }
+ httpRequestActions.forEach((a, i) => {
+ commands.push({
+ key: `http_request_action.${i}`,
+ label: a.label,
+ onSelect: () => a.call(activeRequest),
+ });
+ });
+ }
+
+ if (activeRequest?.model === "grpc_request") {
+ grpcRequestActions.forEach((a, i) => {
+ commands.push({
+ key: `grpc_request_action.${i}`,
+ label: a.label,
+ onSelect: () => a.call(activeRequest),
+ });
+ });
+ }
+
+ if (activeRequest != null) {
+ commands.push({
+ key: "http_request.rename",
+ label: "Rename Request",
+ onSelect: () => renameModelWithPrompt(activeRequest),
+ });
+
+ commands.push({
+ key: "sidebar.selected.delete",
+ label: "Delete Request",
+ onSelect: () => deleteModelWithConfirm(activeRequest),
+ });
+ }
+
+ return commands.sort((a, b) =>
+ ("searchText" in a ? a.searchText : a.label).localeCompare(
+ "searchText" in b ? b.searchText : b.label,
+ ),
+ );
+ }, [
+ activeCookieJar?.id,
+ activeEnvironment,
+ activeRequest,
+ baseEnvironment,
+ createWorkspace,
+ grpcRequestActions,
+ httpRequestActions,
+ sendRequest,
+ setSidebarHidden,
+ workspaceId,
+ ]);
+
+ const sortedRequests = useMemo(() => {
+ return [...requests].sort((a, b) => {
+ const aRecentIndex = recentRequests.indexOf(a.id);
+ const bRecentIndex = recentRequests.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [recentRequests, requests]);
+
+ const sortedEnvironments = useMemo(() => {
+ return [...subEnvironments].sort((a, b) => {
+ const aRecentIndex = recentEnvironments.indexOf(a.id);
+ const bRecentIndex = recentEnvironments.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [subEnvironments, recentEnvironments]);
+
+ const sortedWorkspaces = useMemo(() => {
+ if (recentWorkspaces == null) {
+ // Should never happen
+ return workspaces;
+ }
+
+ return [...workspaces].sort((a, b) => {
+ const aRecentIndex = recentWorkspaces?.indexOf(a.id);
+ const bRecentIndex = recentWorkspaces?.indexOf(b.id);
+
+ if (aRecentIndex >= 0 && bRecentIndex >= 0) {
+ return aRecentIndex - bRecentIndex;
+ }
+ if (aRecentIndex >= 0 && bRecentIndex === -1) {
+ return -1;
+ }
+ if (aRecentIndex === -1 && bRecentIndex >= 0) {
+ return 1;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+ }, [recentWorkspaces, workspaces]);
+
+ const groups = useMemo(() => {
+ const actionsGroup: CommandPaletteGroup = {
+ key: "actions",
+ label: "Actions",
+ items: workspaceCommands,
+ };
+
+ const requestGroup: CommandPaletteGroup = {
+ key: "requests",
+ label: "Switch Request",
+ items: [],
+ };
+
+ for (const r of sortedRequests) {
+ requestGroup.items.push({
+ key: `switch-request-${r.id}`,
+ searchText: resolvedModelNameWithFolders(r),
+ label: (
+
+
+ {resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
+
+ {i !== 0 && }
+ {name}
+
+ ))}
+
+ ),
+ onSelect: async () => {
+ await router.navigate({
+ to: "/workspaces/$workspaceId",
+ params: { workspaceId: r.workspaceId },
+ search: (prev) => ({ ...prev, request_id: r.id }),
+ });
+ },
+ });
+ }
+
+ const environmentGroup: CommandPaletteGroup = {
+ key: "environments",
+ label: "Switch Environment",
+ items: [],
+ };
+
+ for (const e of sortedEnvironments) {
+ if (e.id === activeEnvironment?.id) {
+ continue;
+ }
+ environmentGroup.items.push({
+ key: `switch-environment-${e.id}`,
+ label: e.name,
+ onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),
+ });
+ }
+
+ const workspaceGroup: CommandPaletteGroup = {
+ key: "workspaces",
+ label: "Switch Workspace",
+ items: [],
+ };
+
+ for (const w of sortedWorkspaces) {
+ workspaceGroup.items.push({
+ key: `switch-workspace-${w.id}`,
+ label: w.name,
+ onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
+ });
+ }
+
+ return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
+ }, [
+ workspaceCommands,
+ sortedRequests,
+ sortedEnvironments,
+ activeEnvironment?.id,
+ sortedWorkspaces,
+ ]);
+
+ const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
+
+ const { filteredGroups, filteredAllItems } = useMemo(() => {
+ const result = command
+ ? fuzzyFilter(
+ allItems.map((i) => ({
+ ...i,
+ filterBy: "searchText" in i ? i.searchText : i.label,
+ })),
+ command,
+ { fields: ["filterBy"] },
+ )
+ .sort((a, b) => b.score - a.score)
+ .map((v) => v.item)
+ : allItems;
+
+ const filteredGroups = groups
+ .map((g) => {
+ const items = result
+ .filter((i) => g.items.find((i2) => i2.key === i.key))
+ .slice(0, MAX_PER_GROUP);
+ return { ...g, items };
+ })
+ .filter((g) => g.items.length > 0);
+
+ const filteredAllItems = filteredGroups.flatMap((g) => g.items);
+ return { filteredAllItems, filteredGroups };
+ }, [allItems, command, groups]);
+
+ const handleSelectAndClose = useCallback(
+ (cb: () => void) => {
+ onClose();
+ cb();
+ },
+ [onClose],
+ );
+
+ const selectedItem = useMemo(() => {
+ let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
+ if (selectedItem == null) {
+ selectedItem = filteredAllItems[0] ?? null;
+ }
+ return selectedItem;
+ }, [filteredAllItems, selectedItemKey]);
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
+ if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
+ const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
+ setSelectedItemKey(next?.key ?? null);
+ } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
+ const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
+ setSelectedItemKey(prev?.key ?? null);
+ } else if (e.key === "Enter") {
+ const selected = filteredAllItems[index];
+ setSelectedItemKey(selected?.key ?? null);
+ if (selected) {
+ handleSelectAndClose(selected.onSelect);
+ }
+ }
+ },
+ [filteredAllItems, handleSelectAndClose, selectedItem?.key],
+ );
+
+ return (
+
+
+ }
+ name="command"
+ label="Command"
+ placeholder="Search or type a command"
+ className="font-sans text-base!"
+ defaultValue={command}
+ onChange={handleSetCommand}
+ onKeyDownCapture={handleKeyDown}
+ />
+
+
+ {filteredGroups.map((g) => (
+
+
+ {g.label}
+
+ {g.items.map((v) => (
+ handleSelectAndClose(v.onSelect)}
+ rightSlot={v.action && }
+ >
+ {v.label}
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+function CommandPaletteItem({
+ children,
+ active,
+ onClick,
+ rightSlot,
+}: {
+ children: ReactNode;
+ active: boolean;
+ onClick: () => void;
+ rightSlot?: ReactNode;
+}) {
+ const ref = useRef(null);
+ useScrollIntoView(ref.current, active);
+
+ return (
+
+ {children}
+
+ );
+}
+
+function CommandPaletteAction({ action }: { action: HotkeyAction }) {
+ return ;
+}
diff --git a/apps/yaak-client/components/CommercialUseBanner.tsx b/apps/yaak-client/components/CommercialUseBanner.tsx
new file mode 100644
index 0000000..a69283d
--- /dev/null
+++ b/apps/yaak-client/components/CommercialUseBanner.tsx
@@ -0,0 +1,129 @@
+import { checkLicense } from "@yaakapp-internal/license";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useKeyValue } from "../hooks/useKeyValue";
+import { appInfo } from "../lib/appInfo";
+import { pricingUrl } from "../lib/pricingUrl";
+import { DismissibleBanner } from "./core/DismissibleBanner";
+import { platform } from "@yaakapp-internal/platform";
+
+const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
+const COMMERCIAL_USE_BANNER_MESSAGE =
+ "Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license.";
+
+export function CommercialUseBanner({
+ source,
+ title,
+}: {
+ source: string;
+ title: string;
+}) {
+ const [visible, setVisible] = useState(false);
+ const snoozeStartedRef = useRef(false);
+ const {
+ isLoading: isSnoozeLoading,
+ set: setSnoozedAt,
+ value: snoozedAt,
+ } = useKeyValue({
+ namespace: "global",
+ key: "commercial-use-banner-snoozed-at",
+ fallback: null,
+ });
+
+ useEffect(() => {
+ let canceled = false;
+
+ shouldShowCommercialUsePrompt()
+ .then((shouldShow) => {
+ if (!canceled) setVisible(shouldShow);
+ })
+ .catch(console.error);
+
+ return () => {
+ canceled = true;
+ };
+ }, [source]);
+
+ const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
+ const handleShow = useCallback(() => {
+ if (snoozeStartedRef.current || snoozed) {
+ return;
+ }
+
+ snoozeStartedRef.current = true;
+ setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
+ }, [setSnoozedAt, snoozed, source]);
+
+ if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
+ return null;
+ }
+
+ return (
+
+
+ setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
+ }
+ onShow={handleShow}
+ actions={[
+ {
+ label: "Purchase License",
+ color: "info",
+ variant: "solid",
+ onClick: () => {
+ openCommercialUsePricing(source).catch(console.error);
+ },
+ },
+ ]}
+ >
+
+
{title}
+
{COMMERCIAL_USE_BANNER_MESSAGE}
+
+
+
+ );
+}
+
+async function shouldShowCommercialUsePrompt(): Promise {
+ // Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
+ if (appInfo.featureLicense !== true) {
+ return false;
+ }
+
+ try {
+ const license = await checkLicense();
+ return license.status === "personal_use";
+ } catch (err) {
+ console.log("Failed to check license before commercial-use prompt", err);
+ return false;
+ }
+}
+
+async function openCommercialUsePricing(source: string): Promise {
+ await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
+}
+
+function isSnoozed(value: string | null, ms: number): boolean {
+ if (value == null) return false;
+
+ try {
+ const snooze = JSON.parse(value) as { at?: unknown };
+ const at = typeof snooze.at === "string" ? snooze.at : null;
+ return isWithinMs(at, ms);
+ } catch {
+ // Older builds stored only the timestamp, so keep respecting that as a global snooze.
+ return isWithinMs(value, ms);
+ }
+}
+
+function isWithinMs(date: string | null, ms: number): boolean {
+ if (date == null) return false;
+
+ const time = new Date(date).getTime();
+ if (Number.isNaN(time)) return false;
+
+ return Date.now() - time < ms;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx
new file mode 100644
index 0000000..f8acf53
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx
@@ -0,0 +1,74 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import { patchModel } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } 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;
+ request: HttpRequest;
+}
+
+const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeRequestBody({ children, request }: Props) {
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+
+ if (request.body?.text == null) {
+ return children;
+ }
+
+ const contentLength = request.body.text.length ?? 0;
+ const tooLargeBytes = LARGE_TEXT_BYTES;
+ const isLarge = contentLength > tooLargeBytes;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Rendering content over{" "}
+
+
+ {" "}
+ may impact performance.
+
+
+ See{" "}
+
+ Working With Large Values
+ {" "}
+ for tips.
+
+
+
+ Reveal Body
+
+ {
+ const confirm = await showConfirm({
+ id: `delete-body-${request.id}`,
+ confirmText: "Delete Body",
+ title: "Delete Body Text",
+ description: "Are you sure you want to delete the request body text?",
+ color: "danger",
+ });
+ if (confirm) {
+ await patchModel(request, { body: { ...request.body, text: "" } });
+ }
+ }}
+ >
+ Delete Body
+
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx
new file mode 100644
index 0000000..90bb2c8
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeResponse.tsx
@@ -0,0 +1,61 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { type ReactNode, useMemo } from "react";
+import { useSaveResponse } from "../hooks/useSaveResponse";
+import { useToggle } from "../hooks/useToggle";
+import { isProbablyTextContentType } from "../lib/contentType";
+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;
+ response: HttpResponse;
+}
+
+const LARGE_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeResponse({ children, response }: Props) {
+ const { mutate: saveResponse } = useSaveResponse(response);
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+ const isProbablyText = useMemo(() => {
+ const contentType = getContentTypeFromHeaders(response.headers);
+ return isProbablyTextContentType(contentType);
+ }, [response.headers]);
+
+ const contentLength = response.contentLength ?? 0;
+ const isLarge = contentLength > LARGE_BYTES;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Showing responses over{" "}
+
+
+ {" "}
+ may impact performance
+
+
+
+ Reveal Response
+
+ saveResponse()}>
+ Save to File
+
+ {isProbablyText && (
+ getResponseBodyText({ response, filter: null })}
+ />
+ )}
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
new file mode 100644
index 0000000..92525f7
--- /dev/null
+++ b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
@@ -0,0 +1,56 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { type ReactNode, useMemo } from "react";
+import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
+import { useToggle } from "../hooks/useToggle";
+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;
+ response: HttpResponse;
+}
+
+const LARGE_BYTES = 2 * 1000 * 1000;
+
+export function ConfirmLargeResponseRequest({ children, response }: Props) {
+ const [showLargeResponse, toggleShowLargeResponse] = useToggle();
+ const isProbablyText = useMemo(() => {
+ const contentType = getContentTypeFromHeaders(response.headers);
+ return isProbablyTextContentType(contentType);
+ }, [response.headers]);
+
+ const contentLength = response.requestContentLength ?? 0;
+ const isLarge = contentLength > LARGE_BYTES;
+ if (!showLargeResponse && isLarge) {
+ return (
+
+
+ Showing content over{" "}
+
+
+ {" "}
+ may impact performance
+
+
+
+ Reveal Request Body
+
+ {isProbablyText && (
+ getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
+ />
+ )}
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/yaak-client/components/ContextMenus.tsx b/apps/yaak-client/components/ContextMenus.tsx
new file mode 100644
index 0000000..dd8bad8
--- /dev/null
+++ b/apps/yaak-client/components/ContextMenus.tsx
@@ -0,0 +1,26 @@
+import { useAtomValue } from "jotai";
+import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
+import { ContextMenu } from "./core/Dropdown";
+import { ErrorBoundary } from "./ErrorBoundary";
+
+/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
+export function ContextMenus() {
+ const menus = useAtomValue(contextMenusAtom);
+ return (
+ <>
+ {menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
+
+ hideContextMenu(id)}
+ />
+
+ ))}
+ >
+ );
+}
diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx
new file mode 100644
index 0000000..31c9091
--- /dev/null
+++ b/apps/yaak-client/components/CookieDialog.tsx
@@ -0,0 +1,731 @@
+import type { Cookie } from "@yaakapp-internal/models";
+import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
+import { formatDate } from "date-fns/format";
+import { useAtomValue } from "jotai";
+import {
+ type ComponentProps,
+ type CSSProperties,
+ type FormEvent,
+ type ReactNode,
+ type RefObject,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { showDialog } from "../lib/dialog";
+import { jotaiStore } from "../lib/jotai";
+import { cookieDomain } from "../lib/model_util";
+import {
+ Icon,
+ SplitLayout,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ TruncatedWideTableCell,
+} 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 { EmptyStateText } from "./EmptyStateText";
+import { PlainInput } from "./core/PlainInput";
+import { Select } from "./core/Select";
+import { showAlert } from "../lib/alert";
+
+interface Props {
+ cookieJarId: string | null;
+}
+
+export const CookieDialog = ({ cookieJarId }: Props) => {
+ const cookieJars = useAtomValue(cookieJarsAtom);
+ const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
+ const [filter, setFilter] = useState("");
+ const [filterUpdateKey, setFilterUpdateKey] = useState(0);
+ const [selectedCookieKey, setSelectedCookieKey] = useState(null);
+ const [editingCookieKey, setEditingCookieKey] = useState(null);
+ const [draftCookie, setDraftCookie] = useState(null);
+ const [draftExpiresInput, setDraftExpiresInput] = useState("");
+ const editorFormRef = useRef(null);
+ const filteredCookies = useMemo(() => {
+ return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
+ }, [cookieJar?.cookies, filter]);
+ const selectedCookie = useMemo(
+ () =>
+ selectedCookieKey == null
+ ? null
+ : (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
+ [filteredCookies, selectedCookieKey],
+ );
+ const detailCookie = draftCookie ?? selectedCookie;
+ const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
+ const isEditingCookie = draftCookie != null;
+
+ const handleAddCookie = () => {
+ setSelectedCookieKey(null);
+ setEditingCookieKey(NEW_COOKIE_KEY);
+ setDraftCookie(newCookieDraft());
+ setDraftExpiresInput("");
+ };
+
+ const handleEditCookie = () => {
+ if (selectedCookie == null) {
+ return;
+ }
+
+ setEditingCookieKey(cookieKey(selectedCookie));
+ setDraftCookie(selectedCookie);
+ setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
+ };
+
+ const handleCancelEdit = () => {
+ if (isCreatingCookie) {
+ setSelectedCookieKey(null);
+ }
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ };
+
+ const handleCloseDetails = () => {
+ if (isEditingCookie) {
+ handleCancelEdit();
+ return;
+ }
+
+ setSelectedCookieKey(null);
+ };
+
+ const handleSaveCookie = (event: FormEvent) => {
+ event.preventDefault();
+
+ if (cookieJar == null || draftCookie == null) {
+ return;
+ }
+
+ let nextCookie = normalizeCookie(draftCookie);
+ if (nextCookie.expires !== "SessionEnd") {
+ const expires = cookieExpiresFromInput(draftExpiresInput);
+ if (expires == null) {
+ showAlert({
+ id: "invalid-cookie-expires",
+ title: "Invalid Cookie",
+ body: "Cookie expiration must be a valid date.",
+ });
+ return;
+ }
+
+ nextCookie = { ...nextCookie, expires };
+ }
+
+ const nextCookieKey = cookieKey(nextCookie);
+ const nextCookies = cookieJar.cookies.filter((cookie) => {
+ const key = cookieKey(cookie);
+ if (editingCookieKey != null && key === editingCookieKey) {
+ return false;
+ }
+ return key !== nextCookieKey;
+ });
+
+ void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
+ setSelectedCookieKey(nextCookieKey);
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ };
+
+ if (cookieJar == null) {
+ return No cookie jar selected
;
+ }
+
+ return (
+
+
+
0 && (
+ {
+ setFilter("");
+ setFilterUpdateKey((key) => key + 1);
+ }}
+ />
+ )
+ }
+ />
+
+
+ {cookieJar.cookies.length === 0 && detailCookie == null ? (
+
+ Cookies will appear when a response includes a Set-Cookie header.
+
+ ) : filteredCookies.length === 0 && detailCookie == null ? (
+
No cookies match the current filter.
+ ) : (
+
+ filteredCookies.length === 0 ? (
+
+ No cookies match the current filter.
+
+ ) : (
+
+
+
+ Name
+ Value
+ Domain
+ Path
+ Expires
+ Size
+ HTTP Only
+ Secure
+ Same Site
+
+ {
+ setSelectedCookieKey(null);
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ void patchModel(cookieJar, { cookies: [] });
+ }}
+ />
+
+
+
+
+ {filteredCookies.map((c: Cookie) => {
+ const key = cookieKey(c);
+ const isSelected = key === selectedCookieKey;
+
+ return (
+ {
+ setSelectedCookieKey(key);
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ }}
+ >
+
+ {c.name}
+
+
+ {c.value}
+
+ {cookieDomain(c)}
+ {c.path}
+ {cookieExpires(c)}
+ {cookieSize(c)}
+
+
+
+
+
+
+ {c.sameSite}
+
+ {
+ event.stopPropagation();
+ if (isSelected) {
+ setSelectedCookieKey(null);
+ }
+ if (editingCookieKey === key) {
+ setEditingCookieKey(null);
+ setDraftCookie(null);
+ setDraftExpiresInput("");
+ }
+ void patchModel(cookieJar, {
+ cookies: cookieJar.cookies.filter(
+ (c2: Cookie) => cookieKey(c2) !== key,
+ ),
+ });
+ }}
+ />
+
+
+ );
+ })}
+
+
+ )
+ }
+ secondSlot={
+ detailCookie == null
+ ? null
+ : ({ style }) => (
+
+ editorFormRef.current?.requestSubmit(),
+ },
+ {
+ key: "cancel",
+ label: "Cancel",
+ onClick: handleCancelEdit,
+ },
+ ]
+ : [
+ {
+ key: "edit",
+ label: "Edit",
+ onClick: handleEditCookie,
+ },
+ ]
+ }
+ onClose={handleCloseDetails}
+ />
+ {isEditingCookie ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ />
+ )}
+
+ );
+};
+
+function CookieDetailsPane({
+ children,
+ formRef,
+ isEditing,
+ onSubmit,
+ style,
+}: {
+ children: ReactNode;
+ formRef: RefObject;
+ isEditing: boolean;
+ onSubmit: (event: FormEvent) => void;
+ style: CSSProperties;
+}) {
+ const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+CookieDialog.show = (cookieJarId: string | null) => {
+ const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
+ if (cookieJar == null) {
+ showAlert({
+ id: "invalid-jar",
+ body: `Failed to find cookie jar for ID: ${cookieJarId}`,
+ title: "Invalid Cookie Jar",
+ });
+ return;
+ }
+
+ showDialog({
+ id: "cookies",
+ title: `${cookieJar.name} Cookies`,
+ size: "full",
+ render: () => ,
+ });
+};
+
+function CookieDetails({ cookie }: { cookie: Cookie }) {
+ return (
+
+
+ {cookie.name}
+
+ {cookie.value}
+
+ {cookieDomain(cookie)}
+ {cookie.path}
+ {cookieExpires(cookie)}
+ {cookieSize(cookie)}
+ {cookie.httpOnly ? "Yes" : "No"}
+ {cookie.secure ? "Yes" : "No"}
+ {cookie.sameSite && (
+ {cookie.sameSite}
+ )}
+
+
+ );
+}
+
+function CookieEditor({
+ cookie,
+ expiresInputValue,
+ onChange,
+ onExpiresInputChange,
+}: {
+ cookie: Cookie;
+ expiresInputValue: string;
+ onChange: (cookie: Cookie) => void;
+ onExpiresInputChange: (value: string) => void;
+}) {
+ const sessionCookie = cookie.expires === "SessionEnd";
+
+ return (
+
+
+
+ onChange({ ...cookie, name })}
+ />
+
+
+ onChange({ ...cookie, value })}
+ />
+
+
+ onChange(cookieWithDomain(cookie, domain))}
+ />
+
+
+ onChange({ ...cookie, path })}
+ />
+
+
+
+ {
+ if (checked) {
+ onChange({ ...cookie, expires: "SessionEnd" });
+ return;
+ }
+
+ const expiresInput =
+ cookieExpiresFromInput(expiresInputValue) == null
+ ? defaultCookieExpiresInputValue()
+ : expiresInputValue;
+
+ onExpiresInputChange(expiresInput);
+ onChange({
+ ...cookie,
+ expires: cookieExpiresFromInput(expiresInput)!,
+ });
+ }}
+ />
+ {
+ onExpiresInputChange(value);
+
+ const expires = cookieExpiresFromInput(value);
+ if (expires != null) {
+ onChange({ ...cookie, expires });
+ }
+ }}
+ />
+
+
+ {cookieSize(cookie)}
+
+ onChange({ ...cookie, httpOnly })}
+ />
+
+
+ onChange({ ...cookie, secure })}
+ />
+
+
+
+ onChange({
+ ...cookie,
+ sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
+ })
+ }
+ />
+
+
+
+ );
+}
+
+function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps) {
+ return ;
+}
+
+function CookieTextInput({
+ autoFocus,
+ disabled,
+ onChange,
+ pattern,
+ placeholder,
+ required,
+ value,
+}: {
+ autoFocus?: boolean;
+ disabled?: boolean;
+ onChange: (value: string) => void;
+ pattern?: string;
+ placeholder?: string;
+ required?: boolean;
+ value: string;
+}) {
+ return (
+ onChange(event.target.value)}
+ pattern={pattern}
+ placeholder={placeholder}
+ required={required}
+ type="text"
+ value={value}
+ />
+ );
+}
+
+function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
+ return (
+
+
+ );
+
+ case "personal_use":
+ return (
+
+
+ Your commercial-use trial has ended.
+
+
+ You may continue using Yaak for personal use only.
+ A license is required for commercial use.
+
+
+
+
+ Learn More
+
+
+
+
+ );
+
+ case "inactive":
+ return (
+
+ Your license is invalid. Please Sign In{" "}
+ for more details
+
+ );
+
+ case "expired":
+ return (
+
+ Your license expired{" "}
+ {formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")} . Please{" "}
+ Resubscribe to continue receiving
+ updates.
+ {check.data.data.changesUrl && (
+ <>
+
+ What's new in latest builds
+ >
+ )}
+
+ );
+
+ case "past_due":
+ return (
+
+ Your payment method needs attention.
+
+ To re-activate your license, please{" "}
+ update your billing info.
+
+ );
+
+ case "error":
+ return (
+
+ License check failed: {check.data.data.message} (Code: {check.data.data.code})
+
+ );
+ }
+ };
+
+ return (
+
+ {renderBanner()}
+
+ {check.error &&
{check.error} }
+ {activate.error &&
{activate.error} }
+
+ {check.data?.status === "active" ? (
+
+ deactivate.mutate()}>
+ Deactivate License
+
+ platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
+ rightSlot={ }
+ >
+ Direct Support
+
+
+ ) : (
+
+
+ Activate License
+
+ }
+ onClick={() =>
+ platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
+ }
+ >
+ Purchase License
+
+
+ )}
+
+ {activateFormVisible && (
+
{
+ e.preventDefault();
+ await activate.mutateAsync({ licenseKey: key });
+ toggleActivateFormVisible();
+ }}
+ >
+
+
+ Submit
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/Settings/SettingsPlugins.tsx b/apps/yaak-client/components/Settings/SettingsPlugins.tsx
new file mode 100644
index 0000000..e83cdc3
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsPlugins.tsx
@@ -0,0 +1,426 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+import type { Plugin } from "@yaakapp-internal/models";
+import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
+import type { PluginVersion } from "@yaakapp-internal/plugins";
+import {
+ checkPluginUpdates,
+ installPlugin,
+ searchPlugins,
+ uninstallPlugin,
+} from "@yaakapp-internal/plugins";
+import {
+ HStack,
+ Icon,
+ InlineCode,
+ LoadingIcon,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeaderCell,
+ TableRow,
+ useDebouncedValue,
+} from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { useInstallPlugin } from "../../hooks/useInstallPlugin";
+import { usePluginInfo } from "../../hooks/usePluginInfo";
+import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
+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";
+import { TabContent, Tabs } from "../core/Tabs/Tabs";
+import { EmptyStateText } from "../EmptyStateText";
+import { SelectFile } from "../SelectFile";
+import { platform } from "@yaakapp-internal/platform";
+
+interface SettingsPluginsProps {
+ defaultSubtab?: string;
+}
+
+export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
+ const [directory, setDirectory] = useState(null);
+ const plugins = useAtomValue(pluginsAtom);
+ const bundledPlugins = plugins.filter((p) => p.source === "bundled");
+ const installedPlugins = plugins.filter((p) => p.source !== "bundled");
+ const createPlugin = useInstallPlugin();
+ const refreshPlugins = useRefreshPlugins();
+ return (
+
+
,
+ },
+ {
+ label: "Bundled",
+ value: "bundled",
+ rightSlot:
,
+ },
+ ]}
+ >
+
+
+
+
+
+
+
+ setDirectory(filePath)}
+ filePath={directory}
+ />
+
+ {directory && (
+ {
+ if (directory == null) return;
+ createPlugin.mutate(directory);
+ setDirectory(null);
+ }}
+ >
+ Add Plugin
+
+ )}
+ refreshPlugins.mutate()}
+ />
+
+ platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
+ const info = usePluginInfo(plugin.id).data;
+ if (info == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
+ const info = usePluginInfo(plugin.id).data;
+ if (info == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
+ const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
+ const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
+
+ return (
+
+ );
+}
+
+function PluginTableRow({
+ plugin,
+ name,
+ version,
+ displayName,
+ url,
+ showCheckbox = true,
+ showUninstall = true,
+}: {
+ plugin: Plugin | null;
+ name: string;
+ version: string;
+ displayName: string;
+ url: string | null;
+ showCheckbox?: boolean;
+ showUninstall?: boolean;
+}) {
+ const updates = usePluginUpdates();
+ const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
+ const installPluginMutation = useMutation({
+ mutationKey: ["install_plugin", name],
+ mutationFn: (name: string) => installPlugin(name, null),
+ });
+ const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
+ const refreshPlugins = useRefreshPlugins();
+
+ return (
+
+ {showCheckbox && (
+
+ {
+ if (plugin) {
+ await patchModel(plugin, { enabled });
+ refreshPlugins.mutate();
+ }
+ }}
+ />
+
+ )}
+
+ {url ? (
+
+ {displayName}
+
+ ) : (
+ displayName
+ )}
+
+
+ {name}
+
+
+
+ {version}
+ {latestVersion != null && (
+
+
+ {latestVersion}
+
+ )}
+
+
+
+
+ {plugin != null && latestVersion != null ? (
+ installPluginMutation.mutate(name)}
+ >
+ Update
+
+ ) : plugin == null ? (
+ installPluginMutation.mutate(name)}
+ >
+ Install
+
+ ) : null}
+ {showUninstall && uninstall != null && (
+ uninstall.mutate()}
+ >
+ Uninstall
+
+ )}
+
+
+
+ );
+}
+
+function PluginSearch() {
+ const [query, setQuery] = useState("");
+ const debouncedQuery = useDebouncedValue(query);
+ const results = useQuery({
+ queryKey: ["plugins", debouncedQuery],
+ queryFn: () => searchPlugins(query),
+ });
+
+ return (
+
+
+
+
+
+ {results.data == null ? (
+
+
+
+ ) : (results.data.plugins ?? []).length === 0 ? (
+
No plugins found
+ ) : (
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {results.data.plugins.map((p) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
+ return plugins.length === 0 ? (
+
+
+ Plugins extend the functionality of Yaak.
+
+ Add your first plugin to get started.
+
+
+ ) : (
+
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {plugins.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
+ return plugins.length === 0 ? (
+
+ No bundled plugins found.
+
+ ) : (
+
+
+
+
+ Display Name
+ Name
+ Version
+
+
+
+
+ {plugins.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+function usePromptUninstall(pluginId: string | null, name: string) {
+ const mut = useMutation({
+ mutationKey: ["uninstall_plugin", pluginId],
+ mutationFn: async () => {
+ if (pluginId == null) return;
+
+ const confirmed = await showConfirmDelete({
+ id: `uninstall-plugin-${pluginId}`,
+ title: "Uninstall Plugin",
+ confirmText: "Uninstall",
+ description: (
+ <>
+ Permanently uninstall {name} ?
+ >
+ ),
+ });
+ if (confirmed) {
+ await minPromiseMillis(uninstallPlugin(pluginId), 700);
+ }
+ },
+ });
+
+ return pluginId == null ? null : mut;
+}
+
+function usePluginUpdates() {
+ return useQuery({
+ queryKey: ["plugin_updates", usePluginsKey()],
+ queryFn: () => checkPluginUpdates(),
+ });
+}
diff --git a/apps/yaak-client/components/Settings/SettingsProxy.tsx b/apps/yaak-client/components/Settings/SettingsProxy.tsx
new file mode 100644
index 0000000..98127b7
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsProxy.tsx
@@ -0,0 +1,180 @@
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import type { ProxySetting } from "@yaakapp-internal/models";
+import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { CommercialUseBanner } from "../CommercialUseBanner";
+import {
+ SettingRowBoolean,
+ SettingRowSelect,
+ SettingRowText,
+ SettingsList,
+ SettingsSection,
+} from "../core/SettingRow";
+
+export function SettingsProxy() {
+ const settings = useAtomValue(settingsAtom);
+ const proxy = enabledProxyOrDefault(settings.proxy);
+
+ const patchProxy = async (patch: Partial) => {
+ await patchModel(settings, {
+ proxy: {
+ ...proxy,
+ ...patch,
+ auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
+ },
+ });
+ };
+
+ return (
+
+
+
Proxy
+
+ Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
+ traffic, or routing through specific infrastructure.
+
+
+
+
+
+ {
+ if (v === "automatic") {
+ await patchModel(settings, { proxy: undefined });
+ } else if (v === "enabled") {
+ await patchModel(settings, { proxy });
+ } else {
+ await patchModel(settings, { proxy: { type: "disabled" } });
+ }
+ }}
+ options={[
+ { label: "Automatic proxy detection", value: "automatic" },
+ { label: "Custom proxy configuration", value: "enabled" },
+ { label: "No proxy", value: "disabled" },
+ ]}
+ selectClassName="w-64!"
+ />
+
+
+ {settings.proxy?.type === "enabled" && (
+ <>
+
+ patchProxy({ disabled: !enabled })}
+ />
+
+ Proxy for http:// traffic
+ >
+ }
+ description="Proxy host used for unencrypted HTTP traffic."
+ value={settings.proxy.http}
+ placeholder="localhost:9090"
+ onChange={(http) => patchProxy({ http })}
+ />
+
+ Proxy for https:// traffic
+ >
+ }
+ description="Proxy host used for HTTPS traffic."
+ value={settings.proxy.https}
+ placeholder="localhost:9090"
+ onChange={(https) => patchProxy({ https })}
+ />
+ patchProxy({ bypass })}
+ />
+
+
+
+
+ patchProxy({ auth: enabled ? { user: "", password: "" } : null })
+ }
+ />
+
+ {settings.proxy.auth != null && (
+ <>
+
+ patchProxy({
+ auth: {
+ user,
+ password:
+ settings.proxy?.type === "enabled"
+ ? (settings.proxy.auth?.password ?? "")
+ : "",
+ },
+ })
+ }
+ />
+
+ patchProxy({
+ auth: {
+ user:
+ settings.proxy?.type === "enabled"
+ ? (settings.proxy.auth?.user ?? "")
+ : "",
+ password,
+ },
+ })
+ }
+ />
+ >
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
+type EnabledProxySetting = Extract;
+
+function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
+ if (proxy?.type === "enabled") return proxy;
+
+ return {
+ disabled: false,
+ type: "enabled",
+ http: "",
+ https: "",
+ auth: { user: "", password: "" },
+ bypass: "",
+ };
+}
diff --git a/apps/yaak-client/components/Settings/SettingsTheme.tsx b/apps/yaak-client/components/Settings/SettingsTheme.tsx
new file mode 100644
index 0000000..2681090
--- /dev/null
+++ b/apps/yaak-client/components/Settings/SettingsTheme.tsx
@@ -0,0 +1,173 @@
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { lazy, Suspense } from "react";
+import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
+import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
+import { useResolvedTheme } from "../../hooks/useResolvedTheme";
+import type { ButtonProps } from "../core/Button";
+import { IconButton } from "../core/IconButton";
+import { Link } from "../core/Link";
+import type { SelectProps } from "../core/Select";
+import {
+ ModelSettingRowSelect,
+ SettingRowSelect,
+ SettingsList,
+ SettingsSection,
+} from "../core/SettingRow";
+
+const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
+
+const buttonColors: ButtonProps["color"][] = [
+ "primary",
+ "info",
+ "success",
+ "notice",
+ "warning",
+ "danger",
+ "secondary",
+ "default",
+];
+
+const icons: IconProps["icon"][] = [
+ "info",
+ "box",
+ "update",
+ "alert_triangle",
+ "arrow_big_right_dash",
+ "download",
+ "copy",
+ "magic_wand",
+ "settings",
+ "trash",
+ "sparkles",
+ "pencil",
+ "paste",
+ "search",
+ "send_horizontal",
+];
+
+export function SettingsTheme() {
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const settings = useAtomValue(settingsAtom);
+ const appearance = useResolvedAppearance();
+ const activeTheme = useResolvedTheme();
+
+ if (settings == null || workspace == null || activeTheme.data == null) {
+ return null;
+ }
+
+ const lightThemes: SelectProps["options"] = activeTheme.data.themes
+ .filter((theme) => !theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ const darkThemes: SelectProps["options"] = activeTheme.data.themes
+ .filter((theme) => theme.dark)
+ .map((theme) => ({
+ label: theme.label,
+ value: theme.id,
+ }));
+
+ return (
+
+
+
Theme
+
+ Make Yaak your own by selecting a theme, or{" "}
+
+ Create Your Own
+
+
+
+
+
+
+ {(settings.appearance === "system" || settings.appearance === "light") && (
+ patchModel(settings, { themeLight })}
+ />
+ )}
+ {(settings.appearance === "system" || settings.appearance === "dark") && (
+ patchModel(settings, { themeDark })}
+ />
+ )}
+
+
+
+
+
+
+ {activeTheme.data.active.label}
+ (preview)
+
+
+ {buttonColors.map((c, i) => (
+
+ ))}
+ {buttonColors.map((c, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/SettingsDropdown.tsx b/apps/yaak-client/components/SettingsDropdown.tsx
new file mode 100644
index 0000000..2c9336b
--- /dev/null
+++ b/apps/yaak-client/components/SettingsDropdown.tsx
@@ -0,0 +1,113 @@
+import { useLicense } from "@yaakapp-internal/license";
+import { useRef } from "react";
+import { openSettings } from "../commands/openSettings";
+import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
+import { useExportData } from "../hooks/useExportData";
+import { appInfo } from "../lib/appInfo";
+import { showDialog } from "../lib/dialog";
+import { importData } from "../lib/importData";
+import { pricingUrl } from "../lib/pricingUrl";
+import type { DropdownRef } from "./core/Dropdown";
+import { Dropdown } from "./core/Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+import { IconButton } from "./core/IconButton";
+import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
+import { platform } from "@yaakapp-internal/platform";
+
+export function SettingsDropdown() {
+ const exportData = useExportData();
+ const dropdownRef = useRef(null);
+ const checkForUpdates = useCheckForUpdates();
+ const { check } = useLicense();
+
+ return (
+ ,
+ onSelect: () => openSettings.mutate(null),
+ },
+ {
+ label: "Keyboard shortcuts",
+ hotKeyAction: "hotkeys.showHelp",
+ leftSlot: ,
+ onSelect: () => {
+ showDialog({
+ id: "hotkey",
+ title: "Keyboard Shortcuts",
+ size: "dynamic",
+ render: () => ,
+ });
+ },
+ },
+ {
+ label: "Plugins",
+ leftSlot: ,
+ onSelect: () => openSettings.mutate("plugins"),
+ },
+ { type: "separator", label: "Share Workspace(s)" },
+ {
+ label: "Import Data",
+ leftSlot: ,
+ onSelect: () => importData.mutate(),
+ },
+ {
+ label: "Export Data",
+ leftSlot: ,
+ onSelect: () => exportData.mutate(),
+ },
+ {
+ label: "Create Run Button",
+ leftSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/button/new"),
+ },
+ { type: "separator", label: `Yaak v${appInfo.version}` },
+ {
+ label: "Check for Updates",
+ leftSlot: ,
+ hidden: !appInfo.featureUpdater,
+ onSelect: () => checkForUpdates.mutate(),
+ },
+ {
+ label: "Purchase License",
+ color: "success",
+ hidden: check.data == null || check.data.status === "active",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () =>
+ platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
+ },
+ {
+ label: "Install CLI",
+ hidden: appInfo.cliVersion != null,
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
+ },
+ {
+ label: "Feedback",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl("https://yaak.app/feedback"),
+ },
+ {
+ label: "Changelog",
+ leftSlot: ,
+ rightSlot: ,
+ onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
+ },
+ ]}
+ >
+
+
+ );
+}
diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx
new file mode 100644
index 0000000..3e600a2
--- /dev/null
+++ b/apps/yaak-client/components/Sidebar.tsx
@@ -0,0 +1,1182 @@
+import type { Extension } from "@codemirror/state";
+import { Compartment } from "@codemirror/state";
+import { debounce } from "@yaakapp-internal/lib";
+import { gitMutations } from "@yaakapp-internal/git";
+import type { GitStatus } from "@yaakapp-internal/git";
+import type {
+ AnyModel,
+ Folder,
+ GrpcRequest,
+ HttpRequest,
+ ModelPayload,
+ WebsocketRequest,
+ Workspace,
+} from "@yaakapp-internal/models";
+import {
+ duplicateModel,
+ foldersAtom,
+ getAnyModel,
+ getModel,
+ grpcConnectionsAtom,
+ httpResponsesAtom,
+ patchModel,
+ websocketConnectionsAtom,
+ workspacesAtom,
+} from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { atom, useAtomValue } from "jotai";
+import { atomFamily } from "jotai-family";
+import { selectAtom } from "jotai/utils";
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { moveToWorkspace } from "../commands/moveToWorkspace";
+import { openFolderSettings } from "../commands/openFolderSettings";
+import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
+import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
+import {
+ activeWorkspaceAtom,
+ activeWorkspaceIdAtom,
+ activeWorkspaceMetaAtom,
+} from "../hooks/useActiveWorkspace";
+import { allRequestsAtom } from "../hooks/useAllRequests";
+import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
+import { getFolderActions } from "../hooks/useFolderActions";
+import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
+import { useHotKey } from "../hooks/useHotKey";
+import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
+import { platform } from "@yaakapp-internal/platform";
+import { usePlatformEvent } from "../hooks/usePlatformEvent";
+import { getModelAncestors } from "../hooks/useModelAncestors";
+import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
+import { deepEqualAtom } from "../lib/atoms";
+import { showConfirm } from "../lib/confirm";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+import { showDialog } from "../lib/dialog";
+import {
+ gitWorktreeStatusByModelIdAtom,
+ gitWorktreeStatusFamily,
+} from "../lib/gitWorktreeStatus";
+import { jotaiStore } from "../lib/jotai";
+import { resolvedModelName } from "../lib/resolvedModelName";
+import { isSidebarFocused } from "../lib/scopes";
+import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
+import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
+import { ContextMenu, Dropdown } from "./core/Dropdown";
+import type { FieldDef } from "./core/Editor/filter/extension";
+import { filter } from "./core/Editor/filter/extension";
+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,
+ Tree,
+ isSelectedFamily,
+ selectedIdsFamily,
+ InlineCode,
+} from "@yaakapp-internal/ui";
+import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
+import { IconButton } from "./core/IconButton";
+import type { InputHandle } from "./core/Input";
+import { Input } from "./core/Input";
+import { EmptyStateText } from "./EmptyStateText";
+import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
+import { GitDropdown } from "./git/GitDropdown";
+import { gitCallbacks } from "./git/callbacks";
+import { FileHistoryDialog } from "./git/FileHistoryDialog";
+import { sync } from "../init/sync";
+
+const collapsedFamily = atomFamily((treeId: string) => {
+ const key = ["sidebar_collapsed", treeId ?? "n/a"];
+ return atomWithKVStorage>(key, {});
+});
+
+type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
+function isSidebarLeafModel(m: AnyModel): boolean {
+ const modelMap: Record, null> = {
+ http_request: null,
+ grpc_request: null,
+ websocket_request: null,
+ folder: null,
+ };
+ return m.model in modelMap;
+}
+
+const OPACITY_SUBTLE = "opacity-80";
+
+function Sidebar({ className }: { className?: string }) {
+ const [hidden, setHidden] = useSidebarHidden();
+ const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
+ const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
+ const filterText = useAtomValue(sidebarFilterAtom);
+ const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
+
+ const wrapperRef = useRef(null);
+ const treeRef = useRef(null);
+ const filterRef = useRef(null);
+ const setFilterRef = useCallback((h: InputHandle | null) => {
+ filterRef.current = h;
+ }, []);
+ const allHidden = useMemo(() => {
+ if (tree?.children?.length === 0) return false;
+ if (filterText) return tree?.children?.every((c) => c.hidden);
+ return true;
+ }, [filterText, tree?.children]);
+
+ const focusActiveItem = useCallback(() => {
+ const didFocus = treeRef.current?.focus();
+ // If we weren't able to focus any items, focus the filter bar
+ if (!didFocus) filterRef.current?.focus();
+ }, []);
+
+ // Focus new sidebar models created by the user in this window. Writes from other
+ // sources (import, sync, CLI) can carry thousands of models and shouldn't move
+ // the selection.
+ usePlatformEvent("model_writes", (payloads) => {
+ for (const payload of payloads) {
+ if (payload.updateSource.type !== "window") continue;
+ if (payload.updateSource.label !== platform.window.label) continue;
+ if (!isSidebarLeafModel(payload.model)) continue;
+ if (!(payload.change.type === "upsert" && payload.change.created)) continue;
+ treeRef.current?.selectItem(payload.model.id, true);
+ }
+ });
+
+ useEffect(() => {
+ return jotaiStore.sub(activeIdAtom, () => {
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId) {
+ treeRef.current?.selectItem(activeId, true);
+ }
+ });
+ }, []);
+
+ useHotKey(
+ "sidebar.filter",
+ () => {
+ filterRef.current?.focus();
+ },
+ {
+ enable: isSidebarFocused,
+ },
+ );
+
+ useHotKey("sidebar.focus", async function focusHotkey() {
+ // Hide the sidebar if it's already focused
+ if (!hidden && isSidebarFocused()) {
+ await setHidden(true);
+ return;
+ }
+
+ // Show the sidebar if it's hidden
+ if (hidden) {
+ await setHidden(false);
+ }
+
+ // Select the 0th index on focus if none selected
+ setTimeout(focusActiveItem, 100);
+ });
+
+ const handleDragEnd = useCallback(async function handleDragEnd({
+ items,
+ parent,
+ children,
+ insertAt,
+ }: {
+ items: SidebarModel[];
+ parent: SidebarModel;
+ children: SidebarModel[];
+ insertAt: number;
+ }) {
+ const prev = children[insertAt - 1] as Exclude;
+ const next = children[insertAt] as Exclude;
+ const folderId = parent.model === "folder" ? parent.id : null;
+
+ const beforePriority = prev?.sortPriority ?? 0;
+ const afterPriority = next?.sortPriority ?? 0;
+ const shouldUpdateAll = afterPriority - beforePriority < 1;
+
+ try {
+ if (shouldUpdateAll) {
+ // Add items to children at insertAt
+ children.splice(insertAt, 0, ...items);
+ await Promise.all(
+ children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
+ );
+ } else {
+ const range = afterPriority - beforePriority;
+ const increment = range / (items.length + 2);
+ await Promise.all(
+ items.map((m, i) =>
+ // Spread item sortPriority out over before/after range
+ patchModel(m, {
+ sortPriority: beforePriority + (i + 1) * increment,
+ folderId,
+ }),
+ ),
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }, []);
+
+ const handleTreeRefInit = useCallback(
+ (n: TreeHandle) => {
+ treeRef.current = n;
+ if (n == null) return;
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId == null) return;
+ const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
+ if (selectedIds.length > 0) return;
+ n.selectItem(activeId);
+ },
+ [treeId],
+ );
+
+ const clearFilterText = useCallback(() => {
+ setSidebarFilterText("");
+ requestAnimationFrame(() => {
+ filterRef.current?.focus();
+ });
+ }, []);
+
+ const handleFilterKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ e.stopPropagation(); // Don't trigger tree navigation hotkeys
+ if (e.key === "Escape") {
+ e.preventDefault();
+ clearFilterText();
+ }
+ },
+ [clearFilterText],
+ );
+
+ const handleFilterChange = useMemo(
+ () =>
+ debounce((text: string) => {
+ jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
+ }, 0),
+ [],
+ );
+
+ const applyFilterExample = useCallback((text: string) => {
+ setSidebarFilterText(text);
+ requestAnimationFrame(() => {
+ filterRef.current?.focus();
+ });
+ }, []);
+
+ const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
+
+ const getSelectedTreeModels = useCallback(
+ () => treeRef.current?.getSelectedItems() as SidebarModel[] | undefined,
+ [],
+ );
+
+ const handleRenameSelected = useCallback((items: SidebarModel[]) => {
+ if (items.length === 1 && items[0] != null) {
+ treeRef.current?.renameItem(items[0].id);
+ }
+ }, []);
+
+ const handleDeleteSelected = useCallback(async (items: SidebarModel[]) => {
+ await deleteModelWithConfirm(items);
+ }, []);
+
+ const handleDuplicateSelected = useCallback(async (items: SidebarModel[]) => {
+ if (items.length === 1 && items[0]) {
+ const newId = await duplicateModel(items[0]);
+ navigateToRequestOrFolderOrWorkspace(newId, items[0].model);
+ } else {
+ await Promise.all(items.map(duplicateModel));
+ }
+ }, []);
+
+ const handleMoveSelected = useCallback((items: SidebarModel[]) => {
+ const requests = items.filter(
+ (i): i is HttpRequest | GrpcRequest | WebsocketRequest =>
+ i.model === "http_request" || i.model === "grpc_request" || i.model === "websocket_request",
+ );
+ if (requests.length > 0) {
+ moveToWorkspace.mutate(requests);
+ }
+ }, []);
+
+ const handleSendSelected = useCallback(async (items: SidebarModel[]) => {
+ await Promise.all(
+ items
+ .filter((i) => i.model === "http_request")
+ .map((i) => sendAnyHttpRequest.mutateAsync(i.id)),
+ );
+ }, []);
+
+ useHotKey(
+ "sidebar.context_menu",
+ useCallback(() => {
+ treeRef.current?.showContextMenu();
+ }, []),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.expand_all",
+ useCallback(() => {
+ jotaiStore.set(collapsedFamily(treeId), {});
+ }, [treeId]),
+ { enable: isSidebarFocused },
+ );
+
+ useHotKey(
+ "sidebar.collapse_all",
+ useCallback(() => {
+ if (tree == null) return;
+ const next = (node: TreeNode, collapsed: Record) => {
+ let newCollapsed = { ...collapsed };
+ for (const n of node.children ?? []) {
+ if (n.item.model !== "folder") continue;
+ newCollapsed[n.item.id] = true;
+ newCollapsed = next(n, newCollapsed);
+ }
+ return newCollapsed;
+ };
+ const collapsed = next(tree, {});
+ jotaiStore.set(collapsedFamily(treeId), collapsed);
+ }, [tree, treeId]),
+ { enable: isSidebarFocused },
+ );
+
+ useHotKey(
+ "sidebar.selected.delete",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) void handleDeleteSelected(items);
+ }, [getSelectedTreeModels, handleDeleteSelected]),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.selected.rename",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) handleRenameSelected(items);
+ }, [getSelectedTreeModels, handleRenameSelected]),
+ { enable: treeHasFocus, allowDefault: true },
+ );
+
+ useHotKey(
+ "sidebar.selected.duplicate",
+ useCallback(async () => {
+ const items = getSelectedTreeModels();
+ if (items) await handleDuplicateSelected(items);
+ }, [getSelectedTreeModels, handleDuplicateSelected]),
+ { priority: 10, enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "sidebar.selected.move",
+ useCallback(() => {
+ const items = getSelectedTreeModels();
+ if (items) handleMoveSelected(items);
+ }, [getSelectedTreeModels, handleMoveSelected]),
+ { enable: treeHasFocus },
+ );
+
+ useHotKey(
+ "request.send",
+ useCallback(async () => {
+ const items = getSelectedTreeModels();
+ if (items) await handleSendSelected(items);
+ }, [getSelectedTreeModels, handleSendSelected]),
+ { enable: treeHasFocus },
+ );
+
+ const getContextMenu = useCallback<(items: SidebarModel[]) => Promise>(
+ async (items) => {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const child = items[0];
+
+ // No children means we're in the root
+ if (child == null) {
+ return getCreateDropdownItems({
+ workspaceId,
+ activeRequest: null,
+ folderId: null,
+ });
+ }
+
+ const workspaces = jotaiStore.get(workspacesAtom);
+ const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
+ const gitItems = getGitContextMenuItems({ items, syncDir });
+ const onlyHttpRequests = items.every((i) => i.model === "http_request");
+ const requestItems = items.filter(
+ (i) =>
+ i.model === "http_request" ||
+ i.model === "grpc_request" ||
+ i.model === "websocket_request",
+ );
+
+ const initialItems: ContextMenuProps["items"] = [
+ {
+ label: "Folder Settings",
+ hidden: !(items.length === 1 && child.model === "folder"),
+ leftSlot: ,
+ onSelect: () => openFolderSettings(child.id),
+ },
+ {
+ label: "Send",
+ hotKeyAction: "request.send",
+ hotKeyLabelOnly: true,
+ hidden: !onlyHttpRequests,
+ leftSlot: ,
+ onSelect: () => handleSendSelected(items),
+ },
+ ...(items.length === 1 && child.model === "http_request"
+ ? await getHttpRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("http_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "grpc_request"
+ ? await getGrpcRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("grpc_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "websocket_request"
+ ? await getWebsocketRequestActions()
+ : []
+ ).map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const request = getModel("websocket_request", child.id);
+ if (request != null) await a.call(request);
+ },
+ })),
+ ...(items.length === 1 && child.model === "folder" ? await getFolderActions() : []).map(
+ (a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ const model = getModel("folder", child.id);
+ if (model != null) await a.call(model);
+ },
+ }),
+ ),
+ ];
+ const modelCreationItems: DropdownItem[] =
+ items.length === 1 && child.model === "folder"
+ ? [
+ { type: "separator" },
+ ...getCreateDropdownItems({
+ workspaceId,
+ activeRequest: null,
+ folderId: child.id,
+ }),
+ ]
+ : [];
+ const menuItems: ContextMenuProps["items"] = [
+ ...initialItems,
+ {
+ type: "separator",
+ hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
+ },
+ ...gitItems,
+ { type: "separator", hidden: gitItems.length === 0 },
+ {
+ label: "Rename",
+ leftSlot: ,
+ hidden: items.length > 1,
+ hotKeyAction: "sidebar.selected.rename",
+ hotKeyLabelOnly: true,
+ onSelect: () => handleRenameSelected(items),
+ },
+ {
+ label: "Duplicate",
+ hotKeyAction: "model.duplicate",
+ hotKeyLabelOnly: true, // Would trigger for every request (bad)
+ leftSlot: ,
+ onSelect: () => handleDuplicateSelected(items),
+ },
+ {
+ label: items.length <= 1 ? "Move" : `Move ${requestItems.length} Requests`,
+ hotKeyAction: "sidebar.selected.move",
+ hotKeyLabelOnly: true,
+ leftSlot: ,
+ hidden:
+ workspaces.length <= 1 ||
+ requestItems.length === 0 ||
+ requestItems.length !== items.length,
+ onSelect: () => handleMoveSelected(items),
+ },
+ {
+ color: "danger",
+ label: "Delete",
+ hotKeyAction: "sidebar.selected.delete",
+ hotKeyLabelOnly: true,
+ leftSlot: ,
+ onSelect: () => handleDeleteSelected(items),
+ },
+ ...modelCreationItems,
+ ];
+ return menuItems;
+ },
+ [],
+ );
+
+ const renderContextMenuFn = useCallback<
+ NonNullable["renderContextMenu"]>
+ >(
+ ({ items, position, onClose }) => (
+
+ ),
+ [],
+ );
+
+ // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
+ const filterLanguageCompartmentRef = useRef(new Compartment());
+ const filterCompartmentMountExtRef = useRef(null);
+ if (filterCompartmentMountExtRef.current == null) {
+ filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
+ filter({ fields: allFields ?? [] }),
+ );
+ }
+
+ useEffect(() => {
+ const view = filterRef.current;
+ if (!view) return;
+ const ext = filter({ fields: allFields ?? [] });
+ view.dispatch({
+ effects: filterLanguageCompartmentRef.current.reconfigure(ext),
+ });
+ }, [allFields]);
+
+ if (tree == null || hidden) {
+ return null;
+ }
+
+ return (
+
+
+ {(tree.children?.length ?? 0) > 0 && (
+ <>
+
+ )
+ }
+ />
+ ,
+ onSelect: () => {
+ const activeId = jotaiStore.get(activeIdAtom);
+ if (activeId == null) return;
+
+ const folders = jotaiStore.get(foldersAtom);
+ const workspaces = jotaiStore.get(workspacesAtom);
+ const currentModel = getAnyModel(activeId);
+ const ancestors = getModelAncestors(folders, workspaces, currentModel);
+ jotaiStore.set(collapsedFamily(treeId), (prev) => {
+ const n = { ...prev };
+ for (const ancestor of ancestors) {
+ if (ancestor.model === "folder") {
+ delete n[ancestor.id];
+ }
+ }
+ return n;
+ });
+ treeRef.current?.selectItem(activeId, false);
+ treeRef.current?.focus();
+ },
+ },
+ {
+ label: "Expand All Folders",
+ leftSlot: ,
+ onSelect: () => jotaiStore.set(collapsedFamily(treeId), {}),
+ hotKeyAction: "sidebar.expand_all",
+ hotKeyLabelOnly: true,
+ },
+ {
+ label: "Collapse All Folders",
+ leftSlot: ,
+ onSelect: () => {
+ if (tree == null) return;
+ const next = (
+ node: TreeNode,
+ collapsed: Record,
+ ) => {
+ let newCollapsed = { ...collapsed };
+ for (const n of node.children ?? []) {
+ if (n.item.model !== "folder") continue;
+ newCollapsed[n.item.id] = true;
+ newCollapsed = next(n, newCollapsed);
+ }
+ return newCollapsed;
+ };
+ jotaiStore.set(collapsedFamily(treeId), next(tree, {}));
+ },
+ hotKeyAction: "sidebar.collapse_all",
+ hotKeyLabelOnly: true,
+ },
+ ]}
+ >
+
+
+ >
+ )}
+
+ {allHidden ? (
+
+ {(emptyFilterSuggestions?.length ?? 0) > 0 ? (
+
+
+ No results, but found matches for{" "}
+ {emptyFilterSuggestions?.map((suggestion, i) => (
+
+ {i > 0 && " or "}
+ applyFilterExample(suggestion.filterText)}
+ >
+
+ {suggestion.filterText}
+
+
+
+ ))}
+
+
+ ) : (
+
+
+ No results for{" "}
+
+ {filterText.text}
+
+
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// Memoized so route navigations (which re-render the workspace layout) don't
+// re-render the sidebar subtree. In large workspaces a sidebar re-render is
+// very expensive: it re-renders DndContext, whose context churn re-renders
+// every visible TreeItem regardless of their memo comparators.
+export default memo(Sidebar);
+
+function getGitContextMenuItems({
+ items,
+ syncDir,
+}: {
+ items: SidebarModel[];
+ syncDir: string | null | undefined;
+}): DropdownItem[] {
+ if (syncDir == null) return [];
+
+ const gitStatusEntries = items.flatMap((item) => {
+ const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
+ return status == null || status.status === "current" ? [] : [status];
+ });
+ const historyItem = items.length === 1 ? items[0] : null;
+ const historyPath =
+ historyItem == null
+ ? null
+ : (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
+ syncPathForModel(historyItem));
+
+ return [
+ {
+ label: "View History",
+ leftSlot: ,
+ hidden: historyPath == null,
+ onSelect: () => {
+ if (historyPath == null) return;
+ showDialog({
+ id: "git-history",
+ size: "lg",
+ title: "File History",
+ noPadding: true,
+ noScroll: true,
+ render: () => ,
+ });
+ },
+ },
+ {
+ label: "Restore Changes",
+ leftSlot: ,
+ hidden: gitStatusEntries.length === 0,
+ async onSelect() {
+ const confirmed = await showConfirm({
+ id: "git-restore-sidebar-items",
+ title: "Restore Changes",
+ description:
+ gitStatusEntries.length === 1
+ ? "This will discard uncommitted changes for the selected item."
+ : `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
+ confirmText: "Restore",
+ color: "danger",
+ });
+ if (!confirmed) return;
+
+ await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
+ relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
+ });
+ await sync({ force: true });
+ },
+ },
+ ];
+}
+
+function syncPathForModel(item: SidebarModel) {
+ return `yaak.${item.id}.yaml`;
+}
+
+const activeIdAtom = atom((get) => {
+ return get(activeRequestIdAtom) || get(activeFolderIdAtom);
+});
+
+function getEditOptions(
+ item: SidebarModel,
+): ReturnType["getEditOptions"]>> {
+ return {
+ onChange: handleSubmitEdit,
+ defaultValue: resolvedModelName(item),
+ placeholder: item.name,
+ };
+}
+
+async function handleSubmitEdit(item: SidebarModel, text: string) {
+ await patchModel(item, { name: text });
+}
+
+function handleActivate(item: SidebarModel) {
+ // TODO: Add folder layout support
+ if (item.model !== "folder" && item.model !== "workspace") {
+ navigateToRequestOrFolderOrWorkspace(item.id, item.model);
+ }
+}
+
+const allPotentialChildrenAtom = atom((get) => {
+ const requests = get(allRequestsAtom);
+ const folders = get(foldersAtom);
+ return [...requests, ...folders];
+});
+
+const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
+
+const sidebarFilterAtom = atom<{ text: string; key: string }>({
+ text: "",
+ key: "",
+});
+
+type SidebarFilterSuggestion = {
+ field: string;
+ filterText: string;
+};
+
+function setSidebarFilterText(text: string) {
+ jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
+}
+
+function getSidebarSuggestionValue(ast: Ast | null) {
+ if (ast == null) return null;
+
+ if (ast.type === "Term" || ast.type === "Phrase") {
+ const value = ast.value.trim();
+ return value.length > 0 ? value : null;
+ }
+
+ if (ast.type === "Field") {
+ const value = ast.value.trim();
+ return value.length > 0 ? value : null;
+ }
+
+ return null;
+}
+
+function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
+ return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
+}
+
+const sidebarSuggestionFieldOrder = [
+ "url",
+ "folder",
+ "method",
+ "type",
+ "grpc_service",
+ "grpc_method",
+ "name",
+];
+
+const sidebarTreeAtom = atom<
+ [TreeNode, FieldDef[], SidebarFilterSuggestion[]] | null
+>((get) => {
+ const allModels = get(memoAllPotentialChildrenAtom);
+ const activeWorkspace = get(activeWorkspaceAtom);
+ const filter = get(sidebarFilterAtom);
+
+ const childrenMap: Record[]> = {};
+ for (const item of allModels) {
+ if ("folderId" in item && item.folderId == null) {
+ childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
+ childrenMap[item.workspaceId]?.push(item);
+ } else if ("folderId" in item && item.folderId != null) {
+ childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
+ childrenMap[item.folderId]?.push(item);
+ }
+ }
+
+ if (activeWorkspace == null) {
+ return null;
+ }
+
+ const queryAst = parseQuery(filter.text);
+ const suggestionValue = getSidebarSuggestionValue(queryAst);
+
+ // returns true if this node OR any child matches the filter
+ const allFields: Record> = {};
+ const suggestionFields = new Set();
+ const build = (node: TreeNode, depth: number): boolean => {
+ const childItems = childrenMap[node.item.id] ?? [];
+ let matchesSelf = true;
+ const fields = getItemFields(node);
+ const model = node.item.model;
+ const isLeafNode = !(model === "folder" || model === "workspace");
+
+ for (const [field, value] of Object.entries(fields)) {
+ if (!value) continue;
+ allFields[field] = allFields[field] ?? new Set();
+ allFields[field].add(value);
+ if (
+ isLeafNode &&
+ suggestionValue != null &&
+ sidebarFieldMatchesValue(value, suggestionValue)
+ ) {
+ suggestionFields.add(field);
+ }
+ }
+
+ if (queryAst != null) {
+ matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
+ }
+
+ let matchesChild = false;
+
+ // Recurse to children
+ node.children = !isLeafNode ? [] : undefined;
+
+ if (node.children != null) {
+ childItems.sort((a, b) => {
+ if (a.sortPriority === b.sortPriority) {
+ return a.updatedAt > b.updatedAt ? 1 : -1;
+ }
+ return a.sortPriority - b.sortPriority;
+ });
+
+ for (const item of childItems) {
+ const childNode = { item, parent: node, depth };
+ const childMatches = build(childNode, depth + 1);
+ if (childMatches) {
+ matchesChild = true;
+ }
+ node.children.push(childNode);
+ }
+ }
+
+ // hide node IFF nothing in its subtree matches
+ const anyMatch = matchesSelf || matchesChild;
+ node.hidden = !anyMatch;
+
+ return anyMatch;
+ };
+
+ const root: TreeNode = {
+ item: activeWorkspace,
+ parent: null,
+ children: [],
+ depth: 0,
+ };
+
+ // Build tree and mark visibility in one pass
+ build(root, 1);
+
+ const fields: FieldDef[] = [];
+ for (const [name, values] of Object.entries(allFields)) {
+ fields.push({
+ name,
+ values: Array.from(values).filter((v) => v.length < 20),
+ });
+ }
+ const suggestions = Array.from(suggestionFields)
+ .sort((a, b) => {
+ const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
+ const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
+ if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
+ return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
+ })
+ .map((field) => ({
+ field,
+ filterText: formatFieldFilter(field, suggestionValue ?? ""),
+ }));
+ return [root, fields, suggestions] as const;
+});
+
+const sidebarGitStatusByModelIdAtom = atom>((get) => {
+ const allModels = get(memoAllPotentialChildrenAtom);
+ const activeWorkspace = get(activeWorkspaceAtom);
+ const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
+ const childrenMap: Record[]> = {};
+ const statusByModelId: Record = {};
+
+ for (const item of allModels) {
+ if ("folderId" in item && item.folderId == null) {
+ childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
+ childrenMap[item.workspaceId]?.push(item);
+ } else if ("folderId" in item && item.folderId != null) {
+ childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
+ childrenMap[item.folderId]?.push(item);
+ }
+ }
+
+ const visit = (item: SidebarModel): GitStatus | null => {
+ const statuses: GitStatus[] = [];
+ const directStatus = gitStatusByModelId[item.id]?.status;
+ if (directStatus != null && directStatus !== "current") {
+ statuses.push(directStatus);
+ }
+
+ for (const child of childrenMap[item.id] ?? []) {
+ const childStatus = visit(child);
+ if (childStatus != null) statuses.push(childStatus);
+ }
+
+ const status = summarizeGitStatuses(statuses);
+ if (status != null) {
+ statusByModelId[item.id] = status;
+ }
+ return status;
+ };
+
+ if (activeWorkspace != null) {
+ visit(activeWorkspace);
+ }
+
+ return statusByModelId;
+});
+
+const sidebarGitStatusFamily = atomFamily(
+ (modelId: string) =>
+ selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
+ Object.is,
+);
+
+function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
+ if (statuses.length === 0) return null;
+ const firstStatus = statuses[0];
+ if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
+ return firstStatus;
+ }
+ return "modified";
+}
+
+function getItemKey(item: SidebarModel) {
+ const responses = jotaiStore.get(httpResponsesAtom);
+ const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
+ const url = "url" in item ? item.url : "n/a";
+ const method = "method" in item ? item.method : "n/a";
+ const service = "service" in item ? item.service : "n/a";
+ return [
+ item.id,
+ item.name,
+ url,
+ method,
+ service,
+ latestResponse?.elapsed,
+ latestResponse?.id ?? "n/a",
+ ].join("::");
+}
+
+const SidebarLeftSlot = memo(function SidebarLeftSlot({
+ treeId,
+ item,
+}: {
+ treeId: string;
+ item: SidebarModel;
+}) {
+ if (item.model === "folder") {
+ return ;
+ }
+ if (item.model === "workspace") {
+ return null;
+ }
+ const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
+ return (
+
+ );
+});
+
+const SidebarInnerItem = memo(function SidebarInnerItem({
+ item,
+}: {
+ treeId: string;
+ item: SidebarModel;
+}) {
+ const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
+ const response = useAtomValue(
+ useMemo(
+ () =>
+ selectAtom(
+ atom((get) => [
+ ...get(grpcConnectionsAtom),
+ ...get(httpResponsesAtom),
+ ...get(websocketConnectionsAtom),
+ ]),
+ (responses) => responses.find((r) => r.requestId === item.id),
+ (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
+ ),
+ [item.id],
+ ),
+ );
+
+ return (
+
+
+ {resolvedModelName(item)}
+
+ {response != null && (
+
+ {response.state !== "closed" ? (
+
+ ) : response.model === "http_response" ? (
+
+ ) : null}
+
+ )}
+
+ );
+});
+
+function getItemFields(node: TreeNode): Record {
+ const item = node.item;
+
+ if (item.model === "workspace") return {};
+
+ const fields: Record = {};
+ if (item.model === "http_request") {
+ fields.method = item.method.toUpperCase();
+ }
+
+ if (item.model === "grpc_request") {
+ fields.grpc_method = item.method ?? "";
+ fields.grpc_service = item.service ?? "";
+ }
+
+ if ("url" in item) fields.url = item.url;
+ fields.name = resolvedModelName(item);
+
+ fields.type = "http";
+ if (item.model === "grpc_request") fields.type = "grpc";
+ else if (item.model === "websocket_request") fields.type = "ws";
+
+ if (node.parent?.item.model === "folder") {
+ fields.folder = node.parent.item.name;
+ }
+
+ return fields;
+}
+
+function getItemText(item: SidebarModel): string {
+ const segments = [];
+ if (item.model === "http_request") {
+ segments.push(item.method);
+ }
+
+ segments.push(resolvedModelName(item));
+
+ return segments.join(" ");
+}
diff --git a/apps/yaak-client/components/SidebarActions.tsx b/apps/yaak-client/components/SidebarActions.tsx
new file mode 100644
index 0000000..ba479a5
--- /dev/null
+++ b/apps/yaak-client/components/SidebarActions.tsx
@@ -0,0 +1,39 @@
+import { HStack } from "@yaakapp-internal/ui";
+import { useMemo } from "react";
+import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { CreateDropdown } from "./CreateDropdown";
+import { IconButton } from "./core/IconButton";
+
+interface Props {
+ floating?: boolean;
+}
+
+export function SidebarActions({ floating = false }: Props) {
+ const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
+ const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
+
+ const hidden = floating ? floatingHidden : sidebarHidden;
+ const setHidden = useMemo(
+ () => (floating ? setFloatingHidden : setSidebarHidden),
+ [floating, setFloatingHidden, setSidebarHidden],
+ );
+
+ return (
+
+ {
+ await setHidden(!hidden);
+ }}
+ className="pointer-events-auto"
+ size="sm"
+ title="Toggle sidebar"
+ icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
+ iconColor="secondary"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/SwitchWorkspaceDialog.tsx b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx
new file mode 100644
index 0000000..2852b8e
--- /dev/null
+++ b/apps/yaak-client/components/SwitchWorkspaceDialog.tsx
@@ -0,0 +1,60 @@
+import type { Workspace } from "@yaakapp-internal/models";
+import { patchModel, settingsAtom } from "@yaakapp-internal/models";
+import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import { Button } from "./core/Button";
+import { Checkbox } from "./core/Checkbox";
+
+interface Props {
+ hide: () => void;
+ workspace: Workspace;
+}
+
+export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
+ const settings = useAtomValue(settingsAtom);
+ const [remember, setRemember] = useState(false);
+
+ return (
+
+
+ Where would you like to open {workspace.name} ?
+
+
+ {
+ hide();
+ switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
+ if (remember) {
+ await patchModel(settings, { openWorkspaceNewWindow: false });
+ }
+ }}
+ >
+ This Window
+
+ }
+ onClick={async () => {
+ hide();
+ switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
+ if (remember) {
+ await patchModel(settings, { openWorkspaceNewWindow: true });
+ }
+ }}
+ >
+ New Window
+
+
+ {settings && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/SyncToFilesystemSetting.tsx b/apps/yaak-client/components/SyncToFilesystemSetting.tsx
new file mode 100644
index 0000000..51e8132
--- /dev/null
+++ b/apps/yaak-client/components/SyncToFilesystemSetting.tsx
@@ -0,0 +1,121 @@
+import { Banner, VStack } from "@yaakapp-internal/ui";
+import { useState } from "react";
+import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
+import { Button } from "./core/Button";
+import { Checkbox } from "./core/Checkbox";
+import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
+import { SelectFile } from "./SelectFile";
+import { platform } from "@yaakapp-internal/platform";
+
+export interface SyncToFilesystemSettingProps {
+ layout?: "form" | "settings";
+ onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
+ onCreateNewWorkspace: () => void;
+ value: { filePath: string | null; initGit?: boolean };
+}
+
+export function SyncToFilesystemSetting({
+ layout = "form",
+ onChange,
+ onCreateNewWorkspace,
+ value,
+}: SyncToFilesystemSettingProps) {
+ const [syncDir, setSyncDir] = useState(null);
+
+ const handleFilePathChange = async (filePath: string | null) => {
+ if (filePath != null) {
+ const files = await platform.files.readDir(filePath);
+ if (files.length > 0) {
+ setSyncDir(filePath);
+ return;
+ }
+ }
+
+ setSyncDir(null);
+ onChange({ ...value, filePath });
+ };
+
+ if (layout === "settings") {
+ return (
+
+ {syncDir && (
+
+ Directory is not empty. Do you want to open it instead?
+
+ {
+ openWorkspaceFromSyncDir.mutate(syncDir);
+ onCreateNewWorkspace();
+ }}
+ >
+ Open Workspace
+
+
+
+ )}
+
+
+
+ {value.filePath && typeof value.initGit === "boolean" && (
+ onChange({ ...value, initGit })}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+ {syncDir && (
+
+ Directory is not empty. Do you want to open it instead?
+
+ {
+ openWorkspaceFromSyncDir.mutate(syncDir);
+ onCreateNewWorkspace();
+ }}
+ >
+ Open Workspace
+
+
+
+ )}
+
+ handleFilePathChange(filePath)}
+ />
+
+ {value.filePath && typeof value.initGit === "boolean" && (
+ onChange({ ...value, initGit })}
+ title="Initialize Git Repo"
+ />
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/TemplateFunctionDialog.tsx b/apps/yaak-client/components/TemplateFunctionDialog.tsx
new file mode 100644
index 0000000..92e2177
--- /dev/null
+++ b/apps/yaak-client/components/TemplateFunctionDialog.tsx
@@ -0,0 +1,296 @@
+import type { EditorView } from "@codemirror/view";
+import type {
+ Folder,
+ GrpcRequest,
+ HttpRequest,
+ WebsocketRequest,
+ Workspace,
+} from "@yaakapp-internal/models";
+import type { TemplateFunction } from "@yaakapp-internal/plugins";
+import type { FnArg, Tokens } from "@yaakapp-internal/templates";
+import { parseTemplate } from "@yaakapp-internal/templates";
+import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useEffect, useMemo, useState } from "react";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { useRenderTemplate } from "../hooks/useRenderTemplate";
+import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
+import {
+ templateTokensToString,
+ useTemplateTokensToString,
+} from "../hooks/useTemplateTokensToString";
+import { useToggle } from "../hooks/useToggle";
+import { showDialog } from "../lib/dialog";
+import { convertTemplateToInsecure } from "../lib/encryption";
+import { jotaiStore } from "../lib/jotai";
+import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
+import { Button } from "./core/Button";
+import { collectArgumentValues } from "./core/Editor/twig/util";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
+
+interface Props {
+ templateFunction: TemplateFunction;
+ initialTokens: Tokens;
+ hide: () => void;
+ onChange: (insert: string) => void;
+ model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
+}
+
+export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
+ const [initialArgValues, setInitialArgValues] = useState | null>(
+ null,
+ );
+ useEffect(() => {
+ if (initialArgValues != null) {
+ return;
+ }
+
+ (async () => {
+ const initial = collectArgumentValues(initialTokens, templateFunction);
+
+ // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
+ // we can display it in the editor input.
+ if (templateFunction.name === "secure") {
+ const template = await templateTokensToString(initialTokens);
+ initial.value = await convertTemplateToInsecure(template);
+ }
+
+ setInitialArgValues(initial);
+ })().catch(console.error);
+ }, [
+ initialArgValues,
+ initialTokens,
+ initialTokens.tokens,
+ templateFunction,
+ templateFunction.args,
+ templateFunction.name,
+ ]);
+
+ if (initialArgValues == null) return null;
+
+ return (
+
+ );
+}
+
+function InitializedTemplateFunctionDialog({
+ templateFunction: { name, previewType: ogPreviewType },
+ initialArgValues,
+ hide,
+ onChange,
+ model,
+}: Omit & {
+ initialArgValues: Record;
+}) {
+ const previewType = ogPreviewType == null ? "live" : ogPreviewType;
+ const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
+ const [argValues, setArgValues] = useState>(initialArgValues);
+
+ const tokens: Tokens = useMemo(() => {
+ const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
+ name,
+ value:
+ argValues[name] === DYNAMIC_FORM_NULL_ARG
+ ? { type: "null" }
+ : typeof argValues[name] === "boolean"
+ ? { type: "bool", value: argValues[name] === true }
+ : { type: "str", text: String(argValues[name] ?? "") },
+ }));
+
+ return {
+ tokens: [
+ {
+ type: "tag",
+ val: {
+ type: "fn",
+ name,
+ args: argTokens,
+ },
+ },
+ ],
+ };
+ }, [argValues, name]);
+
+ const tagText = useTemplateTokensToString(tokens);
+ const templateFunction = useTemplateFunctionConfig(name, argValues, model);
+
+ const handleDone = () => {
+ if (tagText.data) {
+ onChange(tagText.data);
+ }
+ hide();
+ };
+
+ const debouncedTagText = useDebouncedValue(tagText.data ?? "", 400);
+ const [renderKey, setRenderKey] = useState(null);
+ const rendered = useRenderTemplate({
+ template: debouncedTagText,
+ enabled: previewType !== "none",
+ purpose: previewType === "click" ? "send" : "preview",
+ refreshKey: previewType === "live" ? renderKey + debouncedTagText : renderKey,
+ ignoreError: false,
+ });
+
+ const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change
+ const dataContainsSecrets = useMemo(() => {
+ for (const [name, value] of Object.entries(argValues)) {
+ const arg = templateFunction.data?.args.find((a) => "name" in a && a.name === name);
+ const isTextPassword = arg?.type === "text" && arg.password;
+ if (isTextPassword && typeof value === "string" && value && rendered.data?.includes(value)) {
+ return true;
+ }
+ }
+ return false;
+ }, [rendered.data]);
+
+ if (templateFunction.data == null || templateFunction.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ {
+ e.preventDefault();
+ handleDone();
+ }}
+ >
+
+ {name === "secure" ? (
+
setArgValues({ ...argValues, value })}
+ />
+ ) : (
+
+ )}
+
+
+ {previewType !== "none" ? (
+
+
+
+ Rendered Preview
+ {rendered.isLoading && }
+
+
+
+
+
+ {rendered.error || tagText.error ? (
+
+ {`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
+
+ ) : dataContainsSecrets && !showSecretsInPreview ? (
+
+ ------ sensitive values hidden ------
+
+ ) : tooLarge ? (
+ "too large to preview"
+ ) : (
+ rendered.data || <> >
+ )}
+
+
+ {
+ setRenderKey(new Date().toISOString());
+ }}
+ />
+
+
+
+ ) : (
+
+ )}
+
+ {templateFunction.data.name === "secure" && (
+
+ Reveal Encryption Key
+
+ )}
+
+ Save
+
+
+
+
+ );
+}
+
+TemplateFunctionDialog.show = (
+ fn: TemplateFunction,
+ tagValue: string,
+ startPos: number,
+ view: EditorView,
+) => {
+ const initialTokens = parseTemplate(tagValue);
+ showDialog({
+ id: `template-function-${Math.random()}`, // Allow multiple at once
+ size: "md",
+ className: "h-240",
+ noPadding: true,
+ title: {fn.name}(…) ,
+ description: fn.description,
+ render: ({ hide }) => {
+ const model = jotaiStore.get(activeWorkspaceAtom);
+ if (model == null) return null;
+ return (
+ {
+ view.dispatch({
+ changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
+ });
+ }}
+ />
+ );
+ },
+ });
+};
diff --git a/apps/yaak-client/components/Toasts.tsx b/apps/yaak-client/components/Toasts.tsx
new file mode 100644
index 0000000..046f191
--- /dev/null
+++ b/apps/yaak-client/components/Toasts.tsx
@@ -0,0 +1,43 @@
+import { useAtomValue } from "jotai";
+import { AnimatePresence } from "motion/react";
+import type { ReactNode } from "react";
+import { hideToast, toastsAtom } from "../lib/toast";
+import { Toast, type ToastProps } from "./core/Toast";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { Portal } from "@yaakapp-internal/ui";
+
+export type ToastInstance = {
+ id: string;
+ uniqueKey: string;
+ message: ReactNode;
+ timeout: 3000 | 5000 | 8000 | (number & {}) | null;
+ onClose?: ToastProps["onClose"];
+} & Omit;
+
+export const Toasts = () => {
+ const toasts = useAtomValue(toastsAtom);
+ return (
+
+
+
+ {toasts.map((toast: ToastInstance) => {
+ const { message, uniqueKey, ...props } = toast;
+ return (
+
+ hideToast(toast)}
+ >
+ {message}
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/yaak-client/components/UrlBar.tsx b/apps/yaak-client/components/UrlBar.tsx
new file mode 100644
index 0000000..b729ad3
--- /dev/null
+++ b/apps/yaak-client/components/UrlBar.tsx
@@ -0,0 +1,113 @@
+import type { HttpRequest } from "@yaakapp-internal/models";
+import type { IconProps } from "@yaakapp-internal/ui";
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { FormEvent, ReactNode } from "react";
+import { memo, useCallback, useRef, useState } from "react";
+import { useHotKey } from "../hooks/useHotKey";
+import { IconButton } from "./core/IconButton";
+import type { InputHandle, InputProps } from "./core/Input";
+import { Input } from "./core/Input";
+
+type Props = Pick & {
+ className?: string;
+ placeholder: string;
+ onSend: () => void;
+ onUrlChange: (url: string) => void;
+ onPaste?: (v: string) => void;
+ onPasteOverwrite?: InputProps["onPasteOverwrite"];
+ onCancel: () => void;
+ submitIcon?: IconProps["icon"] | null;
+ isLoading: boolean;
+ forceUpdateKey: string;
+ rightSlot?: ReactNode;
+ leftSlot?: ReactNode;
+ autocomplete?: InputProps["autocomplete"];
+ stateKey: InputProps["stateKey"];
+};
+
+export const UrlBar = memo(function UrlBar({
+ forceUpdateKey,
+ onUrlChange,
+ url,
+ placeholder,
+ className,
+ onSend,
+ onCancel,
+ onPaste,
+ onPasteOverwrite,
+ submitIcon = "send_horizontal",
+ autocomplete,
+ leftSlot,
+ rightSlot,
+ isLoading,
+ stateKey,
+}: Props) {
+ const inputRef = useRef(null);
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleInitInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
+ useHotKey("url_bar.focus", () => {
+ inputRef.current?.selectAll();
+ });
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ if (isLoading) onCancel();
+ else onSend();
+ };
+
+ return (
+
+ setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ onPaste={onPaste}
+ onPasteOverwrite={onPasteOverwrite}
+ onChange={onUrlChange}
+ defaultValue={url}
+ placeholder={placeholder}
+ leftSlot={leftSlot}
+ rightSlot={
+
+ {rightSlot && {rightSlot}
}
+ {submitIcon !== null && (
+
+ {
+ // Prevent the button from taking focus
+ e.preventDefault();
+ }}
+ />
+
+ )}
+
+ }
+ />
+
+ );
+});
diff --git a/apps/yaak-client/components/UrlParameterEditor.tsx b/apps/yaak-client/components/UrlParameterEditor.tsx
new file mode 100644
index 0000000..aab2afa
--- /dev/null
+++ b/apps/yaak-client/components/UrlParameterEditor.tsx
@@ -0,0 +1,54 @@
+import { VStack } from "@yaakapp-internal/ui";
+import { useCallback, useRef } from "react";
+import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
+import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
+import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
+
+type Props = {
+ forceUpdateKey: string;
+ pairs: EditablePair[];
+ stateKey: PairEditorProps["stateKey"];
+ onChange: PairEditorProps["onChange"];
+};
+
+export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
+ const pairEditorRef = useRef(null);
+ const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
+ pairEditorRef.current = ref;
+ }, []);
+
+ const [{ urlParametersKey }] = useRequestEditor();
+
+ useRequestEditorEvent(
+ "request_params.focus_value",
+ (name) => {
+ const pair = pairs.find((p) => p.name === name);
+ if (pair?.id != null) {
+ pairEditorRef.current?.focusValue(pair.id);
+ } else {
+ console.log(`Couldn't find pair to focus`, { name, pairs });
+ }
+ },
+ [pairs],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketRequestLayout.tsx b/apps/yaak-client/components/WebsocketRequestLayout.tsx
new file mode 100644
index 0000000..16e6b3e
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketRequestLayout.tsx
@@ -0,0 +1,49 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import type { CSSProperties } from "react";
+
+import { SplitLayout } from "@yaakapp-internal/ui";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { workspaceLayoutAtom } from "../lib/atoms";
+import { WebsocketRequestPane } from "./WebsocketRequestPane";
+import { WebsocketResponsePane } from "./WebsocketResponsePane";
+
+interface Props {
+ activeRequest: WebsocketRequest;
+ style: CSSProperties;
+}
+
+export function WebsocketRequestLayout({ activeRequest, style }: Props) {
+ const workspaceLayout = useAtomValue(workspaceLayoutAtom);
+ const activeWorkspace = useAtomValue(activeWorkspaceAtom);
+ const wsId = activeWorkspace?.id ?? "n/a";
+ return (
+ (
+
+ )}
+ secondSlot={({ style }) => (
+
+
+
+ )}
+ />
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx
new file mode 100644
index 0000000..26ece54
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketRequestPane.tsx
@@ -0,0 +1,314 @@
+import type { WebsocketRequest } from "@yaakapp-internal/models";
+import {
+ flushAllModelWrites,
+ getModel,
+ patchModel,
+ patchModelDebounced,
+} from "@yaakapp-internal/models";
+import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
+import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
+import classNames from "classnames";
+import { atom, useAtomValue } from "jotai";
+import type { CSSProperties } from "react";
+import { useCallback, useMemo, useRef } from "react";
+import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
+import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
+import { allRequestUrlsAtom } from "../hooks/useAllRequests";
+import { useAuthTab } from "../hooks/useAuthTab";
+import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
+import { useHeadersTab } from "../hooks/useHeadersTab";
+import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
+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";
+import { IconButton } from "./core/IconButton";
+import { PlainInput } from "./core/PlainInput";
+import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
+import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
+import { HeadersEditor } from "./HeadersEditor";
+import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
+import { MarkdownEditor } from "./MarkdownEditor";
+import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
+import { UrlBar } from "./UrlBar";
+import { UrlParametersEditor } from "./UrlParameterEditor";
+
+interface Props {
+ style: CSSProperties;
+ fullHeight: boolean;
+ className?: string;
+ activeRequest: WebsocketRequest;
+}
+
+const TAB_MESSAGE = "message";
+const TAB_PARAMS = "params";
+const TAB_HEADERS = "headers";
+const TAB_AUTH = "auth";
+const TAB_SETTINGS = "settings";
+const TAB_DESCRIPTION = "description";
+const TABS_STORAGE_KEY = "websocket_request_tabs";
+
+// Derived from the identity-stable URL list so this only recomputes when a URL
+// actually changes. The active request's own URL is included, but exact matches
+// are filtered out at completion time by genericCompletion.
+const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
+ get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
+);
+
+export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
+ const activeRequestId = activeRequest.id;
+ const tabsRef = useRef(null);
+ const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
+ const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
+ const authTab = useAuthTab(TAB_AUTH, activeRequest);
+ const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
+ const inheritedHeaders = useInheritedHeaders(activeRequest);
+ const numSettingsOverrides = countOverriddenSettings(activeRequest);
+
+ // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
+ useRequestEditorEvent(
+ "request_pane.focus_tab",
+ () => {
+ tabsRef.current?.setActiveTab(TAB_PARAMS);
+ },
+ [],
+ );
+
+ // Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
+ // value detaches from the placeholder.
+ // NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
+ // holds onto it until the URL's placeholders change, so a captured request would go stale and
+ // patch its parameter list back over newer edits.
+ const handleRenamePathPlaceholder = useCallback(
+ (oldName: string, newName: string) => {
+ const request = getModel("websocket_request", activeRequestId);
+ if (request == null) return false;
+
+ const patch = renamePathPlaceholder(request, oldName, newName);
+ if (patch == null) return false; // Unusable name, so the editor reverts the field
+ void patchModel(request, patch);
+ return true;
+ },
+ [activeRequestId],
+ );
+
+ const { urlParameterPairs, urlParametersKey } = useMemo(
+ () =>
+ derivePathPlaceholderPairs(
+ activeRequest.url,
+ activeRequest.urlParameters,
+ handleRenamePathPlaceholder,
+ ),
+ [activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
+ );
+
+ const hasDescription = activeRequest.description.trim().length > 0;
+
+ const tabs = useMemo(() => {
+ return [
+ {
+ value: TAB_MESSAGE,
+ label: "Message",
+ } as TabItem,
+ {
+ value: TAB_PARAMS,
+ rightSlot: ,
+ label: "Params",
+ },
+ ...headersTab,
+ ...authTab,
+ {
+ value: TAB_SETTINGS,
+ label: "Settings",
+ rightSlot: ,
+ },
+ {
+ value: TAB_DESCRIPTION,
+ label: "Info",
+ rightSlot: hasDescription && ,
+ },
+ ];
+ }, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
+
+ const { activeResponse } = usePinnedHttpResponse(activeRequestId);
+ const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
+ const connection = useAtomValue(activeWebsocketConnectionAtom);
+
+ const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
+
+ const autocomplete: GenericCompletionConfig = useMemo(
+ () => getUrlCompletionConfig(autocompleteUrls),
+ [autocompleteUrls],
+ );
+
+ const handleConnect = useCallback(async () => {
+ await flushAllModelWrites(); // The backend reads the request from the DB
+ await connectWebsocket({
+ requestId: activeRequest.id,
+ environmentId: getActiveEnvironment()?.id ?? null,
+ cookieJarId: getActiveCookieJar()?.id ?? null,
+ });
+ }, [activeRequest.id]);
+
+ const handleSend = useCallback(async () => {
+ if (connection == null) return;
+ await flushAllModelWrites(); // The backend reads the message from the DB
+ await sendWebsocket({
+ connectionId: connection?.id,
+ environmentId: getActiveEnvironment()?.id ?? null,
+ });
+ }, [connection]);
+
+ const handleCancel = useCallback(async () => {
+ if (connection == null) return;
+ await closeWebsocket({ connectionId: connection?.id });
+ }, [connection]);
+
+ const handleUrlChange = useCallback(
+ (url: string) => patchModelDebounced(activeRequest, { url }),
+ [activeRequest],
+ );
+
+ const handlePaste = useCallback(
+ async (e: ClipboardEvent, text: string) => {
+ const patch = prepareImportQuerystring(text);
+ if (patch != null) {
+ e.preventDefault(); // Prevent input onChange
+
+ await patchModel(activeRequest, patch);
+ await setActiveTab({
+ storageKey: TABS_STORAGE_KEY,
+ activeTabKey: activeRequestId,
+ value: TAB_PARAMS,
+ });
+
+ // Wait for request to update, then refresh the UI
+ // TODO: Somehow make this deterministic
+ setTimeout(() => {
+ forceUrlRefresh();
+ forceParamsRefresh();
+ }, 100);
+ }
+ },
+ [activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
+ );
+
+ const messageLanguage = languageFromContentType(null, activeRequest.message);
+
+ const isLoading = connection !== null && connection.state !== "closed";
+
+ return (
+
+ {activeRequest && (
+ <>
+
+
+ )
+ }
+ placeholder="wss://example.com"
+ onPasteOverwrite={handlePaste}
+ autocomplete={autocomplete}
+ onSend={isLoading ? handleSend : handleConnect}
+ onCancel={cancelResponse}
+ onUrlChange={handleUrlChange}
+ forceUpdateKey={forceUpdateKey}
+ isLoading={activeResponse != null && activeResponse.state !== "closed"}
+ />
+
+
+
+
+
+
+ patchModelDebounced(activeRequest, { headers })}
+ />
+
+
+ patchModelDebounced(activeRequest, { urlParameters })}
+ />
+
+
+ patchModelDebounced(activeRequest, { message })}
+ stateKey={`json.${activeRequest.id}`}
+ />
+
+
+
+
+
+
+
patchModelDebounced(activeRequest, { name })}
+ />
+ patchModelDebounced(activeRequest, { description })}
+ />
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/WebsocketResponsePane.tsx b/apps/yaak-client/components/WebsocketResponsePane.tsx
new file mode 100644
index 0000000..42806e4
--- /dev/null
+++ b/apps/yaak-client/components/WebsocketResponsePane.tsx
@@ -0,0 +1,241 @@
+import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
+import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
+import { hexy } from "hexy";
+import { useAtomValue } from "jotai";
+import { useMemo, useState } from "react";
+import { useFormatText } from "../hooks/useFormatText";
+import {
+ activeWebsocketConnectionAtom,
+ activeWebsocketConnectionsAtom,
+ 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";
+import { EventViewerRow } from "./core/EventViewerRow";
+import { HotkeyList } from "./core/HotkeyList";
+import { WebsocketStatusTag } from "./core/WebsocketStatusTag";
+import { EmptyStateText } from "./EmptyStateText";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { RecentWebsocketConnectionsDropdown } from "./RecentWebsocketConnectionsDropdown";
+
+interface Props {
+ activeRequest: WebsocketRequest;
+}
+
+export function WebsocketResponsePane({ activeRequest }: Props) {
+ const [showLarge, setShowLarge] = useStateWithDeps(false, [activeRequest.id]);
+ const [showingLarge, setShowingLarge] = useState(false);
+ const [hexDumps, setHexDumps] = useState>({});
+
+ const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
+ const connections = useAtomValue(activeWebsocketConnectionsAtom);
+ const events = useWebsocketEvents(activeConnection?.id ?? null);
+
+ if (activeConnection == null) {
+ return (
+
+ );
+ }
+
+ const header = (
+
+
+ {activeConnection.state !== "closed" && (
+
+ )}
+
+ •
+ {events.length} Messages
+
+
+
+
+
+ );
+
+ return (
+
+ event.id}
+ error={activeConnection.error}
+ header={header}
+ splitLayoutStorageKey="websocket_events"
+ defaultRatio={0.4}
+ renderRow={({ event, isActive, onClick }) => (
+
+ )}
+ renderDetail={({ event, index, onClose }) => (
+ setHexDumps({ ...hexDumps, [index]: v })}
+ showLarge={showLarge}
+ showingLarge={showingLarge}
+ setShowLarge={setShowLarge}
+ setShowingLarge={setShowingLarge}
+ onClose={onClose}
+ />
+ )}
+ />
+
+ );
+}
+
+function WebsocketEventRow({
+ event,
+ isActive,
+ onClick,
+}: {
+ event: WebsocketEvent;
+ isActive: boolean;
+ onClick: () => void;
+}) {
+ const { message: messageBytes, isServer, messageType } = event;
+ const message = messageBytes
+ ? new TextDecoder("utf-8").decode(Uint8Array.from(messageBytes))
+ : "";
+
+ const iconColor =
+ messageType === "error"
+ ? "warning"
+ : messageType === "close" || messageType === "open"
+ ? "secondary"
+ : isServer
+ ? "info"
+ : "primary";
+
+ const icon =
+ messageType === "error"
+ ? "alert_triangle"
+ : messageType === "close" || messageType === "open"
+ ? "info"
+ : isServer
+ ? "arrow_big_down_dash"
+ : "arrow_big_up_dash";
+
+ const content =
+ messageType === "close" ? (
+ "Disconnected from server"
+ ) : messageType === "open" ? (
+ "Connected to server"
+ ) : messageType === "error" ? (
+ {message}
+ ) : message === "" ? (
+ No content
+ ) : (
+ {message.slice(0, 1000)}
+ );
+
+ return (
+ }
+ content={content}
+ timestamp={event.createdAt}
+ />
+ );
+}
+
+function WebsocketEventDetail({
+ event,
+ hexDump,
+ setHexDump,
+ showLarge,
+ showingLarge,
+ setShowLarge,
+ setShowingLarge,
+ onClose,
+}: {
+ event: WebsocketEvent;
+ hexDump: boolean;
+ setHexDump: (v: boolean) => void;
+ showLarge: boolean;
+ showingLarge: boolean;
+ setShowLarge: (v: boolean) => void;
+ setShowingLarge: (v: boolean) => void;
+ onClose: () => void;
+}) {
+ const message = useMemo(() => {
+ if (hexDump) {
+ return event.message ? hexy(event.message) : "";
+ }
+ return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
+ }, [event.message, hexDump]);
+
+ const language = languageFromContentType(null, message);
+ const formattedMessage = useFormatText({ language, text: message, pretty: true });
+
+ const title =
+ event.messageType === "close"
+ ? "Connection Closed"
+ : event.messageType === "open"
+ ? "Connection Open"
+ : event.messageType === "error"
+ ? "WebSocket Error"
+ : `Message ${event.isServer ? "Received" : "Sent"}`;
+
+ const actions: EventDetailAction[] =
+ message !== ""
+ ? [
+ {
+ key: "toggle-hexdump",
+ label: hexDump ? "Show Message" : "Show Hexdump",
+ onClick: () => setHexDump(!hexDump),
+ },
+ ]
+ : [];
+
+ return (
+
+
+ {!showLarge && event.message.length > 1000 * 1000 ? (
+
+ Message previews larger than 1MB are hidden
+
+ {
+ setShowingLarge(true);
+ setTimeout(() => {
+ setShowLarge(true);
+ setShowingLarge(false);
+ }, 500);
+ }}
+ isLoading={showingLarge}
+ color="secondary"
+ variant="border"
+ size="xs"
+ >
+ Try Showing
+
+
+
+ ) : event.message.length === 0 ? (
+
No Content
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/Workspace.tsx b/apps/yaak-client/components/Workspace.tsx
new file mode 100644
index 0000000..1e6d22c
--- /dev/null
+++ b/apps/yaak-client/components/Workspace.tsx
@@ -0,0 +1,227 @@
+import { platform } from "@yaakapp-internal/platform";
+import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
+import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import * as m from "motion/react-m";
+import { useMemo, useState } from "react";
+import {
+ getActiveCookieJar,
+ useEnsureActiveCookieJar,
+ useSubscribeActiveCookieJarId,
+} from "../hooks/useActiveCookieJar";
+import {
+ activeEnvironmentAtom,
+ useSubscribeActiveEnvironmentId,
+} from "../hooks/useActiveEnvironment";
+import { activeFolderAtom } from "../hooks/useActiveFolder";
+import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
+import { activeRequestAtom } from "../hooks/useActiveRequest";
+import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
+import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
+import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
+import { useHotKey } from "../hooks/useHotKey";
+import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
+import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
+import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
+import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
+import { useSidebarHidden } from "../hooks/useSidebarHidden";
+import { useSidebarWidth } from "../hooks/useSidebarWidth";
+import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
+import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
+import { importData } from "../lib/importData";
+import { jotaiStore } from "../lib/jotai";
+import { CreateDropdown } from "./CreateDropdown";
+import { Button } from "./core/Button";
+import { HotkeyList } from "./core/HotkeyList";
+import { CookieDialog } from "./CookieDialog";
+import { FeedbackLink } from "./core/Link";
+import { ErrorBoundary } from "./ErrorBoundary";
+import { FolderLayout } from "./FolderLayout";
+import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
+import { HttpRequestLayout } from "./HttpRequestLayout";
+import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
+import Sidebar from "./Sidebar";
+import { SidebarActions } from "./SidebarActions";
+import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
+import { WorkspaceHeader } from "./WorkspaceHeader";
+
+const body = { gridArea: "body" };
+
+export function Workspace() {
+ // First, subscribe to some things applicable to workspaces
+ useGlobalWorkspaceHooks();
+
+ const workspaces = useAtomValue(workspacesAtom);
+ const settings = useAtomValue(settingsAtom);
+ const osType = platform.osType();
+ const [width, setWidth] = useSidebarWidth();
+ const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
+ const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
+ const activeEnvironment = useAtomValue(activeEnvironmentAtom);
+ const [floating, setFloating] = useState(false);
+
+ const environmentBgStyle = useMemo(() => {
+ if (activeEnvironment?.color == null) return undefined;
+ const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
+ return { background };
+ }, [activeEnvironment?.color]);
+
+ if (workspaces.length === 0) {
+ return ;
+ }
+
+ const header = (
+
+
+
+
+ );
+
+ const workspaceBody = (
+
+
+
+ );
+
+ const sidebarContent = floating ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ );
+
+ return (
+
+ {header}
+ setSidebarHidden(hidden)}
+ floatingHidden={floatingSidebarHidden ?? true}
+ onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
+ onFloatingChange={setFloating}
+ sidebar={sidebarContent}
+ >
+ {workspaceBody}
+
+
+ );
+}
+
+function WorkspaceBody() {
+ const activeRequest = useAtomValue(activeRequestAtom);
+ const activeFolder = useAtomValue(activeFolderAtom);
+ const activeWorkspace = useAtomValue(activeWorkspaceAtom);
+
+ if (activeWorkspace == null) {
+ return (
+
+
+ The active workspace was not found. Select a workspace from the header menu or report this
+ bug to
+
+
+ );
+ }
+
+ if (activeRequest?.model === "grpc_request") {
+ return ;
+ }
+ if (activeRequest?.model === "websocket_request") {
+ return ;
+ }
+ if (activeRequest?.model === "http_request") {
+ return ;
+ }
+ if (activeFolder != null) {
+ return ;
+ }
+
+ return (
+
+ importData.mutate()}>
+ Import
+
+
+
+ New Request
+
+
+
+ }
+ />
+ );
+}
+
+function useGlobalWorkspaceHooks() {
+ useEnsureActiveCookieJar();
+
+ useSubscribeActiveRequestId();
+ useSubscribeActiveFolderId();
+ useSubscribeActiveEnvironmentId();
+ useSubscribeActiveCookieJarId();
+
+ useSubscribeRecentRequests();
+ useSubscribeRecentWorkspaces();
+ useSubscribeRecentEnvironments();
+ useSubscribeRecentCookieJars();
+
+ useSyncWorkspaceRequestTitle();
+
+ useHotKey("model.duplicate", () =>
+ duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
+ );
+
+ useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
+ enable: () => getActiveCookieJar() != null,
+ });
+}
diff --git a/apps/yaak-client/components/WorkspaceActionsDropdown.tsx b/apps/yaak-client/components/WorkspaceActionsDropdown.tsx
new file mode 100644
index 0000000..1731b74
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceActionsDropdown.tsx
@@ -0,0 +1,193 @@
+import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { memo, useCallback, useMemo } from "react";
+import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
+import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
+import { switchWorkspace } from "../commands/switchWorkspace";
+import {
+ activeWorkspaceAtom,
+ activeWorkspaceIdAtom,
+ activeWorkspaceMetaAtom,
+} from "../hooks/useActiveWorkspace";
+import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
+import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
+import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
+import { showDialog } from "../lib/dialog";
+import { importData } from "../lib/importData";
+import { jotaiStore } from "../lib/jotai";
+import { revealInFinderText } from "../lib/reveal";
+import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
+import type { ButtonProps } from "./core/Button";
+import { Button } from "./core/Button";
+import type { DropdownItem } from "./core/Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+import type { RadioDropdownItem } from "./core/RadioDropdown";
+import { RadioDropdown } from "./core/RadioDropdown";
+import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
+import { platform } from "@yaakapp-internal/platform";
+
+type Props = Pick;
+
+export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
+ className,
+ ...buttonProps
+}: Props) {
+ const workspaces = useAtomValue(workspacesAtom);
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const createWorkspace = useCreateWorkspace();
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const { mutate: deleteSendHistory } = useDeleteSendHistory();
+ const workspaceActions = useWorkspaceActions();
+
+ const openCloneGitRepositoryDialog = useCallback(() => {
+ showDialog({
+ id: "clone-git-repository",
+ size: "md",
+ title: "Clone Git Repository",
+ render: ({ hide }) => ,
+ });
+ }, []);
+
+ const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
+ workspaceItems: RadioDropdownItem[];
+ itemsAfter: DropdownItem[];
+ itemsBefore: DropdownItem[];
+ }>(() => {
+ const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
+ key: w.id,
+ label: w.name,
+ value: w.id,
+ leftSlot: w.id === workspace?.id ? : ,
+ }));
+
+ const itemsBefore: DropdownItem[] = [
+ {
+ label: "New Workspace",
+ leftSlot: ,
+ submenu: [
+ {
+ label: "Create Empty",
+ leftSlot: ,
+ onSelect: createWorkspace,
+ },
+ {
+ label: "Open Folder",
+ leftSlot: ,
+ onSelect: async () => {
+ const dir = await platform.dialog.open({
+ title: "Select Workspace Directory",
+ directory: true,
+ multiple: false,
+ });
+
+ if (dir == null) return;
+ openWorkspaceFromSyncDir.mutate(dir);
+ },
+ },
+ {
+ label: "Clone Git Repository",
+ leftSlot: ,
+ onSelect: openCloneGitRepositoryDialog,
+ },
+ {
+ label: "Import Data",
+ leftSlot: ,
+ onSelect: () => importData.mutate(),
+ },
+ ],
+ },
+ ];
+ const itemsAfter: DropdownItem[] = [
+ ...workspaceActions.map((a) => ({
+ label: a.label,
+ leftSlot: ,
+ onSelect: async () => {
+ if (workspace != null) await a.call(workspace);
+ },
+ })),
+ ...(workspaceActions.length > 0 ? [{ type: "separator" as const }] : []),
+ {
+ label: "Workspace Settings",
+ leftSlot: ,
+ hotKeyAction: "workspace_settings.show",
+ onSelect: openWorkspaceSettings,
+ },
+ {
+ label: revealInFinderText,
+ hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
+ leftSlot: ,
+ onSelect: async () => {
+ if (workspaceMeta?.settingSyncDir == null) return;
+ await platform.revealItemInDir(workspaceMeta.settingSyncDir);
+ },
+ },
+ {
+ label: "Clear Send History",
+ color: "warning",
+ leftSlot: ,
+ onSelect: deleteSendHistory,
+ },
+ ];
+
+ return { workspaceItems, itemsAfter, itemsBefore };
+ }, [
+ workspaces,
+ workspaceMeta,
+ deleteSendHistory,
+ createWorkspace,
+ openCloneGitRepositoryDialog,
+ workspace?.id,
+ workspace,
+ workspaceActions.map,
+ workspaceActions.length,
+ ]);
+
+ const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
+ if (workspaceId == null) return;
+
+ const settings = jotaiStore.get(settingsAtom);
+ const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ if (workspaceId === activeWorkspaceId) {
+ // Always open a new window if the selected one is already active
+ switchWorkspace.mutate({ workspaceId, inNewWindow: true });
+ return;
+ }
+ if (typeof settings.openWorkspaceNewWindow === "boolean") {
+ switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
+ return;
+ }
+
+ const workspace = getModel("workspace", workspaceId);
+ if (workspace == null) return;
+
+ showDialog({
+ id: "switch-workspace",
+ size: "sm",
+ title: "Switch Workspace",
+ render: ({ hide }) => ,
+ });
+ }, []);
+
+ return (
+
+
+ {workspace?.name ?? "Workspace"}
+
+
+ );
+});
diff --git a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx
new file mode 100644
index 0000000..43b88ab
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx
@@ -0,0 +1,356 @@
+import {
+ disableEncryption,
+ enableEncryption,
+ revealWorkspaceKey,
+ setWorkspaceKey,
+} from "@yaakapp-internal/crypto";
+import type { WorkspaceMeta } from "@yaakapp-internal/models";
+import { Banner, HStack, VStack } 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";
+
+interface Props {
+ layout?: "form" | "settings";
+ size?: ButtonProps["size"];
+ expanded?: boolean;
+ onDone?: () => void;
+ onEnabledEncryption?: () => void;
+}
+
+export function WorkspaceEncryptionSetting({
+ layout = "form",
+ size,
+ expanded,
+ onDone,
+ onEnabledEncryption,
+}: Props) {
+ const [justEnabledEncryption, setJustEnabledEncryption] = useState(false);
+ const [error, setError] = useState(null);
+
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
+
+ useEffect(() => {
+ if (workspaceMeta == null) {
+ return;
+ }
+
+ if (workspaceMeta?.encryptionKey == null) {
+ setKey({ key: null, error: null });
+ return;
+ }
+
+ revealWorkspaceKey(workspaceMeta.workspaceId).then(
+ (key) => {
+ setKey({ key, error: null });
+ },
+ (err) => {
+ setKey({ key: null, error: `${err}` });
+ },
+ );
+ }, [workspaceMeta, workspaceMeta?.encryptionKey]);
+
+ if (key == null || workspace == null || workspaceMeta == null) {
+ return null;
+ }
+
+ // Prompt for key if it doesn't exist or could not be decrypted
+ if (
+ key.error != null ||
+ (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
+ ) {
+ const enterKey = (
+ {
+ onDone?.();
+ onEnabledEncryption?.();
+ }}
+ onDisabled={() => {
+ onDone?.();
+ }}
+ />
+ );
+
+ return enterKey;
+ }
+
+ // Show the key if it exists
+ if (workspaceMeta.encryptionKey && key.key != null) {
+ const keyRevealer = (
+
+ );
+
+ const content = (
+
+ {justEnabledEncryption && (
+
+ {helpAfterEncryption}
+
+ )}
+ {keyRevealer}
+ {onDone && (
+ {
+ onDone();
+ onEnabledEncryption?.();
+ }}
+ >
+ Done
+
+ )}
+
+ );
+
+ return content;
+ }
+
+ // Show button to enable encryption
+ if (layout === "settings") {
+ return (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+
+ {
+ setError(null);
+ try {
+ await enableEncryption(workspaceMeta.workspaceId);
+ setJustEnabledEncryption(true);
+ } catch (err) {
+ setError(`Failed to enable encryption: ${String(err)}`);
+ }
+ }}
+ >
+ Enable Encryption
+
+
+ >
+ );
+ }
+
+ return (
+
+ {
+ setError(null);
+ try {
+ await enableEncryption(workspaceMeta.workspaceId);
+ setJustEnabledEncryption(true);
+ } catch (err) {
+ setError(`Failed to enable encryption: ${String(err)}`);
+ }
+ }}
+ >
+ Enable Encryption
+
+ {error && (
+
+ {error}
+
+ )}
+ {expanded ? (
+
+
+
+ ) : (
+ }>
+ Workspace encryption
+
+ )}
+
+ );
+}
+
+const setWorkspaceKeyMut = createFastMutation({
+ mutationKey: ["set-workspace-key"],
+ mutationFn: setWorkspaceKey,
+});
+
+function EnterWorkspaceKey({
+ workspaceMeta,
+ onEnabled,
+ onDisabled,
+ error,
+}: {
+ workspaceMeta: WorkspaceMeta;
+ onEnabled?: () => void;
+ onDisabled?: () => void;
+ error?: string | null;
+}) {
+ const [key, setKey] = useState("");
+
+ const handleForgotKey = async () => {
+ const confirmed = await showConfirm({
+ id: "disable-encryption",
+ title: "Disable Encryption",
+ color: "danger",
+ confirmText: "Disable Encryption",
+ description: (
+ <>
+ This will disable encryption for this workspace. Any previously encrypted values will fail
+ to decrypt and will need to be re-entered manually.
+
+
+ This action cannot be undone.
+ >
+ ),
+ });
+
+ if (confirmed) {
+ await disableEncryption(workspaceMeta.workspaceId);
+ onDisabled?.();
+ }
+ };
+
+ return (
+
+ {error ? (
+ {error}
+ ) : (
+
+ This workspace contains encrypted values but no key is configured. Please enter the
+ workspace key to access the encrypted data.
+
+ )}
+ {
+ e.preventDefault();
+ setWorkspaceKeyMut.mutate(
+ {
+ workspaceId: workspaceMeta.workspaceId,
+ key: key.trim(),
+ },
+ { onSuccess: onEnabled },
+ );
+ }}
+ >
+
+
+ Submit
+
+
+
+ Forgot your key?
+
+
+ );
+}
+
+function KeyRevealer({
+ defaultShow = false,
+ disableLabel = false,
+ encryptionKey,
+}: {
+ defaultShow?: boolean;
+ disableLabel?: boolean;
+ encryptionKey: string;
+}) {
+ const [show, setShow] = useStateWithDeps(defaultShow, [defaultShow]);
+
+ return (
+
+
+ {!disableLabel && (
+
+ Workspace encryption key{" "}
+
+
+ )}
+ {encryptionKey && }
+
+
+ {encryptionKey && }
+ setShow((v) => !v)}
+ />
+
+
+ );
+}
+
+function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
+ return (
+
+ {show ? (
+ keyText.split("").map((c, i) => {
+ return (
+
+ {c}
+
+ );
+ })
+ ) : (
+ •••••••••••••••••••••
+ )}
+
+ );
+}
+
+const helpAfterEncryption = (
+
+ The following key is used for encryption operations within this workspace. It is stored securely
+ using your OS keychain, but it is recommended to back it up. If you share this workspace with
+ others, you'll need to send them this key to access any encrypted values.
+
+);
diff --git a/apps/yaak-client/components/WorkspaceHeader.tsx b/apps/yaak-client/components/WorkspaceHeader.tsx
new file mode 100644
index 0000000..aefe5f4
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceHeader.tsx
@@ -0,0 +1,94 @@
+import { HStack, Icon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { useAtom, useAtomValue } from "jotai";
+import { memo } from "react";
+import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
+import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
+import { workspaceLayoutAtom } from "../lib/atoms";
+import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
+import { CookieDropdown } from "./CookieDropdown";
+import { IconButton } from "./core/IconButton";
+import { PillButton } from "./core/PillButton";
+import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
+import { ImportCurl } from "./ImportCurl";
+import { LicenseBadge } from "./LicenseBadge";
+import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
+import { SettingsDropdown } from "./SettingsDropdown";
+import { SidebarActions } from "./SidebarActions";
+import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
+
+interface Props {
+ className?: string;
+ floatingSidebar?: boolean;
+}
+
+export const WorkspaceHeader = memo(function WorkspaceHeader({
+ className,
+ floatingSidebar,
+}: Props) {
+ const togglePalette = useToggleCommandPalette();
+ const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
+ const workspace = useAtomValue(activeWorkspaceAtom);
+ const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
+ const showEncryptionSetup =
+ workspace != null &&
+ workspaceMeta != null &&
+ workspace.encryptionKeyChallenge != null &&
+ workspaceMeta.encryptionKey == null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showEncryptionSetup ? (
+
+ Enter Encryption Key
+
+ ) : (
+
+ )}
+
+ setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
+ }
+ />
+
+
+
+
+ );
+});
diff --git a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx
new file mode 100644
index 0000000..56d98a8
--- /dev/null
+++ b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx
@@ -0,0 +1,191 @@
+import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
+import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
+import { useAtomValue } from "jotai";
+import { useAuthTab } from "../hooks/useAuthTab";
+import { useHeadersTab } from "../hooks/useHeadersTab";
+import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
+import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
+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";
+import { DnsOverridesEditor } from "./DnsOverridesEditor";
+import { HeadersEditor } from "./HeadersEditor";
+import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
+import { MarkdownEditor } from "./MarkdownEditor";
+import { ModelSettingsEditor } from "./ModelSettingsEditor";
+import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
+import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
+
+interface Props {
+ workspaceId: string;
+ hide: () => void;
+ tab?: WorkspaceSettingsTab;
+}
+
+const TAB_AUTH = "auth";
+const TAB_DNS = "dns";
+const TAB_HEADERS = "headers";
+const TAB_GENERAL = "general";
+const TAB_SETTINGS = "settings";
+
+export type WorkspaceSettingsTab =
+ | typeof TAB_AUTH
+ | typeof TAB_DNS
+ | typeof TAB_HEADERS
+ | typeof TAB_GENERAL
+ | typeof TAB_SETTINGS;
+
+const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
+
+export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
+ const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
+ const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
+ const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
+ const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
+ const inheritedHeaders = useInheritedHeaders(workspace ?? null);
+
+ if (workspace == null) {
+ return (
+
+ Workspace not found
+
+ );
+ }
+
+ if (workspaceMeta == null)
+ return (
+
+ WorkspaceMeta not found for workspace
+
+ );
+
+ return (
+ 0 ? (
+
+ ) : null,
+ },
+ ]}
+ storageKey="workspace_settings_tabs"
+ >
+
+
+
+
+ patchModel(workspace, { headers })}
+ stateKey={`headers.${workspace.id}`}
+ />
+
+
+
+
+ patchModel(workspaceMeta, { settingSyncDir: filePath })}
+ />
+
+
+
+
+
+
+
+
+
+
patchModel(workspace, { name })}
+ />
+
+ patchModel(workspace, { description })}
+ heightMode="auto"
+ />
+
+
+ {
+ const didDelete = await deleteModelWithConfirm(workspace, {
+ confirmName: workspace.name,
+ });
+ if (didDelete) {
+ hide(); // Only hide if actually deleted workspace
+ await router.navigate({ to: "/" });
+ }
+ }}
+ color="danger"
+ variant="border"
+ size="xs"
+ >
+ Delete Workspace
+
+
+ {workspaceId}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
+ showDialog({
+ id: "workspace-settings",
+ size: "lg",
+ className: "h-[calc(100vh-5rem)] max-h-200!",
+ noPadding: true,
+ render: ({ hide }) => (
+
+ ),
+ });
+};
diff --git a/apps/yaak-client/components/core/Alert.tsx b/apps/yaak-client/components/core/Alert.tsx
new file mode 100644
index 0000000..8315e40
--- /dev/null
+++ b/apps/yaak-client/components/core/Alert.tsx
@@ -0,0 +1,21 @@
+import { HStack, VStack } from "@yaakapp-internal/ui";
+import type { ReactNode } from "react";
+import { Button } from "./Button";
+
+export interface AlertProps {
+ onHide: () => void;
+ body: ReactNode;
+}
+
+export function Alert({ onHide, body }: AlertProps) {
+ return (
+
+ {body}
+
+
+ Okay
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/AutoScroller.tsx b/apps/yaak-client/components/core/AutoScroller.tsx
new file mode 100644
index 0000000..7675b79
--- /dev/null
+++ b/apps/yaak-client/components/core/AutoScroller.tsx
@@ -0,0 +1,118 @@
+import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
+import type { ReactElement, ReactNode, UIEvent } from "react";
+import { useCallback, useLayoutEffect, useRef, useState } from "react";
+import { IconButton } from "./IconButton";
+
+interface Props {
+ data: T[];
+ render: (item: T, index: number) => ReactElement;
+ header?: ReactNode;
+ /** Make container focusable for keyboard navigation */
+ focusable?: boolean;
+ /** Callback to expose the virtualizer for keyboard navigation */
+ onVirtualizerReady?: (virtualizer: Virtualizer) => void;
+}
+
+export function AutoScroller({
+ data,
+ render,
+ header,
+ focusable = false,
+ onVirtualizerReady,
+}: Props) {
+ const containerRef = useRef(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+
+ // The virtualizer
+ const rowVirtualizer = useVirtualizer({
+ count: data.length,
+ getScrollElement: () => containerRef.current,
+ estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
+ });
+
+ // Expose virtualizer to parent for keyboard navigation
+ useLayoutEffect(() => {
+ onVirtualizerReady?.(rowVirtualizer);
+ }, [rowVirtualizer, onVirtualizerReady]);
+
+ // Scroll to new items
+ const handleScroll = useCallback(
+ (e: UIEvent) => {
+ const el = e.currentTarget;
+
+ // Set auto-scroll when container is scrolled
+ const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
+ const newAutoScroll = pixelsFromBottom <= 0;
+ if (newAutoScroll !== autoScroll) {
+ setAutoScroll(newAutoScroll);
+ }
+ },
+ [autoScroll],
+ );
+
+ // Scroll to bottom on count change
+ useLayoutEffect(() => {
+ if (!autoScroll) return;
+
+ void data.length; // Trigger refresh when length changes
+
+ const el = containerRef.current;
+ if (el == null) return;
+
+ el.scrollTop = el.scrollHeight;
+ }, [autoScroll, data.length]);
+
+ return (
+
+ {!autoScroll && (
+
+ setAutoScroll((v) => !v)}
+ />
+
+ )}
+ {header ??
}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => {
+ const item = data[virtualItem.index];
+ return (
+ item != null && (
+
+ {render(item, virtualItem.index)}
+
+ )
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/BulkPairEditor.test.ts b/apps/yaak-client/components/core/BulkPairEditor.test.ts
new file mode 100644
index 0000000..b059980
--- /dev/null
+++ b/apps/yaak-client/components/core/BulkPairEditor.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parseBulkPairLine } from "./BulkPairEditor";
+
+describe("parseBulkPairLine", () => {
+ test("parses colon-space pairs as name and value", () => {
+ expect(parseBulkPairLine("foo: bar")).toMatchObject({
+ enabled: true,
+ name: "foo",
+ value: "bar",
+ });
+ });
+
+ test("preserves colon-without-space lines as a name with an empty value", () => {
+ expect(parseBulkPairLine("foo:bar")).toMatchObject({
+ enabled: true,
+ name: "foo:bar",
+ value: "",
+ });
+ });
+
+ test("preserves malformed lines instead of dropping their contents", () => {
+ expect(parseBulkPairLine("not a pair")).toMatchObject({
+ enabled: true,
+ name: "not a pair",
+ value: "",
+ });
+ });
+
+ test("unescapes newlines in parsed values", () => {
+ expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
+ enabled: true,
+ name: "foo",
+ value: "bar\nbaz",
+ });
+ });
+});
diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/apps/yaak-client/components/core/BulkPairEditor.tsx
new file mode 100644
index 0000000..fe3b4bd
--- /dev/null
+++ b/apps/yaak-client/components/core/BulkPairEditor.tsx
@@ -0,0 +1,63 @@
+import { useCallback, useMemo } from "react";
+import { generateId } from "../../lib/generateId";
+import { Editor } from "./Editor/LazyEditor";
+import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
+
+type Props = PairEditorProps;
+
+export function BulkPairEditor({
+ pairs,
+ onChange,
+ namePlaceholder,
+ valuePlaceholder,
+ forceUpdateKey,
+ forcedEnvironmentId,
+ stateKey,
+}: Props) {
+ const pairsText = useMemo(() => {
+ return pairs
+ .filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
+ .map(formatBulkPairLine)
+ .join("\n");
+ }, [pairs]);
+
+ const handleChange = useCallback(
+ (text: string) => {
+ const pairs = text
+ .split("\n")
+ .filter((l: string) => l.trim())
+ .map(parseBulkPairLine);
+ onChange(pairs);
+ },
+ [onChange],
+ );
+
+ return (
+
+ );
+}
+
+export function formatBulkPairLine(pair: Pair) {
+ const value = pair.value.replaceAll("\n", "\\n");
+ return `${pair.name}: ${value}`;
+}
+
+export function parseBulkPairLine(line: string): PairWithId {
+ const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
+ return {
+ enabled: true,
+ name: (name ?? line).trim(),
+ value: (value ?? "").replaceAll("\\n", "\n").trim(),
+ id: generateId(),
+ };
+}
diff --git a/apps/yaak-client/components/core/Button.tsx b/apps/yaak-client/components/core/Button.tsx
new file mode 100644
index 0000000..ec389e3
--- /dev/null
+++ b/apps/yaak-client/components/core/Button.tsx
@@ -0,0 +1,34 @@
+import { Button as BaseButton, type ButtonProps as BaseButtonProps } from "@yaakapp-internal/ui";
+import { forwardRef, useImperativeHandle, useRef } from "react";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
+
+export type ButtonProps = BaseButtonProps & {
+ hotkeyAction?: HotkeyAction;
+ hotkeyLabelOnly?: boolean;
+ hotkeyPriority?: number;
+};
+
+export const Button = forwardRef(function Button(
+ { hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: ButtonProps,
+ ref,
+) {
+ const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
+ const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
+
+ const buttonRef = useRef(null);
+ useImperativeHandle(
+ ref,
+ () => buttonRef.current,
+ );
+
+ useHotKey(
+ hotkeyAction ?? null,
+ () => {
+ buttonRef.current?.click();
+ },
+ { priority: hotkeyPriority, enable: !hotkeyLabelOnly },
+ );
+
+ return ;
+});
diff --git a/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx b/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
new file mode 100644
index 0000000..6ea1130
--- /dev/null
+++ b/apps/yaak-client/components/core/ButtonInfiniteLoading.tsx
@@ -0,0 +1,25 @@
+import { useState } from "react";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+
+export function ButtonInfiniteLoading({
+ onClick,
+ isLoading,
+ loadingChildren,
+ children,
+ ...props
+}: ButtonProps & { loadingChildren?: string }) {
+ const [localIsLoading, setLocalIsLoading] = useState(false);
+ return (
+ {
+ setLocalIsLoading(true);
+ onClick?.(e);
+ }}
+ {...props}
+ >
+ {localIsLoading ? (loadingChildren ?? children) : children}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Checkbox.tsx b/apps/yaak-client/components/core/Checkbox.tsx
new file mode 100644
index 0000000..92b1942
--- /dev/null
+++ b/apps/yaak-client/components/core/Checkbox.tsx
@@ -0,0 +1,73 @@
+import { HStack, Icon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { IconTooltip } from "./IconTooltip";
+
+export interface CheckboxProps {
+ checked: boolean | "indeterminate";
+ title: ReactNode;
+ onChange: (checked: boolean) => void;
+ className?: string;
+ disabled?: boolean;
+ inputWrapperClassName?: string;
+ hideLabel?: boolean;
+ fullWidth?: boolean;
+ help?: ReactNode;
+ size?: "sm" | "md";
+}
+
+export function Checkbox({
+ checked,
+ onChange,
+ className,
+ inputWrapperClassName,
+ disabled,
+ title,
+ hideLabel,
+ fullWidth,
+ help,
+ size = "sm",
+}: CheckboxProps) {
+ return (
+
+
+
{
+ onChange(checked === "indeterminate" ? true : !checked);
+ }}
+ />
+
+
+
+
+ {!hideLabel && (
+
+ {title}
+
+ )}
+ {help && }
+
+ );
+}
diff --git a/apps/yaak-client/components/core/CheckboxTree.tsx b/apps/yaak-client/components/core/CheckboxTree.tsx
new file mode 100644
index 0000000..d6d4423
--- /dev/null
+++ b/apps/yaak-client/components/core/CheckboxTree.tsx
@@ -0,0 +1,114 @@
+import { Icon } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useState } from "react";
+import type { CheckboxProps } from "./Checkbox";
+import { Checkbox } from "./Checkbox";
+
+export interface CheckboxTreeNode {
+ key: string;
+ data: T;
+ children: CheckboxTreeNode[];
+}
+
+interface Props {
+ node: CheckboxTreeNode;
+ depth?: number;
+ /** Return "hidden" to render row alignment space instead of a checkbox */
+ checked: (node: CheckboxTreeNode) => CheckboxProps["checked"] | "hidden";
+ onCheck: (node: CheckboxTreeNode, checked: boolean) => void;
+ checkboxTitle?: (node: CheckboxTreeNode) => string;
+ isCheckboxDisabled?: (node: CheckboxTreeNode) => boolean;
+ /** An irrelevant row is hidden unless one of its descendants is relevant */
+ isRelevant: (node: CheckboxTreeNode) => boolean;
+ /** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
+ isCollapsedByDefault?: (node: CheckboxTreeNode) => boolean;
+ renderRow: (node: CheckboxTreeNode) => ReactNode;
+ onSelectRow?: (node: CheckboxTreeNode) => void;
+ canSelectRow?: (node: CheckboxTreeNode) => boolean;
+ isRowSelected?: (node: CheckboxTreeNode) => boolean;
+}
+
+export function CheckboxTree(props: Props) {
+ const { node, depth = 0 } = props;
+ const [collapsed, setCollapsed] = useState(
+ () => props.isCollapsedByDefault?.(node) ?? false,
+ );
+ if (!hasRelevantNode(node, props.isRelevant)) return null;
+
+ const checked = props.checked(node);
+ const selected = props.isRowSelected?.(node) ?? false;
+ const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
+ const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
+ const rowContent = (
+
+ {props.renderRow(node)}
+
+ );
+
+ return (
+ 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
+ )}
+ >
+
+ {selected && (
+
+ )}
+ {hasVisibleChildren ? (
+
setCollapsed((v) => !v)}
+ >
+
+
+ ) : (
+
+ )}
+ {checked === "hidden" ? (
+
+ ) : (
+
props.onCheck(node, checked)}
+ />
+ )}
+ {selectable ? (
+ props.onSelectRow?.(node)}
+ >
+ {rowContent}
+
+ ) : (
+ rowContent
+ )}
+
+
+ {!collapsed &&
+ node.children.map((child) => (
+
+ ))}
+
+ );
+}
+
+function hasRelevantNode(
+ node: CheckboxTreeNode,
+ isRelevant: (node: CheckboxTreeNode) => boolean,
+): boolean {
+ return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
+}
diff --git a/apps/yaak-client/components/core/ColorPicker.tsx b/apps/yaak-client/components/core/ColorPicker.tsx
new file mode 100644
index 0000000..6b2f217
--- /dev/null
+++ b/apps/yaak-client/components/core/ColorPicker.tsx
@@ -0,0 +1,117 @@
+import classNames from "classnames";
+import { useState } from "react";
+import { HexColorPicker } from "react-colorful";
+import { useRandomKey } from "../../hooks/useRandomKey";
+import { Icon } from "@yaakapp-internal/ui";
+import { PlainInput } from "./PlainInput";
+
+interface Props {
+ onChange: (value: string | null) => void;
+ color: string | null;
+ className?: string;
+}
+
+export function ColorPicker({ onChange, color, className }: Props) {
+ const [updateKey, regenerateKey] = useRandomKey();
+ return (
+
+
{
+ onChange(color);
+ regenerateKey(); // To force input to change
+ }}
+ />
+ color.match(/#[0-9a-fA-F]{6}$/) !== null}
+ />
+
+ );
+}
+
+const colors = [
+ null,
+ "danger",
+ "warning",
+ "notice",
+ "success",
+ "primary",
+ "info",
+ "secondary",
+ "custom",
+] as const;
+
+export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
+ const [updateKey, regenerateKey] = useRandomKey();
+ const [selectedColor, setSelectedColor] = useState(() => {
+ if (color == null) return null;
+ const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
+ return c ?? "custom";
+ });
+ return (
+
+
+ {colors.map((color) => (
+ {
+ setSelectedColor(color);
+ if (color == null) {
+ onChange(null);
+ } else if (color === "custom") {
+ onChange("#ffffff");
+ } else {
+ onChange(`var(--${color})`);
+ }
+ }}
+ className={classNames(
+ "flex items-center justify-center",
+ "w-8 h-8 rounded-full transition-all",
+ selectedColor === color && "scale-[1.15]",
+ selectedColor === color ? "opacity-100" : "opacity-60",
+ color === null && "border border-text-subtle",
+ color === "primary" && "bg-primary",
+ color === "secondary" && "bg-secondary",
+ color === "success" && "bg-success",
+ color === "notice" && "bg-notice",
+ color === "warning" && "bg-warning",
+ color === "danger" && "bg-danger",
+ color === "info" && "bg-info",
+ color === "custom" &&
+ "bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]",
+ )}
+ >
+ {color == null && }
+
+ ))}
+
+ {selectedColor === "custom" && (
+ <>
+
{
+ onChange(color);
+ regenerateKey(); // To force input to change
+ }}
+ />
+ color.match(/#[0-9a-fA-F]{6}$/) !== null}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Confirm.tsx b/apps/yaak-client/components/core/Confirm.tsx
new file mode 100644
index 0000000..dac8dc9
--- /dev/null
+++ b/apps/yaak-client/components/core/Confirm.tsx
@@ -0,0 +1,74 @@
+import type { Color } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import type { FormEvent } from "react";
+import { useState } from "react";
+import { CopyIconButton } from "../CopyIconButton";
+import { Button } from "./Button";
+import { PlainInput } from "./PlainInput";
+
+export interface ConfirmProps {
+ onHide: () => void;
+ onResult: (result: boolean) => void;
+ confirmText?: string;
+ requireTyping?: string;
+ color?: Color;
+}
+
+export function Confirm({
+ onHide,
+ onResult,
+ confirmText,
+ requireTyping,
+ color = "primary",
+}: ConfirmProps) {
+ const [confirm, setConfirm] = useState("");
+ const handleHide = () => {
+ onResult(false);
+ onHide();
+ };
+
+ const didConfirm = !requireTyping || confirm === requireTyping;
+
+ const handleSuccess = (e: FormEvent) => {
+ e.preventDefault();
+ if (didConfirm) {
+ onResult(true);
+ onHide();
+ }
+ };
+
+ return (
+
+ {requireTyping && (
+
+ }
+ label={
+ <>
+ Type {requireTyping} to confirm
+ >
+ }
+ />
+ )}
+
+
+ {confirmText ?? "Confirm"}
+
+
+ Cancel
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/CountBadge.tsx b/apps/yaak-client/components/core/CountBadge.tsx
new file mode 100644
index 0000000..5b2fad7
--- /dev/null
+++ b/apps/yaak-client/components/core/CountBadge.tsx
@@ -0,0 +1,48 @@
+import type { Color } from "@yaakapp-internal/plugins";
+import classNames from "classnames";
+
+interface Props {
+ count: number | true;
+ count2?: number | true;
+ className?: string;
+ color?: Color;
+ showZero?: boolean;
+}
+
+export function CountBadge({ count, count2, className, color, showZero }: Props) {
+ if (count === 0 && !showZero) return null;
+
+ return (
+
+ {count === true ? (
+
+ ) : (
+ count
+ )}
+ {count2 != null && (
+ <>
+ /
+ {count2 === true ? (
+
+ ) : (
+ count2
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/DetailsBanner.tsx b/apps/yaak-client/components/core/DetailsBanner.tsx
new file mode 100644
index 0000000..1cc49f7
--- /dev/null
+++ b/apps/yaak-client/components/core/DetailsBanner.tsx
@@ -0,0 +1,60 @@
+import classNames from "classnames";
+import { atom, useAtom } from "jotai";
+import type { HTMLAttributes, ReactNode } from "react";
+import { 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({
+ 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(
+ () =>
+ 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}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx
new file mode 100644
index 0000000..c7962a1
--- /dev/null
+++ b/apps/yaak-client/components/core/Dialog.tsx
@@ -0,0 +1,131 @@
+import type { DialogSize } from "@yaakapp-internal/plugins";
+import { Heading, Overlay } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import * as m from "motion/react-m";
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import { IconButton } from "./IconButton";
+
+export interface DialogProps {
+ children: ReactNode;
+ open: boolean;
+ onClose?: () => void;
+ /** Block dismissal from the backdrop, Escape key, and built-in close button. */
+ disableClose?: boolean;
+ title?: ReactNode;
+ description?: ReactNode;
+ className?: string;
+ size?: DialogSize;
+ /** Hide the built-in close button without changing backdrop or Escape behavior. */
+ hideX?: boolean;
+ noPadding?: boolean;
+ noScroll?: boolean;
+ vAlign?: "top" | "center";
+}
+
+export function Dialog({
+ children,
+ className,
+ size = "full",
+ open,
+ onClose,
+ disableClose,
+ title,
+ description,
+ hideX,
+ noPadding,
+ noScroll,
+ vAlign = "center",
+}: DialogProps) {
+ const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
+ const descriptionId = useMemo(
+ () => (description ? Math.random().toString(36).slice(2) : undefined),
+ [description],
+ );
+
+ return (
+
+ {
+ // NOTE: We handle Escape on the element itself so that it doesn't close multiple
+ // dialogs and can be intercepted by children if needed.
+ if (e.key === "Escape") {
+ if (!disableClose) onClose?.();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
+ >
+
+ {title ? (
+
+ {title}
+
+ ) : (
+
+ )}
+
+ {description ? (
+
+ {description}
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+ {/*Put close at the end so that it's the last thing to be tabbed to*/}
+ {!disableClose && !hideX && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/DismissibleBanner.tsx b/apps/yaak-client/components/core/DismissibleBanner.tsx
new file mode 100644
index 0000000..f2d1871
--- /dev/null
+++ b/apps/yaak-client/components/core/DismissibleBanner.tsx
@@ -0,0 +1,111 @@
+import type { Color } from "@yaakapp-internal/plugins";
+import type { BannerProps } from "@yaakapp-internal/ui";
+import { Banner } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { MouseEvent } from "react";
+import { useEffect } from "react";
+import { useKeyValue } from "../../hooks/useKeyValue";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+
+type DismissibleBannerSize = "sm" | "xs";
+
+export function DismissibleBanner({
+ children,
+ className,
+ id,
+ size = "sm",
+ onDismiss,
+ onShow,
+ actions,
+ ...props
+}: BannerProps & {
+ id: string;
+ size?: DismissibleBannerSize;
+ onDismiss?: () => void | Promise;
+ onShow?: () => void | Promise;
+ actions?: {
+ label: string;
+ onClick: () => void;
+ color?: Color;
+ variant?: ButtonProps["variant"];
+ }[];
+}) {
+ const {
+ isLoading,
+ set: setDismissed,
+ value: dismissed,
+ } = useKeyValue({
+ namespace: "global",
+ key: ["dismiss-banner", id],
+ fallback: false,
+ });
+
+ const shouldShow = !isLoading && !dismissed;
+
+ useEffect(() => {
+ if (shouldShow) {
+ Promise.resolve(onShow?.()).catch(console.error);
+ }
+ }, [onShow, shouldShow]);
+
+ if (!shouldShow) return null;
+
+ const actionSize: ButtonProps["size"] = size === "xs" ? "2xs" : "xs";
+ const stopParentClick = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ return (
+
+
+
+ {children}
+
+ {
+ stopParentClick(event);
+ setDismissed(true).catch(console.error);
+ Promise.resolve(onDismiss?.()).catch(console.error);
+ }}
+ title="Dismiss message"
+ >
+ Dismiss
+
+ {actions?.map((a) => (
+ {
+ stopParentClick(event);
+ a.onClick();
+ }}
+ title={a.label}
+ >
+ {a.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx
new file mode 100644
index 0000000..8b3b94e
--- /dev/null
+++ b/apps/yaak-client/components/core/Dropdown.tsx
@@ -0,0 +1,1069 @@
+import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { atom } from "jotai";
+import * as m from "motion/react-m";
+import type {
+ CSSProperties,
+ HTMLAttributes,
+ MouseEvent,
+ ReactElement,
+ FocusEvent as ReactFocusEvent,
+ KeyboardEvent as ReactKeyboardEvent,
+ ReactNode,
+ RefObject,
+ SetStateAction,
+} from "react";
+import {
+ Children,
+ cloneElement,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+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";
+import { ErrorBoundary } from "../ErrorBoundary";
+import { Button } from "./Button";
+import { Hotkey } from "./Hotkey";
+import { IconButton } from "./IconButton";
+import type { SeparatorAction } from "./Separator";
+import { Separator } from "./Separator";
+
+export type DropdownItemSeparator = {
+ type: "separator";
+ label?: ReactNode;
+ hidden?: boolean;
+ /** A control shown beside the label, eg. revealing the labelled file on disk. */
+ action?: SeparatorAction;
+};
+
+export type DropdownItemContent = {
+ type: "content";
+ label?: ReactNode;
+ hidden?: boolean;
+};
+
+export type DropdownItemDefault = {
+ type?: "default";
+ label: ReactNode;
+ hotKeyAction?: HotkeyAction;
+ hotKeyLabelOnly?: boolean;
+ color?: "default" | "primary" | "danger" | "info" | "warning" | "notice" | "success";
+ disabled?: boolean;
+ hidden?: boolean;
+ leftSlot?: ReactNode;
+ rightSlot?: ReactNode;
+ waitForOnSelect?: boolean;
+ keepOpenOnSelect?: boolean;
+ onSelect?: () => void | Promise;
+ submenu?: DropdownItem[];
+ /** If true, submenu opens on click instead of hover */
+ submenuOpenOnClick?: boolean;
+ /**
+ * How the submenu opens. "row" (default) opens it from the row itself (hover, or click
+ * with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
+ * a dedicated button on the right that opens the submenu.
+ */
+ submenuTrigger?: "row" | "button";
+ icon?: IconProps["icon"];
+};
+
+export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
+
+export interface DropdownProps {
+ children: ReactElement>;
+ items: DropdownItem[];
+ fullWidth?: boolean;
+ hotKeyAction?: HotkeyAction;
+ onOpen?: () => void;
+}
+
+export interface DropdownRef {
+ isOpen: boolean;
+ open: (index?: number) => void;
+ toggle: () => void;
+ close?: () => void;
+ next?: (incrBy?: number) => void;
+ prev?: (incrBy?: number) => void;
+ select?: () => void;
+}
+
+// Every dropdown gets a unique ID and we use this global atom to ensure
+// only one dropdown can be open at a time.
+// TODO: Also make ContextMenu use this
+const openAtom = atom(null);
+
+export const Dropdown = forwardRef(function Dropdown(
+ { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,
+ ref,
+) {
+ const id = useRef(generateId());
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ return jotaiStore.sub(openAtom, () => {
+ const globalOpenId = jotaiStore.get(openAtom);
+ const newIsOpen = globalOpenId === id.current;
+ if (newIsOpen !== isOpen) {
+ setIsOpen(newIsOpen);
+ }
+ });
+ }, [isOpen]);
+
+ // const [isOpen, _setIsOpen] = useState(false);
+ const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null);
+ const buttonRef = useRef(null);
+ const menuRef = useRef>(null);
+
+ const handleSetIsOpen = useCallback(
+ (o: SetStateAction) => {
+ jotaiStore.set(openAtom, (prevId) => {
+ const prevIsOpen = prevId === id.current;
+ const newIsOpen = typeof o === "function" ? o(prevIsOpen) : o;
+ // Persist background color of button until we close the dropdown
+ if (newIsOpen) {
+ onOpen?.();
+ if (buttonRef.current) {
+ buttonRef.current.style.backgroundColor = window
+ .getComputedStyle(buttonRef.current)
+ .getPropertyValue("background-color");
+ }
+ }
+ return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
+ });
+ },
+ [onOpen],
+ );
+
+ // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
+ // we have of detecting the dropdown closed, to do cleanup.
+ useEffect(() => {
+ if (!isOpen) {
+ // Clear persisted BG
+ if (buttonRef.current) buttonRef.current.style.backgroundColor = "";
+ // Set to different value when opened and closed to force it to update. This is to force
+ // to reset its selected-index state, which it does when this prop changes
+ setDefaultSelectedIndex(null);
+ }
+ }, [isOpen]);
+
+ // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
+ // the ref will not update when menuRef updates, causing stale callback state to be used.
+ const menuRefCurrent = menuRef.current;
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ ...menuRefCurrent,
+ isOpen: isOpen,
+ toggle() {
+ if (!isOpen) this.open();
+ else this.close();
+ },
+ open(index?: number) {
+ handleSetIsOpen(true);
+ setDefaultSelectedIndex(index ?? -1);
+ },
+ close() {
+ handleSetIsOpen(false);
+ },
+ }),
+ [isOpen, handleSetIsOpen, menuRefCurrent],
+ );
+
+ useHotKey(hotKeyAction ?? null, () => {
+ setDefaultSelectedIndex(0);
+ handleSetIsOpen(true);
+ });
+
+ const child = useMemo(() => {
+ const existingChild = Children.only(children);
+ const originalOnClick = existingChild.props?.onClick;
+ const props: HTMLAttributes & { ref: RefObject } =
+ {
+ ...existingChild.props,
+ ref: buttonRef,
+ "aria-haspopup": "true",
+ onClick: (e: MouseEvent) => {
+ // Call original onClick first if it exists
+ originalOnClick?.(e);
+
+ // Only toggle dropdown if event wasn't prevented
+ if (!e.defaultPrevented) {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSetIsOpen((o) => !o); // Toggle dropdown
+ }
+ },
+ };
+ return cloneElement(existingChild, props);
+ }, [children, handleSetIsOpen]);
+
+ useEffect(() => {
+ buttonRef.current?.setAttribute("aria-expanded", isOpen.toString());
+ }, [isOpen]);
+
+ const windowSize = useWindowSize();
+ const triggerRect = useMemo(() => {
+ if (!windowSize) return null; // No-op to TS happy with this dep
+ if (!isOpen) return null;
+ return buttonRef.current?.getBoundingClientRect();
+ }, [isOpen, windowSize]);
+
+ return (
+ <>
+ {child}
+
+ handleSetIsOpen(false)}
+ isOpen={isOpen}
+ />
+
+ >
+ );
+});
+
+export interface ContextMenuProps {
+ triggerPosition: { x: number; y: number } | null;
+ /**
+ * The box the menu belongs to, when the trigger has one.
+ *
+ * Placement aligns to the trigger's left or right edge depending on the room beside it, so a
+ * menu opened from an element wants its real rect. Without one the position is treated as a
+ * zero-width point, which is right for a menu opened at the cursor and wrong for one opened
+ * from a button.
+ */
+ triggerRect?: Pick;
+ /**
+ * The element the menu belongs to, so a click on it doesn't count as a click outside.
+ *
+ * Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
+ * then the trigger's own click opens it again, and pressing it looks like it does nothing.
+ */
+ triggerRef?: RefObject;
+ className?: string;
+ items: DropdownProps["items"];
+ onClose: () => void;
+}
+
+export const ContextMenu = forwardRef(function ContextMenu(
+ { triggerPosition, triggerRect, triggerRef, className, items, onClose },
+ ref,
+) {
+ const triggerShape = useMemo(
+ () =>
+ triggerRect ?? {
+ top: triggerPosition?.y ?? 0,
+ bottom: triggerPosition?.y ?? 0,
+ left: triggerPosition?.x ?? 0,
+ right: triggerPosition?.x ?? 0,
+ },
+ [triggerPosition, triggerRect],
+ );
+
+ if (triggerPosition == null) return null;
+
+ return (
+
+ );
+});
+
+interface MenuProps {
+ className?: string;
+ defaultSelectedIndex: number | null;
+ triggerShape: Pick | null;
+ onClose: () => void;
+ onCloseAll?: () => void;
+ showTriangle?: boolean;
+ fullWidth?: boolean;
+ isOpen: boolean;
+ items: DropdownItem[];
+ // Any element, not just a button: a menu can be opened from anything, and this is only ever
+ // used to ask whether a click landed on the trigger
+ triggerRef?: RefObject;
+ isSubmenu?: boolean;
+}
+
+const Menu = forwardRef, MenuProps>(
+ (
+ {
+ className,
+ isOpen,
+ items,
+ fullWidth,
+ onClose,
+ onCloseAll,
+ triggerShape,
+ defaultSelectedIndex,
+ showTriangle,
+ triggerRef,
+ isSubmenu,
+ }: MenuProps,
+ ref,
+ ) => {
+ const [selectedIndex, setSelectedIndex] = useStateWithDeps(
+ defaultSelectedIndex ?? -1,
+ [defaultSelectedIndex],
+ );
+
+ const [filter, setFilter] = useState("");
+
+ // Clear filter when menu opens
+ useEffect(() => {
+ if (isOpen) {
+ setFilter("");
+ }
+ }, [isOpen]);
+
+ const [activeSubmenu, setActiveSubmenu] = useState<{
+ item: DropdownItemDefault;
+ parent: HTMLButtonElement;
+ viaKeyboard?: boolean;
+ } | null>(null);
+
+ const mousePosition = useRef({ x: 0, y: 0 });
+ const submenuTimeoutRef = useRef(null);
+ const submenuRef = useRef(null);
+
+ // HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
+ // have access to the latest value.
+ const selectedIndexRef = useRef(selectedIndex);
+ useEffect(() => {
+ selectedIndexRef.current = selectedIndex;
+ }, [selectedIndex]);
+
+ const handleClose = useCallback(() => {
+ onClose();
+ setActiveSubmenu(null);
+ }, [onClose]);
+
+ // Close the entire menu hierarchy (used when selecting an item)
+ const handleCloseAll = useCallback(() => {
+ if (onCloseAll) {
+ onCloseAll();
+ } else {
+ handleClose();
+ }
+ }, [onCloseAll, handleClose]);
+
+ // Handle type-ahead filtering (only for the deepest open menu)
+ const handleMenuKeyDown = (e: ReactKeyboardEvent) => {
+ // Skip if this menu has a submenu open - let the submenu handle typing
+ if (activeSubmenu) return;
+
+ const isCharacter = e.key.length === 1;
+ const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
+ if (isCharacter && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f + e.key);
+ setSelectedIndex(0);
+ } else if (e.key === "Backspace" && !isSpecial) {
+ e.preventDefault();
+ setFilter((f) => f.slice(0, -1));
+ }
+ };
+
+ useKey(
+ "Escape",
+ () => {
+ if (!isOpen) return;
+ if (activeSubmenu) setActiveSubmenu(null);
+ else if (filter !== "") setFilter("");
+ else handleClose();
+ },
+ {},
+ [isOpen, filter, setFilter, handleClose, activeSubmenu],
+ );
+
+ const handlePrev = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? 0) - incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex--;
+ } else if (nextIndex < 0) {
+ nextIndex = items.length - 1;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ const handleNext = useCallback(
+ (incrBy = 1) => {
+ setSelectedIndex((currIndex) => {
+ let nextIndex = (currIndex ?? -1) + incrBy;
+ const maxTries = items.length;
+ for (let i = 0; i < maxTries; i++) {
+ if (items[nextIndex]?.hidden || items[nextIndex]?.type === "separator") {
+ nextIndex++;
+ } else if (nextIndex >= items.length) {
+ nextIndex = 0;
+ } else {
+ break;
+ }
+ }
+ return nextIndex;
+ });
+ },
+ [items, setSelectedIndex],
+ );
+
+ // Ensure selection is on a valid item (not hidden/separator/content)
+ useEffect(() => {
+ const item = items[selectedIndex ?? -1];
+ if (item?.hidden || item?.type === "separator" || item?.type === "content") {
+ handleNext();
+ }
+ }, [selectedIndex, items, handleNext]);
+
+ useKey(
+ "ArrowUp",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handlePrev();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowDown",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ e.preventDefault();
+ handleNext();
+ },
+ {},
+ [isOpen, activeSubmenu],
+ );
+
+ useKey(
+ "ArrowLeft",
+ (e) => {
+ if (!isOpen) return;
+ // Only handle if this menu doesn't have an open submenu
+ // (let the deepest submenu handle the key first)
+ if (activeSubmenu) return;
+ // If this is a submenu, ArrowLeft closes it and returns to parent
+ if (isSubmenu) {
+ e.preventDefault();
+ onClose();
+ }
+ },
+ {},
+ [isOpen, isSubmenu, activeSubmenu, onClose],
+ );
+
+ const handleSelect = useCallback(
+ async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
+ // Handle click-to-open submenu
+ if ("submenu" in item && item.submenu && item.submenuOpenOnClick && parentEl) {
+ setActiveSubmenu({ item, parent: parentEl });
+ return;
+ }
+
+ if (!("onSelect" in item) || !item.onSelect) return;
+ setSelectedIndex(null);
+
+ const promise = item.onSelect();
+ if (item.waitForOnSelect) {
+ try {
+ await promise;
+ } catch {
+ // Nothing
+ }
+ }
+
+ if (!item.keepOpenOnSelect) {
+ handleCloseAll();
+ } else if (isSubmenu) {
+ // Keep the parent menu open, but close this submenu — its items may no
+ // longer describe the row after the action (e.g. Pin → Unpin, Remove)
+ handleClose();
+ }
+ },
+ [handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
+ );
+
+ useImperativeHandle(ref, () => {
+ return {
+ close: handleClose,
+ prev: handlePrev,
+ next: handleNext,
+ select: async () => {
+ const item = items[selectedIndexRef.current ?? -1] ?? null;
+ if (!item) return;
+ await handleSelect(item);
+ },
+ };
+ }, [handleClose, handleNext, handlePrev, handleSelect, items]);
+
+ const styles = useMemo<{
+ container: CSSProperties;
+ menu: CSSProperties;
+ triangle: CSSProperties;
+ upsideDown: boolean;
+ }>(() => {
+ if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
+
+ if (isSubmenu) {
+ const parentRect = triggerShape;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const spaceRight = docRect.width - parentRect.right;
+ const spaceBelow = docRect.height - parentRect.top;
+ const spaceAbove = parentRect.bottom;
+ const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
+ // Estimate submenu height (items * ~28px + padding), flip if not enough space below
+ const estimatedHeight = items.length * 28 + 20;
+ const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
+
+ return {
+ upsideDown: openUpward,
+ container: {
+ top: openUpward ? undefined : parentRect.top,
+ bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
+ left: openLeft ? undefined : parentRect.right,
+ right: openLeft ? docRect.width - parentRect.left : undefined,
+ },
+ menu: {
+ maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
+ },
+ triangle: {}, // No triangle for submenus
+ };
+ }
+
+ const menuMarginY = 5;
+ const docRect = document.documentElement.getBoundingClientRect();
+ const width = triggerShape.right - triggerShape.left;
+ const heightAbove = triggerShape.top;
+ const heightBelow = docRect.height - triggerShape.bottom;
+ const horizontalSpaceRemaining = docRect.width - triggerShape.left;
+ const top = triggerShape.bottom;
+ const onRight = horizontalSpaceRemaining < 300;
+ const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;
+ const triggerWidth = triggerShape.right - triggerShape.left;
+ return {
+ upsideDown,
+ container: {
+ top: !upsideDown ? top + menuMarginY : undefined,
+ bottom: upsideDown
+ ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY
+ : undefined,
+ right: onRight ? docRect.width - triggerShape.right : undefined,
+ left: !onRight ? triggerShape.left : undefined,
+ minWidth: fullWidth ? triggerWidth : undefined,
+ maxWidth: "40rem",
+ },
+ triangle: {
+ width: "0.4rem",
+ height: "0.4rem",
+ ...(onRight
+ ? { right: width / 2, marginRight: "-0.2rem" }
+ : { left: width / 2, marginLeft: "-0.2rem" }),
+ ...(upsideDown
+ ? { bottom: "-0.2rem", rotate: "225deg" }
+ : { top: "-0.2rem", rotate: "45deg" }),
+ },
+ menu: {
+ maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
+ },
+ };
+ }, [fullWidth, items.length, triggerShape, isSubmenu]);
+
+ const filteredItems = useMemo(
+ () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
+ [items, filter],
+ );
+
+ const handleFocus = useCallback(
+ (i: DropdownItem) => {
+ const index = filteredItems.indexOf(i) ?? null;
+ setSelectedIndex(index);
+ },
+ [filteredItems, setSelectedIndex],
+ );
+
+ useKey(
+ "ArrowRight",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (item?.type !== "separator" && item?.type !== "content" && item?.submenu) {
+ e.preventDefault();
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex],
+ );
+
+ useKey(
+ "Enter",
+ (e) => {
+ if (!isOpen || activeSubmenu) return;
+ const item = filteredItems[selectedIndex ?? -1];
+ if (!item || item.type === "separator" || item.type === "content") return;
+ e.preventDefault();
+ if (item.submenu && item.submenuTrigger !== "button") {
+ const parent = document.activeElement as HTMLButtonElement;
+ if (parent) {
+ setActiveSubmenu({ item, parent, viaKeyboard: true });
+ }
+ } else if (item.onSelect) {
+ fireAndForget(handleSelect(item));
+ }
+ },
+ {},
+ [isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
+ );
+
+ const handleItemHover = useCallback(
+ (item: DropdownItemDefault, parent: HTMLButtonElement) => {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+
+ if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
+ setActiveSubmenu({ item, parent });
+ } else if (activeSubmenu && activeSubmenu.item !== item) {
+ // Hovering the row that owns the open submenu must not dismiss it — the
+ // pointer travels across the row on its way to a button-triggered submenu
+ submenuTimeoutRef.current = window.setTimeout(() => {
+ const submenuEl = submenuRef.current;
+ if (!submenuEl || !activeSubmenu) {
+ setActiveSubmenu(null);
+ return;
+ }
+
+ const { parent } = activeSubmenu;
+ const parentRect = parent.getBoundingClientRect();
+ const submenuRect = submenuEl.getBoundingClientRect();
+ const mouse = mousePosition.current;
+
+ if (
+ mouse.x >= submenuRect.left &&
+ mouse.x <= submenuRect.right &&
+ mouse.y >= submenuRect.top &&
+ mouse.y <= submenuRect.bottom
+ ) {
+ return;
+ }
+
+ const tolerance = 5;
+ const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
+ const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
+ const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
+ const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
+
+ const inTriangle =
+ isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
+
+ if (!inTriangle) {
+ setActiveSubmenu(null);
+ }
+ }, 100);
+ }
+ },
+ [activeSubmenu],
+ );
+
+ const menuRef = useRef(null);
+ useClickOutside(menuRef, handleClose, triggerRef);
+
+ // Keep focus on menu container when filtering leaves no items
+ useEffect(() => {
+ if (filteredItems.length === 0 && filter && menuRef.current) {
+ menuRef.current.focus();
+ }
+ }, [filteredItems.length, filter]);
+
+ const submenuTriggerShape = useMemo(() => {
+ if (!activeSubmenu) return null;
+ const rect = activeSubmenu.parent.getBoundingClientRect();
+ return {
+ top: rect.top,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ };
+ }, [activeSubmenu]);
+
+ const handleMouseMove = (event: React.MouseEvent) => {
+ mousePosition.current = { x: event.clientX, y: event.clientY };
+ };
+
+ const menuContent = (
+ {
+ // Prevent showing any ancestor context menus
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ role="menu"
+ aria-orientation="vertical"
+ dir="ltr"
+ style={styles.container}
+ className={classNames(
+ className,
+ "x-theme-menu",
+ "outline-hidden my-1 pointer-events-auto z-40",
+ "fixed",
+ )}
+ >
+ {showTriangle && !isSubmenu && (
+
+ )}
+
+ {filter && (
+
+
+ {filter}
+
+ )}
+ {filteredItems.length === 0 && (
+ No matches
+ )}
+ {filteredItems.map((item, i) => {
+ if (item.hidden) {
+ return null;
+ }
+ if (item.type === "separator") {
+ return (
+
+ {item.label}
+
+ );
+ }
+ if (item.type === "content") {
+ return (
+ // oxlint-disable-next-line no-array-index-key -- index is fine
+
+ {item.label}
+
+ );
+ }
+ const isParentOfActiveSubmenu = activeSubmenu?.item === item;
+ return (
+ setActiveSubmenu({ item, parent: el })}
+ // oxlint-disable-next-line no-array-index-key -- It's fine
+ key={i}
+ item={item}
+ />
+ );
+ })}
+
+ {activeSubmenu && (
+ {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
+ }}
+ >
+
setActiveSubmenu(null)}
+ onCloseAll={handleCloseAll}
+ triggerShape={submenuTriggerShape}
+ />
+
+ )}
+
+ );
+
+ // Hotkeys must be rendered even when menu is closed (so they work globally)
+ const hotKeyElements = items.map(
+ (item, i) =>
+ item.type !== "separator" &&
+ item.type !== "content" &&
+ !item.hotKeyLabelOnly &&
+ item.hotKeyAction && (
+
+ ),
+ );
+
+ if (!isOpen) {
+ return <>{hotKeyElements}>;
+ }
+
+ if (isSubmenu) {
+ return menuContent;
+ }
+
+ return (
+ <>
+ {hotKeyElements}
+
+ {menuContent}
+
+ >
+ );
+ },
+);
+
+interface MenuItemProps {
+ className?: string;
+ item: DropdownItemDefault;
+ onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise;
+ onFocus: (item: DropdownItemDefault) => void;
+ onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
+ focused: boolean;
+ isParentOfActiveSubmenu?: boolean;
+}
+
+function MenuItem({
+ className,
+ focused,
+ onFocus,
+ onHover,
+ item,
+ onSelect,
+ onOpenSubmenu,
+ isParentOfActiveSubmenu,
+ ...props
+}: MenuItemProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const handleClick = useCallback(async () => {
+ if (item.waitForOnSelect) setIsLoading(true);
+ await onSelect?.(item, buttonRef.current ?? undefined);
+ if (item.waitForOnSelect) setIsLoading(false);
+ }, [item, onSelect]);
+
+ const handleFocus = useCallback(
+ (e: ReactFocusEvent) => {
+ e.stopPropagation(); // Don't trigger focus on any parents
+ return onFocus?.(item);
+ },
+ [item, onFocus],
+ );
+
+ const buttonRef = useRef(null);
+ const initRef = useCallback(
+ (el: HTMLButtonElement | null) => {
+ buttonRef.current = el;
+ if (el === null) return;
+ if (focused) {
+ setTimeout(() => el.focus(), 0);
+ }
+ },
+ [focused],
+ );
+
+ const handleMouseEnter = (e: MouseEvent) => {
+ onHover(item, e.currentTarget);
+ e.currentTarget.focus();
+ };
+
+ const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
+
+ const rightSlot =
+ item.submenu && !hasButtonSubmenu ? (
+
+ ) : (
+ (item.rightSlot ?? )
+ );
+
+ const button = (
+ e.currentTarget.blur()}
+ disabled={item.disabled}
+ onFocus={handleFocus}
+ onClick={handleClick}
+ justify="start"
+ leftSlot={
+ (isLoading || item.leftSlot || item.icon) && (
+
+ {isLoading ? : item.icon ? : item.leftSlot}
+
+ )
+ }
+ rightSlot={rightSlot && {rightSlot}
}
+ innerClassName="text-left!"
+ color="custom"
+ className={classNames(
+ className,
+ "h-xs", // More compact
+ "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
+ "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
+ isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
+ hasButtonSubmenu && "pr-8",
+ item.color === "danger" && "text-danger!",
+ item.color === "primary" && "text-primary!",
+ item.color === "success" && "text-success!",
+ item.color === "warning" && "text-warning!",
+ item.color === "notice" && "text-notice!",
+ item.color === "info" && "text-info!",
+ )}
+ {...props}
+ >
+ {item.label}
+
+ );
+
+ if (!hasButtonSubmenu) {
+ return button;
+ }
+
+ // The submenu trigger overlays the row as a sibling (not a child) because the row is
+ // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
+ // row keeps its focus highlight while the mouse is over the trigger.
+ return (
+ {
+ const el = buttonRef.current;
+ if (el == null) return;
+ onHover(item, el);
+ el.focus();
+ }}
+ onMouseLeave={() => buttonRef.current?.blur()}
+ >
+ {button}
+
+ {
+ // Prevent the trigger from stealing focus, which would unhighlight the row
+ e.preventDefault();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onOpenSubmenu(item, e.currentTarget);
+ }}
+ />
+
+
+ );
+}
+
+interface MenuItemHotKeyProps {
+ action: HotkeyAction | undefined;
+ onSelect: MenuItemProps["onSelect"];
+ item: MenuItemProps["item"];
+}
+
+function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
+ useHotKey(action ?? null, () => onSelect(item));
+ return null;
+}
+
+function sign(
+ p1: { x: number; y: number },
+ p2: { x: number; y: number },
+ p3: { x: number; y: number },
+) {
+ return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
+}
+
+function isPointInTriangle(
+ pt: { x: number; y: number },
+ v1: { x: number; y: number },
+ v2: { x: number; y: number },
+ v3: { x: number; y: number },
+) {
+ const d1 = sign(pt, v1, v2);
+ const d2 = sign(pt, v2, v3);
+ const d3 = sign(pt, v3, v1);
+
+ const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+ const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+
+ return !(has_neg && has_pos);
+}
diff --git a/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts b/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts
new file mode 100644
index 0000000..ff9ac07
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/BetterMatchDecorator.ts
@@ -0,0 +1,13 @@
+import { type DecorationSet, MatchDecorator, type ViewUpdate } from "@codemirror/view";
+
+/**
+ * This is a custom MatchDecorator that will not decorate a match if the selection is inside it
+ */
+export class BetterMatchDecorator extends MatchDecorator {
+ updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
+ if (!update.startState.selection.eq(update.state.selection)) {
+ return super.createDeco(update.view);
+ }
+ return super.updateDeco(update, deco);
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.css b/apps/yaak-client/components/core/Editor/DiffViewer.css
new file mode 100644
index 0000000..6e48545
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/DiffViewer.css
@@ -0,0 +1,41 @@
+@reference "../../../main.css";
+
+.cm-wrapper.cm-multiline .cm-mergeView {
+ @apply h-full w-full overflow-auto pr-0.5;
+
+ .cm-mergeViewEditors {
+ @apply w-full min-h-full;
+ }
+
+ .cm-mergeViewEditor {
+ @apply w-full min-h-full relative;
+
+ .cm-collapsedLines {
+ @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded-sm cursor-default;
+ }
+ }
+
+ .cm-line {
+ @apply pl-1.5;
+ }
+ .cm-changedLine {
+ /* Round top corners only if previous line is not a changed line */
+ &:not(.cm-changedLine + &) {
+ @apply rounded-t-sm;
+ }
+ /* Round bottom corners only if next line is not a changed line */
+ &:not(:has(+ .cm-changedLine)) {
+ @apply rounded-b-sm;
+ }
+ }
+
+ /* Let content grow and disable individual scrolling for sync */
+ .cm-editor {
+ @apply h-auto! relative!;
+ position: relative !important;
+ }
+
+ .cm-scroller {
+ @apply overflow-visible!;
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.tsx b/apps/yaak-client/components/core/Editor/DiffViewer.tsx
new file mode 100644
index 0000000..f36f28e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/DiffViewer.tsx
@@ -0,0 +1,64 @@
+import { yaml } from "@codemirror/lang-yaml";
+import { syntaxHighlighting } from "@codemirror/language";
+import { MergeView } from "@codemirror/merge";
+import { EditorView } from "@codemirror/view";
+import classNames from "classnames";
+import { useEffect, useRef } from "react";
+import "./DiffViewer.css";
+import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
+
+interface Props {
+ /** Original/previous version (left side) */
+ original: string;
+ /** Modified/current version (right side) */
+ modified: string;
+ className?: string;
+}
+
+export function DiffViewer({ original, modified, className }: Props) {
+ const containerRef = useRef(null);
+ const viewRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ // Clean up previous instance
+ viewRef.current?.destroy();
+
+ const sharedExtensions = [
+ yaml(),
+ syntaxHighlighting(syntaxHighlightStyle),
+ ...readonlyExtensions,
+ EditorView.lineWrapping,
+ ];
+
+ viewRef.current = new MergeView({
+ a: {
+ doc: original,
+ extensions: sharedExtensions,
+ },
+ b: {
+ doc: modified,
+ extensions: sharedExtensions,
+ },
+ parent: containerRef.current,
+ collapseUnchanged: { margin: 2, minSize: 3 },
+ highlightChanges: false,
+ gutter: true,
+ orientation: "a-b",
+ revertControls: undefined,
+ });
+
+ return () => {
+ viewRef.current?.destroy();
+ viewRef.current = null;
+ };
+ }, [original, modified]);
+
+ return (
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/apps/yaak-client/components/core/Editor/Editor.css
new file mode 100644
index 0000000..bc473ab
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/Editor.css
@@ -0,0 +1,515 @@
+@reference "../../../main.css";
+
+.cm-wrapper {
+ @apply h-full overflow-hidden;
+
+ .cm-editor {
+ @apply w-full block text-base;
+
+ /* Regular cursor */
+
+ .cm-cursor {
+ @apply border-text!;
+ /* Widen the cursor a bit */
+ @apply border-l-[2px];
+ }
+
+ /* Vim-mode cursor */
+
+ .cm-fat-cursor {
+ @apply outline-0! bg-text!;
+ @apply text-surface!;
+ }
+
+ /* Matching bracket */
+
+ .cm-matchingBracket {
+ @apply bg-transparent border-b border-b-text-subtle;
+ }
+
+ &:not(.cm-focused) {
+ .cm-cursor,
+ .cm-fat-cursor {
+ @apply hidden;
+ }
+ }
+
+ &.cm-focused {
+ outline: none !important;
+ }
+
+ .cm-content {
+ @apply py-0;
+ }
+
+ .cm-line {
+ @apply w-full;
+ /* Important! Ensure it spans the entire width */
+ @apply w-full text-text px-0;
+
+ /* So the search highlight border is not cut off by editor view */
+ @apply pl-[1px];
+ }
+
+ .cm-placeholder {
+ @apply text-placeholder;
+ }
+
+ .cm-scroller {
+ /* Inherit line-height from outside */
+ line-height: inherit;
+
+ * {
+ @apply cursor-text;
+ @apply caret-transparent!;
+ }
+ }
+
+ .cm-selectionBackground {
+ @apply bg-selection!;
+ }
+
+ /* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
+ residual line below wrapped lines after deselecting (CodeMirror issue #1600, #1627).
+ The layer div must be hidden when empty to force a repaint. */
+ .cm-selectionLayer:empty {
+ display: none;
+ }
+
+ /* Style gutters */
+
+ .cm-gutters {
+ @apply border-0 text-text-subtlest bg-surface pr-1.5;
+ /* Not sure why, but there's a tiny gap left of the gutter that you can see text
+ through. Move left slightly to fix that. */
+ @apply -left-[1px];
+
+ .cm-gutterElement {
+ @apply cursor-default;
+ }
+ }
+
+ .cm-gutter-lint {
+ @apply w-auto!;
+
+ .cm-gutterElement {
+ @apply px-0;
+ }
+
+ .cm-lint-marker {
+ @apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
+ @apply rounded-full w-[0.9em] h-[0.9em];
+
+ content: "";
+
+ &.cm-lint-marker-error {
+ @apply bg-danger;
+ }
+ }
+ }
+
+ .template-tag {
+ /* Colors */
+ @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
+ @apply hover:border-border hover:text-text hover:bg-surface-highlight;
+
+ @apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
+
+ -webkit-text-security: none;
+
+ * {
+ @apply cursor-default;
+ }
+
+ /* The chevron on a tag that opens a menu. The tag itself is the button, so this is
+ only the affordance that says so. */
+ .tag-action {
+ @apply inline-flex items-center align-middle ml-1;
+ @apply text-text-subtlest;
+
+ svg {
+ @apply w-[0.9em] h-[0.9em];
+ }
+ }
+
+ /* A preview of the value a tag stands for. Sized before the image loads, so arriving
+ never changes the line's height. */
+ .tag-thumbnail {
+ @apply inline-block align-middle mr-1 rounded-xs;
+ @apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
+ }
+
+ .fn {
+ @apply inline-block;
+ .fn-inner {
+ @apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
+ }
+ .fn-arg-name {
+ /* Nothing yet */
+ @apply opacity-60;
+ }
+ .fn-arg-value {
+ @apply inline-block truncate;
+ }
+ .fn-bracket {
+ @apply text-text-subtle opacity-30;
+ }
+ }
+ }
+
+ .hyperlink-widget {
+ & > * {
+ @apply underline;
+ }
+
+ &:hover > * {
+ @apply text-primary;
+ }
+
+ -webkit-text-security: none;
+ }
+ }
+
+ &.cm-singleline {
+ .cm-editor {
+ @apply w-full h-full;
+ }
+
+ .cm-scroller {
+ @apply font-mono text-xs;
+
+ /* Hide scrollbars */
+
+ &::-webkit-scrollbar-corner,
+ &::-webkit-scrollbar {
+ @apply hidden!;
+ }
+ }
+ }
+
+ &.cm-multiline {
+ &.cm-full-height {
+ @apply relative;
+
+ .cm-editor {
+ @apply inset-0 absolute;
+ position: absolute !important;
+ }
+ }
+
+ .cm-editor {
+ @apply h-full;
+ }
+
+ .cm-scroller {
+ @apply font-mono text-editor;
+ }
+ }
+}
+
+/* Style search matches */
+.cm-searchMatch {
+ @apply bg-transparent!;
+ @apply rounded-[2px] outline outline-1;
+
+ &.cm-searchMatch-selected {
+ @apply outline-text;
+ @apply bg-text!;
+
+ &,
+ * {
+ @apply text-surface! font-semibold!;
+ }
+ }
+}
+
+/* Obscure text for password fields */
+.cm-wrapper.cm-obscure-text .cm-line {
+ -webkit-text-security: disc;
+}
+
+/* Obscure text for password fields */
+.cm-wrapper.cm-obscure-text .cm-line {
+ -webkit-text-security: disc;
+
+ .cm-placeholder {
+ -webkit-text-security: none;
+ }
+}
+
+.cm-editor .cm-gutterElement {
+ @apply flex items-center;
+ transition: color var(--transition-duration);
+}
+
+.cm-editor .fold-gutter-icon {
+ @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
+ @apply cursor-default!;
+}
+
+.cm-editor .fold-gutter-icon::after {
+ @apply block w-1.5 h-1.5 p-0.5 border-transparent border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
+}
+
+/* Rotate the fold gutter chevron when open */
+.cm-editor .fold-gutter-icon[data-open]::after {
+ @apply rotate-[-45deg];
+}
+
+/* Adjust fold gutter icon position after rotation */
+.cm-editor .fold-gutter-icon:not([data-open])::after {
+ @apply relative -left-[0.1em] top-[0.1em] rotate-[-135deg];
+}
+
+.cm-editor .fold-gutter-icon:hover {
+ @apply text-text bg-surface-highlight;
+}
+
+.cm-editor .cm-foldPlaceholder {
+ @apply px-2 border border-border-subtle bg-surface-highlight;
+ @apply hover:text-text hover:border-border-subtle text-text;
+ @apply cursor-default!;
+}
+
+.cm-editor .cm-activeLineGutter {
+ @apply bg-transparent text-text-subtle;
+}
+
+/* Cursor and mouse cursor for readonly mode */
+.cm-wrapper.cm-readonly {
+ &.cm-singleline * {
+ @apply cursor-default;
+ }
+}
+
+.cm-singleline .cm-editor {
+ .cm-content {
+ @apply h-full flex items-center;
+
+ /* Break characters on line wrapping mode, useful for URL field.
+ * We can make this dynamic if we need it to be configurable later
+ */
+
+ &.cm-lineWrapping {
+ @apply break-all;
+ }
+ }
+}
+
+.cm-tooltip-lint {
+ @apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
+
+ .cm-diagnostic-error {
+ @apply border-l-danger px-4 py-2;
+ }
+}
+
+.cm-lintPoint {
+ &.cm-lintPoint-error {
+ &::after {
+ @apply border-b-danger;
+ }
+ }
+}
+
+.cm-tooltip.cm-tooltip-hover {
+ @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
+ @apply p-1.5;
+
+ /* Style the tooltip for popping up "open in browser" and other stuff */
+
+ a,
+ button {
+ @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
+ }
+
+ a {
+ @apply cursor-default!;
+
+ &::after {
+ @apply text-text bg-text h-3 w-3 ml-1;
+ content: "";
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
+ -webkit-mask-size: contain;
+ display: inline-block;
+ }
+ }
+}
+
+/* NOTE: Extra selector required to override default styles */
+.cm-tooltip.cm-tooltip-autocomplete,
+.cm-tooltip.cm-completionInfo {
+ @apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
+
+ & * {
+ @apply font-mono! text-editor!;
+ }
+
+ .cm-completionIcon {
+ @apply opacity-80 italic;
+
+ &::after {
+ content: "a" !important; /* Default (eg. for GraphQL) */
+ }
+
+ &.cm-completionIcon-function::after {
+ content: "f" !important;
+ @apply text-info;
+ }
+
+ &.cm-completionIcon-variable::after {
+ content: "x" !important;
+ @apply text-primary;
+ }
+
+ &.cm-completionIcon-namespace::after {
+ content: "n" !important;
+ @apply text-warning;
+ }
+
+ &.cm-completionIcon-constant::after {
+ content: "c" !important;
+ @apply text-notice;
+ }
+
+ &.cm-completionIcon-class::after {
+ content: "o" !important;
+ }
+
+ &.cm-completionIcon-enum::after {
+ content: "e" !important;
+ }
+
+ &.cm-completionIcon-interface::after {
+ content: "i" !important;
+ }
+
+ &.cm-completionIcon-keyword::after {
+ content: "k" !important;
+ }
+
+ &.cm-completionIcon-method::after {
+ content: "m" !important;
+ }
+
+ &.cm-completionIcon-property::after {
+ content: "a" !important;
+ }
+
+ &.cm-completionIcon-text::after {
+ content: "t" !important;
+ }
+
+ &.cm-completionIcon-type::after {
+ content: "t" !important;
+ }
+ }
+
+ &.cm-completionInfo {
+ @apply mx-0.5 -mt-0.5 font-sans;
+ }
+
+ * {
+ @apply transition-none;
+ }
+
+ &.cm-tooltip-autocomplete {
+ @apply font-mono;
+
+ & > ul {
+ @apply p-1 max-h-[40vh];
+ }
+
+ & > ul > li {
+ @apply cursor-default px-2 h-[2em] rounded-sm text-text flex items-center;
+ }
+
+ & > ul > li[aria-selected] {
+ @apply bg-surface-highlight text-text;
+ }
+
+ .cm-completionIcon {
+ @apply text-sm flex items-center pb-0.5 shrink-0;
+ }
+
+ .cm-completionLabel {
+ @apply text-text;
+ }
+
+ .cm-completionDetail {
+ @apply ml-auto pl-6 text-text-subtle;
+ }
+ }
+}
+
+.cm-editor .cm-panels {
+ @apply bg-surface-highlight backdrop-blur-sm p-1 mb-1 text-text z-20 rounded-md;
+
+ input,
+ button {
+ @apply rounded-sm outline-hidden;
+ }
+
+ button {
+ @apply border-border-subtle bg-surface-highlight text-text hover:border-info;
+ @apply appearance-none bg-none cursor-default;
+ }
+
+ button[name="close"] {
+ @apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
+ }
+
+ input {
+ @apply bg-surface border-border-subtle focus:border-border-focus;
+ @apply border outline-hidden;
+ }
+
+ input.cm-textfield {
+ @apply cursor-text;
+ }
+
+ .cm-search label {
+ @apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;
+
+ input[type="checkbox"] {
+ @apply hidden;
+ }
+
+ &:has(:checked) {
+ @apply text-primary border-border;
+ }
+ }
+
+ /* Hide the "All" button */
+
+ button[name="select"] {
+ @apply hidden;
+ }
+
+ /* Replace next/prev button text with chevron icons */
+
+ .cm-search button[name="next"],
+ .cm-search button[name="prev"] {
+ @apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;
+ }
+
+ .cm-search button[name="prev"]::after,
+ .cm-search button[name="next"]::after {
+ @apply block w-3.5 h-3.5 bg-text;
+ content: "";
+ }
+
+ .cm-search button[name="prev"]::after {
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+ }
+
+ .cm-search button[name="next"]::after {
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
+ }
+
+ .cm-search-match-count {
+ @apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;
+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx
new file mode 100644
index 0000000..fa18b8e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/Editor.tsx
@@ -0,0 +1,770 @@
+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 { settingsAtom } from "@yaakapp-internal/models";
+import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+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 { 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 type { GenericCompletionConfig } from "./genericCompletion";
+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 EditorProps {
+ actions?: ReactNode;
+ autoFocus?: boolean;
+ autoSelect?: boolean;
+ 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 };
+
+const emptyVariables: WrappedEnvironmentVariable[] = [];
+const emptyExtension: Extension = [];
+
+export function Editor(props: EditorProps) {
+ return ;
+}
+
+function EditorInner({
+ actions,
+ autoFocus,
+ autoSelect,
+ autocomplete,
+ autocompleteFunctions,
+ autocompleteVariables,
+ className,
+ defaultValue,
+ disableTabIndent,
+ disabled,
+ extraExtensions,
+ forcedEnvironmentId,
+ forceUpdateKey,
+ format,
+ heightMode,
+ hideGutter,
+ graphQLSchema,
+ language,
+ lintExtension,
+ onBlur,
+ onChange,
+ onFocus,
+ onKeyDown,
+ onPaste,
+ onPasteOverwrite,
+ placeholder,
+ readOnly,
+ singleLine,
+ containerOnly,
+ stateKey,
+ type,
+ wrapLines,
+ setRef,
+}: EditorProps) {
+ const settings = useAtomValue(settingsAtom);
+
+ const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
+ const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
+ const environmentVariables = useMemo(() => {
+ if (!autocompleteVariables) return emptyVariables;
+ return typeof autocompleteVariables === "function"
+ ? allEnvironmentVariables.filter(autocompleteVariables)
+ : allEnvironmentVariables;
+ }, [allEnvironmentVariables, autocompleteVariables]);
+
+ if (settings && wrapLines === undefined) {
+ wrapLines = settings.editorSoftWrap;
+ }
+
+ if (disabled) {
+ readOnly = true;
+ }
+
+ if (
+ singleLine ||
+ language == null ||
+ language === "text" ||
+ language === "url" ||
+ language === "pairs"
+ ) {
+ disableTabIndent = true;
+ }
+
+ if (format == null && !readOnly) {
+ format =
+ language === "json"
+ ? tryFormatJson
+ : language === "xml" || language === "html"
+ ? tryFormatXml
+ : 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 });
+ },
+ [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],
+ );
+
+ 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 (fn.name === "secure") {
+ withEncryptionEnabled(show);
+ } else {
+ show();
+ }
+ },
+ [],
+ );
+
+ const onClickVariable = useCallback(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
+ await editEnvironment(v.environment, { addOrFocusVariable: v.variable });
+ },
+ [],
+ );
+
+ const onClickMissingVariable = useCallback(async (name: string) => {
+ const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
+ await editEnvironment(activeEnvironment, {
+ addOrFocusVariable: { name, value: "", enabled: true },
+ });
+ }, []);
+
+ const [, { focusParamValue }] = useRequestEditor();
+ const onClickPathParameter = useCallback(
+ async (name: string) => {
+ focusParamValue(name);
+ },
+ [focusParamValue],
+ );
+
+ const completionOptions = useTemplateFunctionCompletionOptions(
+ onClickFunction,
+ !!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,
+ language,
+ lintExtension,
+ hideGutter,
+ environmentVariables,
+ autocomplete,
+ completionOptions,
+ 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);
+
+ const view = new EditorView({ state, parent: container });
+
+ // 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 };
+ if (autoFocus) {
+ view.focus();
+ }
+ if (autoSelect) {
+ view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
+ }
+ 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,
+}: 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);
+ }),
+ ];
+}
+
+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/apps/yaak-client/components/core/Editor/LazyEditor.tsx b/apps/yaak-client/components/core/Editor/LazyEditor.tsx
new file mode 100644
index 0000000..93a2706
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/LazyEditor.tsx
@@ -0,0 +1,12 @@
+import { lazy, Suspense } from "react";
+import type { EditorProps } from "./Editor";
+
+const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
+
+export function Editor(props: EditorProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts
new file mode 100644
index 0000000..29be56c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/extensions.ts
@@ -0,0 +1,343 @@
+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 { 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 { 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"];
+
+export function getLanguageExtension({
+ useTemplating,
+ language = "text",
+ lintExtension,
+ environmentVariables,
+ autocomplete,
+ hideGutter,
+ onClickVariable,
+ onClickMissingVariable,
+ onClickPathParameter,
+ completionOptions,
+ graphQLSchema,
+}: {
+ useTemplating: boolean;
+ environmentVariables: WrappedEnvironmentVariable[];
+ onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
+ onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
+ 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);
+ }
+
+ // GraphQL is a special exception
+ if (language === "graphql") {
+ return [
+ graphql(graphQLSchema ?? undefined, {
+ async onCompletionInfoRender(gqlCompletionItem): Promise {
+ if (!gqlCompletionItem.documentation) return null;
+ const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);
+ const span = document.createElement("span");
+ span.innerHTML = innerHTML;
+ return span;
+ },
+ onShowInDocs(field, type, parentType) {
+ const activeRequestId = jotaiStore.get(activeRequestIdAtom);
+ if (activeRequestId == null) return;
+ jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({
+ ...v,
+ [activeRequestId]: { field, type, parentType },
+ }));
+ },
+ }),
+ extraExtensions,
+ ];
+ }
+
+ 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,
+ });
+}
+
+// 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]),
+];
diff --git a/apps/yaak-client/components/core/Editor/filter/extension.ts b/apps/yaak-client/components/core/Editor/filter/extension.ts
new file mode 100644
index 0000000..f98c608
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/extension.ts
@@ -0,0 +1,211 @@
+import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
+import { autocompletion, startCompletion } from "@codemirror/autocomplete";
+import { LanguageSupport, LRLanguage, syntaxTree } from "@codemirror/language";
+import type { SyntaxNode } from "@lezer/common";
+import { parser } from "./filter";
+
+export interface FieldDef {
+ name: string;
+ // Optional static or dynamic value suggestions for this field
+ values?: string[] | (() => string[]);
+ info?: string;
+}
+
+export interface FilterOptions {
+ fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
+}
+
+const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
+const VALUE_IDENT = /\S+$/;
+const VALUE_IDENT_ONLY = /^\S+$/;
+
+function normalizeFields(fields: FieldDef[]): {
+ fieldNames: string[];
+ fieldMap: Record string[]); info?: string }>;
+} {
+ const fieldNames: string[] = [];
+ const fieldMap: Record string[]); info?: string }> = {};
+ for (const f of fields) {
+ fieldNames.push(f.name);
+ fieldMap[f.name] = { values: f.values, info: f.info };
+ }
+ return { fieldNames, fieldMap };
+}
+
+function wordBefore(
+ doc: string,
+ pos: number,
+ pattern: RegExp,
+): { from: number; to: number; text: string } | null {
+ const upto = doc.slice(0, pos);
+ const m = upto.match(pattern);
+ if (!m) return null;
+ const from = pos - m[0].length;
+ return { from, to: pos, text: m[0] };
+}
+
+function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
+ const w = wordBefore(doc, pos, FIELD_IDENT);
+ const from = w?.from ?? pos;
+ const beforeToken = doc[from - 1];
+
+ if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
+ return { from, includeAt: true };
+ }
+
+ if (beforeToken === "@") {
+ const beforeAt = doc[from - 2];
+ if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
+ return { from, includeAt: false };
+ }
+ }
+
+ return null;
+}
+
+function inPhrase(ctx: CompletionContext): boolean {
+ // Lezer node names from your grammar: Phrase is the quoted token
+ let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
+ while (n) {
+ if (n.name === "Phrase") return true;
+ n = n.parent;
+ }
+ return false;
+}
+
+// While typing an incomplete quote, there's no Phrase token yet.
+function inUnclosedQuote(doc: string, pos: number): boolean {
+ let quotes = 0;
+ for (let i = 0; i < pos; i++) {
+ if (doc[i] === '"' && doc[i - 1] !== "\\") quotes++;
+ }
+ return quotes % 2 === 1; // odd = inside an open quote
+}
+
+/**
+ * Heuristic context detector (works without relying on exact node names):
+ * - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
+ * - Otherwise, we're in a field name or bare term position.
+ */
+function contextInfo(stateDoc: string, pos: number) {
+ const lastColon = stateDoc.lastIndexOf(":", pos - 1);
+ const lastBoundary = Math.max(
+ stateDoc.lastIndexOf(" ", pos - 1),
+ stateDoc.lastIndexOf("\t", pos - 1),
+ stateDoc.lastIndexOf("\n", pos - 1),
+ stateDoc.lastIndexOf("(", pos - 1),
+ stateDoc.lastIndexOf(")", pos - 1),
+ );
+
+ const inValue = lastColon > lastBoundary;
+
+ let fieldName: string | null = null;
+ let emptyAfterColon = false;
+
+ if (inValue) {
+ // word before the colon = field name
+ const beforeColon = stateDoc.slice(0, lastColon);
+ const m = beforeColon.match(FIELD_IDENT);
+ fieldName = m ? m[0] : null;
+
+ // nothing (or only spaces) typed after the colon?
+ const after = stateDoc.slice(lastColon + 1, pos);
+ emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
+ }
+
+ return { inValue, fieldName, lastColon, emptyAfterColon };
+}
+
+/** Build a completion list for field names */
+function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
+ return fieldNames.map((name) => ({
+ label: name,
+ type: "property",
+ apply: (view, _completion, from, to) => {
+ // Leave cursor right after the field filter colon.
+ const insert = `${includeAt ? "@" : ""}${name}:`;
+ view.dispatch({
+ changes: { from, to, insert },
+ selection: { anchor: from + insert.length },
+ });
+ startCompletion(view);
+ },
+ }));
+}
+
+/** Build a completion list for field values (if provided) */
+function fieldValueCompletions(
+ def: { values?: string[] | (() => string[]); info?: string } | undefined,
+): Completion[] | null {
+ if (!def || !def.values) return null;
+ const vals = Array.isArray(def.values) ? def.values : def.values();
+ return vals.map((v) => ({
+ label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
+ displayLabel: v,
+ type: "constant",
+ }));
+}
+
+/** The main completion source */
+function makeCompletionSource(opts: FilterOptions) {
+ const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
+ return (ctx: CompletionContext): CompletionResult | null => {
+ const { state, pos } = ctx;
+ const doc = state.doc.toString();
+
+ if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
+ return null;
+ }
+
+ const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
+
+ // In field value position
+ if (inValue && fieldName) {
+ const w = wordBefore(doc, pos, VALUE_IDENT);
+ const from = w?.from ?? pos;
+ const to = pos;
+ const valDefs = fieldMap[fieldName];
+ const vals = fieldValueCompletions(valDefs);
+
+ // If user hasn't typed a value char yet:
+ // - Show value suggestions if available
+ // - Otherwise show nothing (no fallback to field names)
+ if (emptyAfterColon) {
+ if (vals?.length) {
+ return { from, to, options: vals, filter: true };
+ }
+ return null; // <-- key change: do not suggest fields here
+ }
+
+ // User started typing a value; filter value suggestions (if any)
+ if (vals?.length) {
+ return { from, to, options: vals, filter: true };
+ }
+ // No specific values: also show nothing (keeps UI quiet)
+ return null;
+ }
+
+ // Not in a value: suggest field names (and maybe boolean ops)
+ const completion = fieldCompletionFrom(doc, pos);
+ if (completion == null) return null;
+ const { from, includeAt } = completion;
+ const to = pos;
+ const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
+
+ return { from, to, options, filter: true };
+ };
+}
+
+const language = LRLanguage.define({
+ name: "filter",
+ parser,
+ languageData: {
+ autocompletion: {},
+ },
+});
+
+/** Public extension */
+export function filter(options: FilterOptions) {
+ const source = makeCompletionSource(options);
+ return new LanguageSupport(language, [autocompletion({ override: [source] })]);
+}
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.grammar b/apps/yaak-client/components/core/Editor/filter/filter.grammar
new file mode 100644
index 0000000..dcf8b81
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.grammar
@@ -0,0 +1,78 @@
+@top Query { Expr }
+
+@skip { space+ }
+@tokens {
+ space { $[ \t\r\n]+ }
+
+ LParen { "(" }
+ RParen { ")" }
+ At { "@" }
+ Colon { ":" }
+ Not { "-" | "NOT" }
+
+ // Keywords (case-insensitive)
+ And { "AND" }
+ Or { "OR" }
+
+ // "quoted phrase" with simple escapes: \" and \\
+ Phrase { '"' (!["\\] | "\\" _)* '"' }
+
+ // Bare words run until filter syntax or whitespace. Leading '-' remains unary
+ // negation, but '-' may appear after the first character.
+ Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
+ FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
+
+ @precedence { Not, And, Or, Word }
+}
+
+@detectDelim
+
+// Precedence: NOT (highest) > AND > OR (lowest)
+// We also allow implicit AND in your parser/evaluator, but for highlighting,
+// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
+Expr {
+ OrExpr
+}
+
+OrExpr {
+ AndExpr (Or AndExpr)*
+}
+
+AndExpr {
+ Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
+}
+
+Unary {
+ Not Unary
+| Primary
+}
+
+Primary {
+ Group
+| Field
+| Phrase
+| Term
+}
+
+Group {
+ LParen Expr RParen
+}
+
+Field {
+ FieldName Colon FieldValue
+}
+
+FieldName {
+ At? Word
+}
+
+FieldValue {
+ Phrase
+| FieldValueWord
+}
+
+Term {
+ Word
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.test.ts b/apps/yaak-client/components/core/Editor/filter/filter.test.ts
new file mode 100644
index 0000000..6002175
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./filter";
+
+function getNodeNames(input: string): string[] {
+ const tree = parser.parse(input);
+ const nodes: string[] = [];
+ const cursor = tree.cursor();
+ do {
+ if (cursor.name !== "Query") {
+ nodes.push(cursor.name);
+ }
+ } while (cursor.next());
+ return nodes;
+}
+
+describe("filter grammar", () => {
+ test("parses URL-like field values as one value", () => {
+ const nodes = getNodeNames("@url:yaak.app/foo-bar");
+
+ expect(nodes).not.toContain("⚠");
+ expect(nodes).toContain("FieldValue");
+ expect(nodes).toContain("FieldValueWord");
+ });
+
+ test("parses punctuation-heavy field values as one value", () => {
+ const nodes = getNodeNames("@url:yaa$*@tsrna(*)");
+
+ expect(nodes).not.toContain("⚠");
+ expect(nodes).toContain("FieldValue");
+ expect(nodes).toContain("FieldValueWord");
+ });
+
+ test("parses operator-looking field values as one value", () => {
+ const negativeValueNodes = getNodeNames("@url:-foo");
+ const operatorWordNodes = getNodeNames("@url:AND");
+
+ expect(negativeValueNodes).not.toContain("⚠");
+ expect(negativeValueNodes).toContain("FieldValueWord");
+ expect(operatorWordNodes).not.toContain("⚠");
+ expect(operatorWordNodes).toContain("FieldValueWord");
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/filter.ts b/apps/yaak-client/components/core/Editor/filter/filter.ts
new file mode 100644
index 0000000..717a6da
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/filter.ts
@@ -0,0 +1,22 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import {LRParser} from "@lezer/lr"
+import {highlight} from "./highlight"
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
+ stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
+ goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
+ nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
+ maxTerm: 27,
+ nodeProps: [
+ ["openedBy", 8,"LParen"],
+ ["closedBy", 9,"RParen"]
+ ],
+ propSources: [highlight],
+ skippedNodes: [0,22],
+ repeatNodeCount: 3,
+ tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
+ tokenizers: [0, 1],
+ topRules: {"Query":[0,1]},
+ tokenPrec: 145
+})
diff --git a/apps/yaak-client/components/core/Editor/filter/format.test.ts b/apps/yaak-client/components/core/Editor/filter/format.test.ts
new file mode 100644
index 0000000..07ba7af
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/format.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vite-plus/test";
+import { formatFieldFilter } from "./format";
+import { evaluate, parseQuery } from "./query";
+
+function matchesFormattedUrl(value: string) {
+ return evaluate(parseQuery(formatFieldFilter("url", value)), {
+ fields: { url: value },
+ });
+}
+
+describe("formatFieldFilter", () => {
+ test("keeps URL-like values bare", () => {
+ expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
+ expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
+ });
+
+ test("keeps non-syntax punctuation bare", () => {
+ expect(formatFieldFilter("url", "yaa$*@tsrna(*)")).toBe("@url:yaa$*@tsrna(*)");
+ expect(matchesFormattedUrl("yaa$*@tsrna(*)")).toBe(true);
+ });
+
+ test("keeps values that start with an operator token bare", () => {
+ expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
+ expect(matchesFormattedUrl("-foo")).toBe(true);
+ });
+
+ test("keeps boolean operator words bare", () => {
+ expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
+ expect(formatFieldFilter("url", "or")).toBe("@url:or");
+ expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
+ expect(matchesFormattedUrl("AND")).toBe(true);
+ });
+
+ test("escapes quoted values", () => {
+ expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
+ expect(matchesFormattedUrl('say "hi"')).toBe(true);
+ });
+
+ test("quotes values that start with a quote", () => {
+ expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
+ expect(matchesFormattedUrl('"hi"')).toBe(true);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/format.ts b/apps/yaak-client/components/core/Editor/filter/format.ts
new file mode 100644
index 0000000..f1fc721
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/format.ts
@@ -0,0 +1,7 @@
+const bareFieldValue = /^[^\s"]\S*$/;
+
+export function formatFieldFilter(field: string, value: string) {
+ const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
+ return `@${field}:${filterValue}`;
+}
diff --git a/apps/yaak-client/components/core/Editor/filter/highlight.ts b/apps/yaak-client/components/core/Editor/filter/highlight.ts
new file mode 100644
index 0000000..9c05d72
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/highlight.ts
@@ -0,0 +1,22 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ // Boolean operators
+ And: t.operatorKeyword,
+ Or: t.operatorKeyword,
+ Not: t.operatorKeyword,
+
+ // Structural punctuation
+ LParen: t.paren,
+ RParen: t.paren,
+ Colon: t.punctuation,
+ Minus: t.operator,
+
+ // Literals
+ Phrase: t.string, // "quoted string"
+
+ // Fields
+ "FieldName/At": t.attributeName,
+ "FieldName/Word": t.attributeName,
+ "FieldValue/FieldValueWord": t.attributeValue,
+});
diff --git a/apps/yaak-client/components/core/Editor/filter/query.ts b/apps/yaak-client/components/core/Editor/filter/query.ts
new file mode 100644
index 0000000..12bf4a4
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/filter/query.ts
@@ -0,0 +1,308 @@
+// query.ts
+// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
+
+import { fuzzyMatch } from "fuzzbunny";
+/////////////////////////
+// AST
+/////////////////////////
+
+export type Ast =
+ | { type: "Term"; value: string } // foo
+ | { type: "Phrase"; value: string } // "hi there"
+ | { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase"
+ | { type: "Not"; node: Ast } // -foo or NOT foo
+ | { type: "And"; left: Ast; right: Ast } // a AND b
+ | { type: "Or"; left: Ast; right: Ast }; // a OR b
+
+/////////////////////////
+// Tokenizer
+/////////////////////////
+type Tok =
+ | { kind: "LPAREN" }
+ | { kind: "RPAREN" }
+ | { kind: "AND" }
+ | { kind: "OR" }
+ | { kind: "NOT" } // explicit NOT
+ | { kind: "MINUS" } // unary minus before term/phrase/paren group
+ | { kind: "COLON" }
+ | { kind: "WORD"; text: string } // bareword (unquoted)
+ | { kind: "PHRASE"; text: string } // "quoted phrase"
+ | { kind: "EOF" };
+
+const isSpace = (c: string) => /\s/.test(c);
+const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
+const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
+
+export function tokenize(input: string): Tok[] {
+ const toks: Tok[] = [];
+ let i = 0;
+ const n = input.length;
+
+ const peek = () => input[i] ?? "";
+ const advance = () => input[i++];
+
+ const readWord = () => {
+ let s = "";
+ while (i < n && isWordChar(peek())) s += advance();
+ return s;
+ };
+
+ const readFieldValue = () => {
+ let s = "";
+ while (i < n && !isSpace(peek())) s += advance();
+ return s;
+ };
+
+ const readPhrase = () => {
+ // assumes current char is opening quote
+ advance(); // consume opening "
+ let s = "";
+ while (i < n) {
+ const c = advance();
+ if (c === `"`) break;
+ if (c === "\\" && i < n) {
+ // escape \" and \\ (simple)
+ const next = advance();
+ s += next;
+ } else {
+ s += c;
+ }
+ }
+ return s;
+ };
+
+ while (i < n) {
+ const c = peek();
+
+ if (isSpace(c)) {
+ i++;
+ continue;
+ }
+
+ if (c === "(") {
+ toks.push({ kind: "LPAREN" });
+ i++;
+ continue;
+ }
+ if (c === ")") {
+ toks.push({ kind: "RPAREN" });
+ i++;
+ continue;
+ }
+ if (c === ":") {
+ toks.push({ kind: "COLON" });
+ i++;
+ if (peek() && !isSpace(peek()) && peek() !== `"`) {
+ toks.push({ kind: "WORD", text: readFieldValue() });
+ }
+ continue;
+ }
+ if (c === `"`) {
+ const text = readPhrase();
+ toks.push({ kind: "PHRASE", text });
+ continue;
+ }
+ if (c === "-") {
+ toks.push({ kind: "MINUS" });
+ i++;
+ continue;
+ }
+
+ // WORD / AND / OR / NOT
+ if (isWordStart(c)) {
+ const w = readWord();
+ const upper = w.toUpperCase();
+ if (upper === "AND") toks.push({ kind: "AND" });
+ else if (upper === "OR") toks.push({ kind: "OR" });
+ else if (upper === "NOT") toks.push({ kind: "NOT" });
+ else toks.push({ kind: "WORD", text: w });
+ continue;
+ }
+
+ // Unknown char—skip to be forgiving
+ i++;
+ }
+
+ toks.push({ kind: "EOF" });
+ return toks;
+}
+
+class Parser {
+ private i = 0;
+ constructor(private toks: Tok[]) {}
+
+ private peek(): Tok {
+ return this.toks[this.i] ?? { kind: "EOF" };
+ }
+ private advance(): Tok {
+ return this.toks[this.i++] ?? { kind: "EOF" };
+ }
+ private at(kind: Tok["kind"]) {
+ return this.peek().kind === kind;
+ }
+
+ // Top-level: parse OR-precedence chain, allowing implicit AND.
+ parse(): Ast | null {
+ if (this.at("EOF")) return null;
+ const expr = this.parseOr();
+ if (!this.at("EOF")) {
+ // Optionally, consume remaining tokens or throw
+ }
+ return expr;
+ }
+
+ // Precedence: NOT (highest), AND, OR (lowest)
+ private parseOr(): Ast {
+ let node = this.parseAnd();
+ while (this.at("OR")) {
+ this.advance();
+ const rhs = this.parseAnd();
+ node = { type: "Or", left: node, right: rhs };
+ }
+ return node;
+ }
+
+ private parseAnd(): Ast {
+ let node = this.parseUnary();
+ // Implicit AND: if next token starts a primary, treat as AND.
+ while (this.at("AND") || this.startsPrimary()) {
+ if (this.at("AND")) this.advance();
+ const rhs = this.parseUnary();
+ node = { type: "And", left: node, right: rhs };
+ }
+ return node;
+ }
+
+ private parseUnary(): Ast {
+ if (this.at("NOT") || this.at("MINUS")) {
+ this.advance();
+ const node = this.parseUnary();
+ return { type: "Not", node };
+ }
+ return this.parsePrimaryOrField();
+ }
+
+ private startsPrimary(): boolean {
+ const k = this.peek().kind;
+ return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
+ }
+
+ private parsePrimaryOrField(): Ast {
+ // Parenthesized group
+ if (this.at("LPAREN")) {
+ this.advance();
+ const inside = this.parseOr();
+ // if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
+ this.advance();
+ return inside;
+ }
+
+ // Phrase
+ if (this.at("PHRASE")) {
+ const t = this.advance() as Extract;
+ return { type: "Phrase", value: t.text };
+ }
+
+ // Field or bare word
+ if (this.at("WORD")) {
+ const wordTok = this.advance() as Extract;
+
+ if (this.at("COLON")) {
+ // field:value or field:"phrase"
+ this.advance(); // :
+ let value: string;
+ if (this.at("PHRASE")) {
+ const p = this.advance() as Extract;
+ value = p.text;
+ } else if (this.at("WORD")) {
+ const w = this.advance() as Extract;
+ value = w.text;
+ } else {
+ // Anything else after colon is treated literally as a single Term token.
+ const t = this.advance();
+ value = tokText(t);
+ }
+ return { type: "Field", field: wordTok.text, value };
+ }
+
+ // plain term
+ return { type: "Term", value: wordTok.text };
+ }
+
+ const w = this.advance() as Extract;
+ return { type: "Phrase", value: "text" in w ? w.text : "" };
+ }
+}
+
+function tokText(t: Tok): string {
+ if ("text" in t) return t.text;
+
+ switch (t.kind) {
+ case "COLON":
+ return ":";
+ case "LPAREN":
+ return "(";
+ case "RPAREN":
+ return ")";
+ default:
+ return "";
+ }
+}
+
+export function parseQuery(q: string): Ast | null {
+ if (q.trim() === "") return null;
+ const toks = tokenize(q);
+ const parser = new Parser(toks);
+ return parser.parse();
+}
+
+export type Doc = {
+ text?: string;
+ fields?: Record;
+};
+
+type Technique = "substring" | "fuzzy" | "strict";
+
+function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
+ if (!hay || !needle) return false;
+ if (technique === "strict") return hay === needle;
+ if (technique === "fuzzy") return !!fuzzyMatch(hay, needle);
+ return hay.indexOf(needle) !== -1;
+}
+
+export function evaluate(ast: Ast | null, doc: Doc): boolean {
+ if (!ast) return true; // Match everything if no query is provided
+
+ const text = (doc.text ?? "").toLowerCase();
+ const fieldsNorm: Record = {};
+
+ for (const [k, v] of Object.entries(doc.fields ?? {})) {
+ if (!(typeof v === "string" || Array.isArray(v))) continue;
+ fieldsNorm[k.toLowerCase()] = Array.isArray(v)
+ ? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase())
+ : [String(v ?? "").toLowerCase()];
+ }
+
+ const evalNode = (node: Ast): boolean => {
+ switch (node.type) {
+ case "Term":
+ return includes(text, node.value.toLowerCase(), "fuzzy");
+ case "Phrase":
+ // Quoted phrases match exactly
+ return includes(text, node.value.toLowerCase(), "substring");
+ case "Field": {
+ const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
+ if (vals.length === 0) return false;
+ return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
+ }
+ case "Not":
+ return !evalNode(node.node);
+ case "And":
+ return evalNode(node.left) && evalNode(node.right);
+ case "Or":
+ return evalNode(node.left) || evalNode(node.right);
+ }
+ };
+
+ return evalNode(ast);
+}
diff --git a/apps/yaak-client/components/core/Editor/genericCompletion.ts b/apps/yaak-client/components/core/Editor/genericCompletion.ts
new file mode 100644
index 0000000..630da4c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/genericCompletion.ts
@@ -0,0 +1,43 @@
+import type { Completion, CompletionContext } from "@codemirror/autocomplete";
+import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
+import { defaultBoost } from "./twig/completion";
+
+export type GenericCompletion = GenericCompletionOption & {
+ apply?: Completion["apply"];
+};
+
+export interface GenericCompletionConfig {
+ minMatch?: number;
+ options: GenericCompletion[];
+}
+
+/**
+ * Complete options, always matching until the start of the line
+ */
+export function genericCompletion(config?: GenericCompletionConfig) {
+ if (config == null) return [];
+
+ const { minMatch = 1, options } = config;
+
+ return function completions(context: CompletionContext) {
+ const toMatch = context.matchBefore(/.*/);
+
+ // Only match if we're at the start of the line
+ if (toMatch === null || toMatch.from > 0) return null;
+
+ const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
+ if (!matchedMinimumLength && !context.explicit) return null;
+
+ const optionsWithoutExactMatches = options
+ .filter((o) => o.label !== toMatch.text)
+ .map((o) => ({
+ ...o,
+ boost: defaultBoost(o),
+ }));
+ return {
+ validFor: () => true, // Not really sure why this is all it needs
+ from: toMatch.from,
+ options: optionsWithoutExactMatches,
+ };
+ };
+}
diff --git a/apps/yaak-client/components/core/Editor/hyperlink/extension.ts b/apps/yaak-client/components/core/Editor/hyperlink/extension.ts
new file mode 100644
index 0000000..7f1f1a9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/hyperlink/extension.ts
@@ -0,0 +1,129 @@
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from "@codemirror/view";
+import { activeWorkspaceIdAtom } from "../../../../hooks/useActiveWorkspace";
+import { copyToClipboard } from "../../../../lib/copy";
+import { createRequestAndNavigate } from "../../../../lib/createRequestAndNavigate";
+import { jotaiStore } from "../../../../lib/jotai";
+
+const REGEX =
+ /(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
+
+const tooltip = hoverTooltip(
+ (view, pos, side) => {
+ const { from, text } = view.state.doc.lineAt(pos);
+ let match: RegExpExecArray | null;
+ let found: { start: number; end: number } | null = null;
+
+ // oxlint-disable-next-line no-cond-assign
+ while ((match = REGEX.exec(text))) {
+ const start = from + match.index;
+ const end = start + match[0].length;
+
+ if (pos >= start && pos <= end) {
+ found = { start, end };
+ break;
+ }
+ }
+
+ if (found == null) {
+ return null;
+ }
+
+ if ((found.start === pos && side < 0) || (found.end === pos && side > 0)) {
+ return null;
+ }
+
+ return {
+ pos: found.start,
+ end: found.end,
+ create() {
+ const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
+ const link = text.substring(found?.start - from, found?.end - from);
+ const dom = document.createElement("div");
+
+ const $open = document.createElement("a");
+ $open.textContent = "Open in browser";
+ $open.href = link;
+ $open.target = "_blank";
+ $open.rel = "noopener noreferrer";
+
+ const $copy = document.createElement("button");
+ $copy.textContent = "Copy to clipboard";
+ $copy.addEventListener("click", () => {
+ copyToClipboard(link);
+ });
+
+ const $create = document.createElement("button");
+ $create.textContent = "Create new request";
+ $create.addEventListener("click", async () => {
+ await createRequestAndNavigate({
+ model: "http_request",
+ workspaceId: workspaceId ?? "n/a",
+ url: link,
+ });
+ });
+
+ dom.appendChild($open);
+ dom.appendChild($copy);
+ if (workspaceId != null) {
+ dom.appendChild($create);
+ }
+
+ return { dom };
+ },
+ };
+ },
+ {
+ hoverTime: 150,
+ },
+);
+
+const decorator = () => {
+ const placeholderMatcher = new MatchDecorator({
+ regexp: REGEX,
+ decoration(match, view, matchStartPos) {
+ const matchEndPos = matchStartPos + match[0].length - 1;
+
+ // Don't decorate if the cursor is inside the match
+ for (const r of view.state.selection.ranges) {
+ if (r.from > matchStartPos && r.to <= matchEndPos) {
+ return Decoration.replace({});
+ }
+ }
+
+ const groupMatch = match[1];
+ if (groupMatch == null) {
+ // Should never happen, but make TS happy
+ console.warn("Group match was empty", match);
+ return Decoration.replace({});
+ }
+
+ return Decoration.mark({
+ class: "hyperlink-widget",
+ });
+ },
+ });
+
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = placeholderMatcher.createDeco(view);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
+ }
+ },
+ {
+ decorations: (instance) => instance.decorations,
+ provide: (plugin) =>
+ EditorView.bidiIsolatedRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ }),
+ },
+ );
+};
+
+export const hyperlink = [tooltip, decorator()];
diff --git a/apps/yaak-client/components/core/Editor/json-lint.ts b/apps/yaak-client/components/core/Editor/json-lint.ts
new file mode 100644
index 0000000..1a46bd2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json-lint.ts
@@ -0,0 +1,60 @@
+import type { Diagnostic } from "@codemirror/lint";
+import type { EditorView } from "@codemirror/view";
+import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
+
+const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
+
+// 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?: 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 = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
+
+ 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/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts
new file mode 100644
index 0000000..67cd59e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts
@@ -0,0 +1,41 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Extension } from "@codemirror/state";
+import type { EditorView, ViewUpdate } from "@codemirror/view";
+import { ViewPlugin } from "@codemirror/view";
+import type { JsonPathSegment } from "./jsonPath";
+import { jsonPathSegmentsAt } from "./jsonPath";
+
+export interface BreadcrumbUpdate {
+ /** Path to the cursor, or `null` when the document isn't JSON. */
+ segments: JsonPathSegment[] | null;
+}
+
+/**
+ * Reports the JSON path under the cursor when the editor is created (it may restore a
+ * cached selection), and whenever the selection, document, or parsed tree changes. The
+ * tree matters because large documents parse in the background, so a path read early
+ * can be wrong until the parser reaches the cursor.
+ *
+ * The callback is not fired on blur: moving focus to the filter box (by clicking
+ * a crumb) must not erase the breadcrumb that was just acted on.
+ */
+export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
+ const report = (view: EditorView) => {
+ onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
+ };
+
+ return ViewPlugin.define((view) => {
+ report(view);
+ return {
+ update(update: ViewUpdate) {
+ if (
+ update.selectionSet ||
+ update.docChanged ||
+ syntaxTree(update.startState) !== syntaxTree(update.state)
+ ) {
+ report(update.view);
+ }
+ },
+ };
+ });
+}
diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
new file mode 100644
index 0000000..a578c45
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
@@ -0,0 +1,136 @@
+import { EditorState } from "@codemirror/state";
+import { jsonc } from "@shopify/lang-jsonc";
+import { forceParsing } from "@codemirror/language";
+import { describe, expect, test } from "vite-plus/test";
+import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
+
+function stateFor(doc: string): EditorState {
+ const state = EditorState.create({ doc, extensions: [jsonc()] });
+ // Ensure the whole doc is parsed so resolveInner sees a complete tree.
+ forceParsing({ state } as never, doc.length, 5000);
+ return state;
+}
+
+/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
+function pathAt(doc: string, needle: string): string {
+ const pos = doc.indexOf(needle) + needle.length;
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ if (segs == null) throw new Error("no JSON tree");
+ return segmentsToJsonPath(segs);
+}
+
+/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
+function pathBefore(doc: string, needle: string): string {
+ const pos = doc.indexOf(needle);
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ if (segs == null) throw new Error("no JSON tree");
+ return segmentsToJsonPath(segs);
+}
+
+describe("jsonPathSegmentsAt", () => {
+ test("nested object keys", () => {
+ const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
+ expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
+ });
+
+ test("array element index", () => {
+ const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
+ expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
+ });
+
+ test("root array element", () => {
+ const doc = `[ { "id": 1 }, { "id": 2 } ]`;
+ expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
+ });
+
+ test("key containing a dot uses bracket-quote notation", () => {
+ const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
+ expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
+ });
+
+ test("cursor on a property name resolves that key", () => {
+ const doc = `{ "outer": { "inner": 5 } }`;
+ expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
+ });
+
+ test("deeply nested arrays and objects", () => {
+ const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
+ expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
+ });
+
+ test("root scalar yields the root path", () => {
+ expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
+ });
+
+ test("caret just before a key still resolves that key", () => {
+ // The character to the left is the object's whitespace, not the property.
+ const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
+ expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
+ });
+
+ test("caret in a line's indentation resolves that line's member", () => {
+ // Caret in the blank indentation of the `title` line (structurally the
+ // object's whitespace) reads as being on `title`, not a neighbour.
+ const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
+ const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
+ });
+
+ test("caret at the end of an array element keeps the element index", () => {
+ // The caret sits after `}` where the left token is the object's close brace.
+ const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
+ const pos = doc.indexOf(`},`) + 1;
+ const segs = jsonPathSegmentsAt(stateFor(doc), pos);
+ expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
+ });
+
+ test("non-JSON documents return null", () => {
+ const state = EditorState.create({ doc: ` ` });
+ expect(jsonPathSegmentsAt(state, 3)).toBeNull();
+ });
+});
+
+describe("segmentsToJsonPath", () => {
+ test("empty segment list is the root", () => {
+ expect(segmentsToJsonPath([])).toBe(`$`);
+ });
+
+ test("partial path via count", () => {
+ const segs = [
+ { kind: "key", key: "a" },
+ { kind: "index", index: 2 },
+ { kind: "key", key: "b" },
+ ] as const;
+ expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
+ });
+});
+
+describe("jsonPathToSegments", () => {
+ test("round-trips paths built by segmentsToJsonPath", () => {
+ for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
+ const segments = jsonPathToSegments(path);
+ expect(segments).not.toBeNull();
+ expect(segmentsToJsonPath(segments ?? [])).toBe(path);
+ }
+ });
+
+ test("returns null for paths that don't name a single location", () => {
+ for (const path of [
+ "$..id",
+ "$[*]",
+ "$.items[?(@.id)]",
+ "$[0:2]",
+ "items[0]",
+ "$.a b",
+ '$["\\q"]',
+ '$["unterminated]',
+ '$["line\nbreak"]',
+ "$[01]",
+ "$[9007199254740993]",
+ "$.$",
+ ]) {
+ expect(jsonPathToSegments(path)).toBeNull();
+ }
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.ts
new file mode 100644
index 0000000..ab77e62
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/json/jsonPath.ts
@@ -0,0 +1,102 @@
+import { syntaxTree } from "@codemirror/language";
+import type { EditorState } from "@codemirror/state";
+import type { SyntaxNode } from "@lezer/common";
+import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
+
+export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
+export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
+
+// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
+// exactly one of these; everything else in the tree is punctuation or a comment.
+const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
+// The nodes with children that a caret can land "between" — its whitespace and
+// punctuation belong to the container, not to any value inside it.
+const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
+
+/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
+function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
+ const raw = state.doc.sliceString(node.from, node.to);
+ try {
+ return JSON.parse(raw) as string;
+ } catch {
+ // A half-typed or malformed key still deserves a best-effort label.
+ return raw.replace(/^"|"$/g, "");
+ }
+}
+
+/** Index of `child` among the value elements of its parent `Array` node. */
+function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
+ let idx = 0;
+ for (let c = array.firstChild; c != null; c = c.nextSibling) {
+ if (c.from === child.from && c.to === child.to) return idx;
+ if (VALUE_NODES.has(c.name)) idx++;
+ }
+ return idx;
+}
+
+/**
+ * The path from the document root to the member on the caret's line, or `null`
+ * when the document has no JSON tree (a different language, or empty). An empty
+ * array means the caret sits at the root value itself.
+ *
+ * A caret exactly on a token uses that token. A caret in a line's whitespace
+ * resolves to the enclosing object or array, so it's re-anchored to the first
+ * non-whitespace character on that line — in pretty-printed JSON each member
+ * starts its own line, so that's the member's own token. This tracks "the line
+ * I'm on" without guessing between neighbouring keys.
+ *
+ * From the anchor, each ancestor contributes a segment: a `Property` its key, a
+ * value directly inside an `Array` its index. Collected deepest-first, reversed.
+ */
+export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
+ const tree = syntaxTree(state);
+ if (tree.topNode.name !== "JsoncText") return null;
+
+ let node: SyntaxNode = tree.resolveInner(pos, -1);
+ if (CONTAINER_NODES.has(node.name)) {
+ const line = state.doc.lineAt(pos);
+ const indent = line.text.length - line.text.trimStart().length;
+ if (indent < line.text.length) {
+ node = tree.resolveInner(line.from + indent, 1);
+ }
+ }
+
+ const segments: JsonPathSegment[] = [];
+ let cur: SyntaxNode | null = node;
+ while (cur != null) {
+ const parent: SyntaxNode | null = cur.parent;
+ if (cur.name === "Property") {
+ const nameNode = cur.getChild("PropertyName");
+ if (nameNode != null) {
+ segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
+ }
+ } else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
+ segments.push({ kind: "index", index: indexInArray(parent, cur) });
+ }
+ cur = parent;
+ }
+
+ segments.reverse();
+ return segments;
+}
+
+const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
+
+/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
+function segmentToJsonPath(segment: JsonPathSegment): string {
+ if (segment.kind === "index") return `[${segment.index}]`;
+ // jsonpath-plus interprets a bare `$` as its root operator.
+ if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
+ // Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
+ // JSON.stringify gives correct double-quoting and escaping, matching the
+ // convention the JSONPath filter box already displays.
+ return `[${JSON.stringify(segment.key)}]`;
+}
+
+/**
+ * Build a JSONPath expression for the first `count` segments (all of them by
+ * default). `segmentsToJsonPath([])` is the root, `$`.
+ */
+export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
+ return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
+}
diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts
new file mode 100644
index 0000000..6df9f0a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts
@@ -0,0 +1,500 @@
+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 {
+ COLLAPSE_MEDIA_CHARS,
+ COLLAPSE_TOKEN_CHARS,
+ collapseDecorations,
+ largeValues,
+ MAX_VISIBLE_LINE_CHARS,
+} from "./largeValues";
+import type { SniffedValue } from "./sniffValue";
+
+const BIG = "A".repeat(1_000_000);
+
+/** With a grammar, so tokens can be collapsed individually */
+const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
+
+/** Without a grammar, so only the column rule applies */
+const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
+
+/**
+ * The decorations as if the whole document were on screen.
+ *
+ * In the editor the plugin passes the viewport instead, which is the same call with narrower
+ * ranges — the rules themselves don't know the difference.
+ */
+function decorationsFor(state: EditorState) {
+ return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
+}
+
+function collapsedRanges(state: EditorState) {
+ const ranges: { from: number; to: number }[] = [];
+ const iter = decorationsFor(state).iter();
+ while (iter.value != null) {
+ ranges.push({ from: iter.from, to: iter.to });
+ iter.next();
+ }
+ return ranges;
+}
+
+/** How much of each line is still rendered */
+function visibleLineLengths(state: EditorState) {
+ const hidden = collapsedRanges(state);
+ const lengths: number[] = [];
+ for (let n = 1; n <= state.doc.lines; n++) {
+ const line = state.doc.line(n);
+ const covered = hidden
+ .filter((h) => h.from >= line.from && h.to <= line.to)
+ .reduce((sum, h) => sum + (h.to - h.from), 0);
+ lengths.push(line.length - covered);
+ }
+ return lengths;
+}
+
+describe("collapsing", () => {
+ test("leaves an ordinary body alone", () => {
+ expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
+ });
+
+ test("leaves a large body of short lines alone", () => {
+ const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+ expect(collapsedRanges(jsonState(doc))).toEqual([]);
+ });
+
+ test("leaves a line just under the column limit alone", () => {
+ expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
+ });
+
+ test("never renders more than the column limit per line", () => {
+ for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
+ for (const length of visibleLineLengths(state)) {
+ expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ }
+ }
+ });
+
+ test("keeps the document text intact", () => {
+ const doc = `{"image":"${BIG}"}`;
+ expect(jsonState(doc).sliceDoc()).toBe(doc);
+ expect(jsonState(doc).doc.length).toBe(doc.length);
+ });
+});
+
+describe("token collapsing, with a grammar", () => {
+ test("hides the whole value, leaving its quotes visible", () => {
+ const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
+ const state = jsonState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(1);
+ // Exactly the text between the quotes, so the line reads as `"image":""`
+ expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
+ expect(doc[ranges[0]!.from - 1]).toBe('"');
+ expect(doc[ranges[0]!.to]).toBe('"');
+
+ // Everything after the value is still rendered, unlike a plain column cut
+ expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
+ });
+
+ // Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
+ // machine. With a bigger body this falls back to the column cut, which is by design but
+ // makes the assertion depend on how fast the runner is.
+ test("keeps every key visible in a minified body with several large values", () => {
+ const chunk = "B".repeat(20_000);
+ const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
+ const state = jsonState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(5);
+ for (const key of ["a", "b", "c", "d", "e"]) {
+ // No collapse swallows the key
+ const at = doc.indexOf(`"${key}":`);
+ expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
+ }
+ expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ });
+
+ test("collapses a value on a pretty-printed line", () => {
+ const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
+ const state = jsonState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(1);
+ expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
+ // Only the long line is touched, and all that is left of it is ` "image": ""`
+ expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
+ expect(state.doc.line(2).text).toBe(' "name": "a.png",');
+ });
+
+ test("ignores tokens under the collapse threshold", () => {
+ // Under the threshold once the surrounding quotes are counted
+ const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
+ const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+
+ // Nothing is big enough to collapse on its own, so the column rule takes over
+ const ranges = collapsedRanges(jsonState(doc));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.to).toBe(doc.length);
+ });
+});
+
+describe("column collapsing, without a grammar", () => {
+ test("collapses everything past the limit", () => {
+ const ranges = collapsedRanges(plainState(BIG));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ expect(ranges[0]!.to).toBe(BIG.length);
+ });
+
+ test("handles a long line of many short tokens", () => {
+ // A single-line CSV row: no token is long enough to collapse on its own
+ const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
+ const ranges = collapsedRanges(plainState(row));
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ });
+
+ test("collapses each long line independently", () => {
+ const doc = `${BIG}\nshort\n${BIG}`;
+ const ranges = collapsedRanges(plainState(doc));
+ expect(ranges).toHaveLength(2);
+ for (const length of visibleLineLengths(plainState(doc))) {
+ expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
+ }
+ });
+
+ test("never hides a line break", () => {
+ const doc = `${BIG}\nshort`;
+ const state = plainState(doc);
+ for (const { from, to } of collapsedRanges(state)) {
+ expect(state.sliceDoc(from, to)).not.toContain("\n");
+ }
+ expect(state.doc.lines).toBe(2);
+ });
+});
+
+describe("undelimited tokens", () => {
+ // The text grammar parses a whole line as one token. Collapsing it whole would leave the
+ // line with nothing on it but a tag, so the column cut handles it instead.
+ const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
+
+ test("falls back to the column cut for a long line of plain text", () => {
+ const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
+ const ranges = collapsedRanges(textState(doc));
+
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ expect(ranges[0]!.to).toBe(doc.length);
+ });
+
+ test("leaves the start of the line readable", () => {
+ const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
+ const state = textState(doc);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
+ expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
+ });
+});
+
+describe("sniffing the collapsed value", () => {
+ /** The widget standing in for each collapse, in document order */
+ function widgets(state: EditorState) {
+ const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
+ const iter = decorationsFor(state).iter();
+ while (iter.value != null) {
+ found.push(iter.value.spec.widget);
+ iter.next();
+ }
+ return found;
+ }
+
+ /** A base64 value that starts with a PNG signature and runs on well past the limits */
+ function pngBase64(length = 1_000_000) {
+ const bytes = new Uint8Array(length);
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ let binary = "";
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ }
+
+ test("names the format of a base64 value inside JSON", () => {
+ const state = jsonState(`{"image":"${pngBase64()}"}`);
+ expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
+ });
+
+ test("names the format of a data URI", () => {
+ const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
+ expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
+ });
+
+ test("names nothing when the value is just text", () => {
+ expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
+ expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
+ });
+
+ test("reads a column cut from the start of its line, not from the cut", () => {
+ // A body that is nothing but one base64 blob: the tail is hidden, but the value it
+ // belongs to begins at the start of the line, which is where the signature is
+ const state = plainState(pngBase64());
+ const [widget] = widgets(state);
+
+ expect(widget?.valueFrom).toBe(0);
+ expect(widget?.sniffed).toMatchObject({ label: "PNG" });
+ });
+
+ test("reads a token collapse from the value itself", () => {
+ const doc = `{"image":"${pngBase64()}"}`;
+ const [widget] = widgets(jsonState(doc));
+
+ // Just past the opening quote, so the signature is the first thing it sees
+ expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
+ expect(widget?.sniffed).toMatchObject({ label: "PNG" });
+ });
+
+ test("finds a run that starts partway through a line, and names it", () => {
+ // No grammar here, and the value is not the whole line. The run is found by its alphabet,
+ // so the prefix stays on screen and the blob is still named.
+ const prefix = "some prefix text ";
+ const state = plainState(`${prefix}${pngBase64()}`);
+ const [widget] = widgets(state);
+
+ expect(widget?.valueFrom).toBe(prefix.length);
+ expect(widget?.sniffed).toMatchObject({ label: "PNG" });
+ });
+});
+
+describe("recomputing", () => {
+ test("updates when the document changes", () => {
+ const state = plainState('{"image":"short"}');
+ expect(collapsedRanges(state)).toEqual([]);
+
+ const next = state.update({
+ changes: { from: 0, to: state.doc.length, insert: BIG },
+ }).state;
+ expect(collapsedRanges(next)).toHaveLength(1);
+ });
+});
+
+describe("media collapsing, below the length rules", () => {
+ /** A real PNG signature, at a size that no length rule would touch */
+ function png(bytes: number) {
+ const b = new Uint8Array(bytes);
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ let binary = "";
+ for (const byte of b) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ }
+
+ /** Well under every length threshold, so only the media rule can collapse it */
+ const SMALL = png(3_000);
+
+ test("collapses a small image on a short line", () => {
+ expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
+ expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
+
+ const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
+ const ranges = collapsedRanges(state);
+
+ expect(ranges).toHaveLength(1);
+ expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
+ });
+
+ test("names it, so the tag can say what it is", () => {
+ const state = jsonState(`{"avatar":"${SMALL}"}`);
+ const iter = decorationsFor(state).iter();
+ expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
+ });
+
+ test("leaves a value of the same size alone when nothing recognises it", () => {
+ // The only difference from the case above is what the bytes turn out to be
+ const prose = "x".repeat(SMALL.length);
+ expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
+ });
+
+ test("leaves anything under the media threshold alone, recognised or not", () => {
+ const tiny = png(400);
+ expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
+ expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
+ });
+
+ test("leaves text alone even when its first bytes match a signature", () => {
+ // `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
+ // enough that the sniff alone must not be allowed to hide something.
+ const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
+ expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
+ expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
+ });
+
+ test("still collapses it once it is long enough to be a rendering problem", () => {
+ // Past the length rule the sniff no longer decides anything, so this is hidden for its size
+ const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
+ expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
+ });
+
+ test("collapses a data URI on a short line", () => {
+ const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
+ expect(collapsedRanges(state)).toHaveLength(1);
+ });
+
+ test("still leaves an ordinary document completely untouched", () => {
+ const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
+ "\n",
+ );
+ expect(collapsedRanges(jsonState(doc))).toEqual([]);
+ });
+});
+
+describe("media rule only, for a document being edited", () => {
+ /** What an editable editor gets: rule 2 alone */
+ function editableRanges(state: EditorState) {
+ const ranges: { from: number; to: number }[] = [];
+ const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
+ while (iter.value != null) {
+ ranges.push({ from: iter.from, to: iter.to });
+ iter.next();
+ }
+ return ranges;
+ }
+
+ function png(bytes: number) {
+ const b = new Uint8Array(bytes);
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ let binary = "";
+ for (const byte of b) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ }
+
+ test("collapses a pasted image, the one thing it is for", () => {
+ const value = png(3_000);
+ const state = jsonState(`{\n "avatar": "${value}"\n}`);
+
+ expect(editableRanges(state)).toHaveLength(1);
+ expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
+ value,
+ );
+ });
+
+ test("leaves a long value alone when nothing can name it", () => {
+ // Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
+ const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+ expect(editableRanges(jsonState(doc))).toEqual([]);
+ });
+
+ test("never cuts at the column limit, which would strand text out of reach", () => {
+ // Rule 3 territory: a minified body with no value big enough to collapse on its own
+ const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
+ expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
+ expect(editableRanges(jsonState(doc))).toEqual([]);
+ // The read-only rules still cut it
+ expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
+ });
+
+ test("never swallows a template tag, collapsing only the run beside it", () => {
+ // A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
+ // on screen where it can still be read and edited
+ const tag = "{{ image }}";
+ const doc = `{"avatar":"${tag}${png(3_000)}"}`;
+ const ranges = editableRanges(jsonState(doc));
+
+ expect(ranges).toHaveLength(1);
+ expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
+ expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
+ });
+
+ test("finds a data URI whole, header and all", () => {
+ const value = `data:image/jpeg;base64,${png(3_000)}`;
+ const doc = `{"avatar":"${value}"}`;
+ const ranges = editableRanges(jsonState(doc));
+
+ expect(ranges).toHaveLength(1);
+ expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
+ });
+
+ test("hides no line break, so the plugin may provide it from the viewport", () => {
+ const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
+ for (const { from, to } of editableRanges(state)) {
+ expect(state.sliceDoc(from, to)).not.toContain("\n");
+ }
+ });
+});
+
+describe("templated fields, where the grammar is an overlay", () => {
+ // Every editable field mixes its language with twig, which mounts the base language as an
+ // overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
+ // one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
+ // case that has to keep working: an image pasted into a JSON request body.
+ const twigState = (doc: string) =>
+ EditorState.create({
+ doc,
+ extensions: [
+ twig({
+ base: jsonc(),
+ environmentVariables: [],
+ completionOptions: [],
+ onClickVariable: () => {},
+ onClickMissingVariable: () => {},
+ onClickPathParameter: () => {},
+ extraExtensions: [],
+ }),
+ largeValues,
+ ],
+ });
+
+ function png(bytes: number) {
+ const b = new Uint8Array(bytes);
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ let binary = "";
+ for (const byte of b) binary += String.fromCharCode(byte);
+ return btoa(binary);
+ }
+
+ test("collapses a pasted image in a templated body", () => {
+ const value = png(3_000);
+ const state = twigState(`{\n "avatar": "${value}"\n}`);
+ const ranges: { from: number; to: number }[] = [];
+ const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
+ while (iter.value != null) {
+ ranges.push({ from: iter.from, to: iter.to });
+ iter.next();
+ }
+
+ expect(ranges).toHaveLength(1);
+ expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
+ });
+});
+
+describe("viewport scoping", () => {
+ const doc = () => {
+ const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
+ return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
+ };
+
+ test("decorates only the ranges it is given", () => {
+ const state = jsonState(doc());
+ const secondLine = state.doc.line(3);
+
+ const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
+ const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
+
+ expect(all.size).toBe(3);
+ expect(one.size).toBe(1);
+ });
+
+ test("covers a line the range only partly overlaps", () => {
+ // The viewport can start mid-line; the collapse still has to span the whole value
+ const state = jsonState(doc());
+ const line = state.doc.line(2);
+ const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
+
+ expect(partial.size).toBe(1);
+ const iter = partial.iter();
+ expect(iter.to).toBeGreaterThan(line.from + 6);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts
new file mode 100644
index 0000000..4918599
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/largeValues.ts
@@ -0,0 +1,584 @@
+import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
+import { formatSize } from "@yaakapp-internal/lib/formatSize";
+import type { EditorState, Extension, Range } from "@codemirror/state";
+import type { Tree as SyntaxTree } from "@lezer/common";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+import { fireAndForget } from "../../../lib/fireAndForget";
+import type { SniffedValue } from "./sniffValue";
+import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
+
+/**
+ * How much of a line may be rendered before the rest is collapsed.
+ *
+ * VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
+ * reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
+ * collapse to a placeholder that can be opened instead.
+ */
+export const MAX_VISIBLE_LINE_CHARS = 10_000;
+
+/**
+ * A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
+ * enough that nothing a person might actually read is ever hidden on length alone.
+ */
+export const COLLAPSE_TOKEN_CHARS = 5_000;
+
+/**
+ * A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
+ * what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
+ * a tag beats a wall of base64 well below the length that would make it a rendering problem —
+ * this covers the avatars and icons that make up most base64 in real payloads. Anything we
+ * can't identify stays visible until it's big enough to be a problem on its own.
+ */
+export const COLLAPSE_MEDIA_CHARS = 1_000;
+
+/**
+ * Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
+ * is only noise on screen.
+ *
+ * Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
+ * response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
+ * the UI, because soft wrap has to measure the whole line end to end to find its break points.
+ * Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
+ * 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
+ *
+ * Three rules:
+ *
+ * 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
+ * as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
+ * values keeps every one of its keys. Needs a grammar to find the value.
+ * 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
+ * which is what catches an ordinary embedded image long before it is a rendering problem.
+ * 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
+ * column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
+ * line that is simply long.
+ *
+ * Only what the viewport covers is examined, and only lines long enough to hold a collapse are
+ * looked at, so a document of ordinary short lines costs a length check per visible line and
+ * nothing else — no grammar is consulted and no tree is walked.
+ *
+ * Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
+ * itself is a button, opening a menu that views, copies or saves exactly what it stands for.
+ *
+ * The value's own head says what it is — a `data:` URI names its media type, and raw base64
+ * gives its format up in the first dozen bytes — so the tag can name the format and open the
+ * value in the matching viewer without any of it being read. See {@link sniffValue}.
+ *
+ * An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
+ * by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
+ * which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
+ * So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
+ * they hide text by length alone, without being able to say what it is: under rule 3 a minified
+ * body would become uneditable past the column limit, which really would be editing text you
+ * can't see.
+ */
+
+/**
+ * Which of the three rules to apply.
+ *
+ * `media` is the subset safe for a document being edited: collapse only what we can name.
+ */
+export type CollapseRules = "all" | "media";
+
+/**
+ * A hidden range, the value it belongs to, and what that value turned out to be.
+ *
+ * The hidden range and the value differ under the column rule, where the value starts at the
+ * beginning of the line but only what runs past the limit is hidden. The tag stands in for
+ * [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
+ */
+interface Collapse {
+ from: number;
+ to: number;
+ valueFrom: number;
+ sniffed: SniffedValue | null;
+}
+
+/**
+ * Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
+ * rendering React here would leave it empty while CodeMirror measures line heights.
+ */
+const CHEVRON_DOWN =
+ ' ';
+
+/**
+ * Marks the tag as the thing that opens the menu.
+ *
+ * The whole tag is the target rather than the chevron alone: there is only one action, and a
+ * label you can't click next to a hit area a few pixels wide is a worse button than the tag
+ * itself. The chevron stays as the affordance that says so.
+ *
+ * A span with a role rather than a real button, because a button's box model makes the line
+ * taller — the one thing this extension exists to keep from happening.
+ */
+function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
+ el.role = "button";
+ el.ariaHasPopup = "menu";
+ el.tabIndex = 0;
+ el.title = title;
+ el.ariaLabel = title;
+
+ // Keep the editor from moving the cursor when the tag is pressed
+ el.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ el.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpen();
+ });
+ el.addEventListener("keydown", (e) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ e.stopPropagation();
+ onOpen();
+ });
+}
+
+class LargeValueWidget extends WidgetType {
+ constructor(
+ private readonly from: number,
+ private readonly to: number,
+ private readonly valueFrom: number,
+ private readonly sniffed: SniffedValue | null,
+ ) {
+ super();
+ }
+
+ eq(other: LargeValueWidget) {
+ return (
+ other.from === this.from &&
+ other.to === this.to &&
+ other.valueFrom === this.valueFrom &&
+ other.sniffed?.mime === this.sniffed?.mime
+ );
+ }
+
+ toDOM(view: EditorView) {
+ const length = this.to - this.from;
+
+ const el = document.createElement("span");
+ // The same neutral tag styling a path parameter uses. The theme scope matters: these
+ // tokens resolve against the tag palette, not the editor's ambient one, where a
+ // tag-sized border is meant to be near invisible.
+ el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
+
+ const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
+ if (thumbnail != null) {
+ el.appendChild(thumbnail);
+ }
+
+ const label = document.createElement("span");
+ // A named type reads as a stand-in for the value, so it only needs to say what and how big.
+ // Without a name there is nothing to show but the fact that something is missing.
+ label.textContent =
+ this.sniffed == null
+ ? `${formatSize(length)} hidden…`
+ : `${this.sniffed.label} · ${formatSize(length)}`;
+ el.appendChild(label);
+
+ const chevron = document.createElement("span");
+ chevron.className = "tag-action";
+ chevron.ariaHidden = "true";
+ chevron.innerHTML = CHEVRON_DOWN;
+ el.appendChild(chevron);
+
+ makeTagButton(
+ el,
+ this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
+ () => fireAndForget(this.openMenu(view, el)),
+ );
+
+ return el;
+ }
+
+ /**
+ * Copy, view and save, in a menu opened against the button.
+ *
+ * Everything the menu needs is pulled in on click. The editor loads on every response, and
+ * neither the React menu nor the viewers behind the dialog are worth carrying until someone
+ * asks for them.
+ */
+ private async openMenu(view: EditorView, tag: HTMLElement) {
+ const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
+ await Promise.all([
+ import("../../../lib/contextMenu"),
+ import("../../LargeValueDialog"),
+ import("../../../lib/largeValue"),
+ ]);
+
+ const { sniffed } = this;
+ // Sliced when an action runs rather than now, so opening the menu never touches the value
+ const value = () => view.state.sliceDoc(this.valueFrom, this.to);
+ const head = view.state.sliceDoc(
+ this.valueFrom,
+ Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
+ );
+
+ // The whole tag, so the menu can align to whichever edge has room beside it
+ const rect = tag.getBoundingClientRect();
+ toggleContextMenu({
+ id: "large-value",
+ triggerPosition: { x: rect.left, y: rect.bottom },
+ triggerRect: rect,
+ triggerEl: tag,
+ items: [
+ { type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
+ ...largeValueActions({
+ value,
+ sniffed,
+ // Exactly what the tag stands in for, which under the column rule is only the tail
+ copyText: () => view.state.sliceDoc(this.from, this.to),
+ onView: () => showLargeValueDialog({ text: value(), sniffed }),
+ }),
+ ],
+ });
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+/**
+ * How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
+ *
+ * The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
+ * own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
+ * thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
+ * that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
+ * file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
+ * that previews some images and not others is worse than one that previews none. Only widgets in
+ * the viewport are ever built, so a screenful is the most that decode at once.
+ */
+const THUMBNAIL_MAX_CHARS = 4_000_000;
+
+/**
+ * A preview of the value, at a size fixed before it loads.
+ *
+ * The box is a fixed square from the moment it's inserted, so the image arriving never changes
+ * the line's height or the tag's width. A growing line box is the layout cost this whole
+ * extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
+ *
+ * The src is the value's own text handed straight to the decoder as a data URI. Decoding the
+ * base64 ourselves first would mean walking megabytes on the main thread, which is the one
+ * thing that must not happen here.
+ */
+function buildThumbnail(
+ view: EditorView,
+ valueFrom: number,
+ to: number,
+ sniffed: SniffedValue | null,
+): HTMLElement | null {
+ if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
+ if (sniffed.encoding !== "base64") return null;
+ if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
+
+ const img = document.createElement("img");
+ img.className = "tag-thumbnail";
+ img.alt = "";
+ img.ariaHidden = "true";
+ img.decoding = "async";
+ // A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
+ // broken-image glyph sitting in the tag.
+ img.addEventListener("error", () => img.remove());
+ // Read the document and build the data URI after the widget is measured and on screen
+ requestAnimationFrame(() => {
+ if (!img.isConnected) return;
+ const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
+ img.src = `data:${sniffed.mime};base64,${payload}`;
+ });
+ return img;
+}
+
+interface Line {
+ from: number;
+ to: number;
+}
+
+/**
+ * The lines the given ranges touch, in document order and each listed once.
+ *
+ * Only lines long enough to hold a collapse are returned. That check is what keeps this free on
+ * ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
+ */
+function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
+ const lines: Line[] = [];
+ let lastFrom = -1;
+
+ for (const range of ranges) {
+ let pos = range.from;
+ for (;;) {
+ const line = state.doc.lineAt(pos);
+ if (line.from > lastFrom) {
+ lastFrom = line.from;
+ if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
+ lines.push({ from: line.from, to: line.to });
+ }
+ }
+ if (line.to >= range.to) break;
+ pos = line.to + 1;
+ }
+ }
+
+ return lines;
+}
+
+/**
+ * How long to spend parsing before falling back to the column rule.
+ *
+ * The initial parse is budgeted by time, so it stops partway through a document with several
+ * large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
+ * 12 ms, against the 171 ms of layout it saves.
+ */
+const PARSE_TIMEOUT_MS = 100;
+
+/**
+ * The parsed tree covering these lines.
+ *
+ * Parsing is only ever forced for a line long enough to stall layout, where finding the values
+ * inside it is what saves the frame. For everything else we take whatever the background parse
+ * has reached and decorate again when it advances — a forced parse runs from the top of the
+ * document, so making one happen on every scroll would cost far more than the tags are worth.
+ */
+function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
+ const last = lines[lines.length - 1];
+ const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
+ if (last == null || !stalls) {
+ return syntaxTree(state);
+ }
+ return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
+}
+
+const QUOTES = ['"', "'", "`"];
+
+/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
+const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
+
+/** The header that turns a bare run into a data URI, when one sits right before it. */
+const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
+
+/**
+ * Encoded media on this line, found by reading the text rather than the grammar.
+ *
+ * Deliberately grammar-free. Every editable field in the app mixes its language with the twig
+ * parser, which mounts the base language as an *overlay*, and overlays are not traversed by
+ * `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
+ * inside it. Rule 2 has to work in those fields above all, since that is where images get
+ * pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
+ * the alphabet does.
+ *
+ * A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
+ * as one thing and its declared type is what names it.
+ */
+function findMediaRuns(state: EditorState, line: Line): Collapse[] {
+ const text = state.sliceDoc(line.from, line.to);
+ const collapses: Collapse[] = [];
+
+ ENCODED_RUN.lastIndex = 0;
+ for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
+ let start = m.index;
+ // A data URI's own header breaks the alphabet, so the run starts after it
+ const header = DATA_URI_HEAD.exec(text.slice(0, start));
+ if (header != null) {
+ start = header.index;
+ }
+
+ const valueFrom = line.from + start;
+ const valueTo = line.from + m.index + m[0].length;
+ const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
+ const sniffed = sniffValue(head);
+
+ // Only hide what we can name. A short magic number matches by chance now and then, so the
+ // run itself has to look encoded too.
+ if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
+ collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
+ }
+ }
+
+ return collapses;
+}
+
+/**
+ * Quoted values big enough to be a rendering problem on their own, whatever they hold.
+ *
+ * Needs the grammar, to find where the value starts and ends, and so only runs for a document
+ * nobody is editing — which is also the only place this rule applies.
+ */
+function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
+ const collapses: Collapse[] = [];
+
+ tree.iterate({
+ from: line.from,
+ to: line.to,
+ enter: (node) => {
+ // A node this small can't contain anything worth collapsing, and neither can its children
+ if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
+ // Only leaves, so we collapse the string itself rather than the object holding it
+ if (node.node.firstChild != null) return true;
+
+ const from = Math.max(node.from, line.from);
+ const to = Math.min(node.to, line.to);
+ const open = state.sliceDoc(from, from + 1);
+ const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
+ if (!quoted) return false;
+
+ // Everything inside the quotes. The whole value is hidden, so it is its own value range
+ const valueFrom = from + 1;
+ const valueTo = to - 1;
+ if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
+
+ const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
+ collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
+ return false;
+ },
+ });
+
+ return collapses;
+}
+
+/**
+ * Both rules' findings as one list, in document order, with nothing overlapping.
+ *
+ * A big quoted base64 value satisfies both rules, and two decorations over the same text is an
+ * error. The token wins where they collide, because its range stops at the quotes and so leaves
+ * the structure around it readable.
+ */
+function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
+ const merged = [...tokens];
+ for (const run of runs) {
+ if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
+ merged.push(run);
+ }
+ }
+ return merged.sort((a, b) => a.from - b.from);
+}
+
+/**
+ * Where the line runs past the column limit, counting only what is still visible after the
+ * token collapses, or -1 if it fits.
+ */
+function findColumnCut(line: Line, tokens: Collapse[]): number {
+ let visible = 0;
+ let pos = line.from;
+
+ for (const token of [...tokens, null]) {
+ const segmentEnd = token == null ? line.to : token.from;
+ if (segmentEnd > pos) {
+ if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
+ return pos + (MAX_VISIBLE_LINE_CHARS - visible);
+ }
+ visible += segmentEnd - pos;
+ }
+ if (token != null) {
+ pos = token.to;
+ }
+ }
+
+ return -1;
+}
+
+function collapsesForLine(
+ state: EditorState,
+ tree: SyntaxTree,
+ line: Line,
+ rules: CollapseRules,
+): Collapse[] {
+ const runs = findMediaRuns(state, line);
+ // Length alone only hides things on a line that would stall without it, and never in a
+ // document being edited, where hiding text by size would put it out of reach
+ const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
+ const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
+
+ // Short enough to render, so there is nothing left to cut
+ if (!stalls) {
+ return tokens;
+ }
+
+ const cut = findColumnCut(line, tokens);
+ if (cut < 0) {
+ return tokens;
+ }
+
+ // The cut always lands in a visible stretch, so it never splits a token collapse
+ const kept = tokens.filter((t) => t.to <= cut);
+ // Only the tail is hidden, but the value it belongs to runs from the start of the line — a
+ // body that is nothing but one base64 blob is still recognisable from there
+ const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
+ kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
+ return kept;
+}
+
+/**
+ * The collapses covering the given document ranges.
+ *
+ * Kept separate from the plugin so the rules can be exercised against a state directly, with no
+ * view and no DOM.
+ */
+export function collapseDecorations(
+ state: EditorState,
+ ranges: readonly { from: number; to: number }[],
+ rules: CollapseRules = "all",
+): DecorationSet {
+ const lines = collapsibleLines(state, ranges);
+ if (lines.length === 0) {
+ return Decoration.none;
+ }
+
+ const tree = treeForLines(state, lines);
+ const decorations: Range[] = [];
+ for (const line of lines) {
+ for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
+ const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
+ decorations.push(Decoration.replace({ widget }).range(from, to));
+ }
+ }
+ return Decoration.set(decorations);
+}
+
+/**
+ * Scoped to the viewport, so the cost is a screenful however big the document is.
+ *
+ * Recomputed when the document changes, when the viewport moves, and when background parsing
+ * advances — a value can only be found once the grammar has reached it.
+ */
+function collapsePlugin(rules: CollapseRules) {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
+ }
+
+ update(update: ViewUpdate) {
+ if (
+ update.docChanged ||
+ update.viewportChanged ||
+ syntaxTree(update.startState) !== syntaxTree(update.state)
+ ) {
+ this.decorations = collapseDecorations(
+ update.view.state,
+ update.view.visibleRanges,
+ rules,
+ );
+ }
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ // Step the cursor over a placeholder instead of stranding it inside, and delete it whole
+ provide: (plugin) =>
+ EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
+ },
+ );
+}
+
+/** All three rules. For a document nobody is editing. */
+export const largeValues: Extension = [collapsePlugin("all")];
+
+/** Only what we can name, which is the part that is safe to hide while someone is editing. */
+export const mediaValues: Extension = [collapsePlugin("media")];
diff --git a/apps/yaak-client/components/core/Editor/pairs/extension.ts b/apps/yaak-client/components/core/Editor/pairs/extension.ts
new file mode 100644
index 0000000..823e9b3
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./pairs";
+
+const language = LRLanguage.define({
+ name: "pairs",
+ parser,
+ languageData: {},
+});
+
+export function pairs() {
+ return new LanguageSupport(language, []);
+}
diff --git a/apps/yaak-client/components/core/Editor/pairs/highlight.ts b/apps/yaak-client/components/core/Editor/pairs/highlight.ts
new file mode 100644
index 0000000..954deff
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ Sep: t.bracket,
+ Key: t.attributeName,
+ Value: t.string,
+});
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.grammar b/apps/yaak-client/components/core/Editor/pairs/pairs.grammar
new file mode 100644
index 0000000..bcb1fca
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.grammar
@@ -0,0 +1,9 @@
+@top pairs { (Key Sep Value "\n")* }
+
+@tokens {
+ Sep { ":" $[ \t]+ }
+ Key { ":"? ![:]+ }
+ Value { ![\n]+ }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts
new file mode 100644
index 0000000..279b8f8
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.terms.ts
@@ -0,0 +1,5 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const pairs = 1,
+ Key = 2,
+ Sep = 3,
+ Value = 4;
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
new file mode 100644
index 0000000..440f7c5
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./pairs";
+
+function getNodeNames(input: string): string[] {
+ const tree = parser.parse(input);
+ const nodes: string[] = [];
+ const cursor = tree.cursor();
+ do {
+ if (cursor.name !== "pairs") {
+ nodes.push(cursor.name);
+ }
+ } while (cursor.next());
+ return nodes;
+}
+
+describe("pairs grammar", () => {
+ test("parses colon-space pairs with a value", () => {
+ expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
+ });
+
+ test("does not parse colon-without-space as a value", () => {
+ const nodes = getNodeNames("foo:bar\n");
+
+ expect(nodes).not.toContain("Value");
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/pairs/pairs.ts b/apps/yaak-client/components/core/Editor/pairs/pairs.ts
new file mode 100644
index 0000000..fdd40d5
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/pairs/pairs.ts
@@ -0,0 +1,29 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import { LRParser } from "@lezer/lr";
+import { highlight } from "./highlight";
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R",
+ stateData: "f~OQPO~ORRO~OSTO~OVUO~O",
+ goto: "]UPPPPPVQQORSQ",
+ nodeNames: "⚠ pairs Key Sep Value",
+ maxTerm: 7,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 1,
+ tokenData:
+ "%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
+ tokenizers: [0, 1, 2],
+ topRules: { pairs: [0, 1] },
+ tokenPrec: 0,
+ termNames: {
+ "0": "⚠",
+ "1": "@top",
+ "2": "Key",
+ "3": "Sep",
+ "4": "Value",
+ "5": '(Key Sep Value "\\n")+',
+ "6": "␄",
+ "7": '"\\n"',
+ },
+});
diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts b/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
new file mode 100644
index 0000000..ad63819
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
@@ -0,0 +1,202 @@
+import { SearchQuery } from "@codemirror/search";
+import { EditorState } from "@codemirror/state";
+import { describe, expect, test } from "vite-plus/test";
+import {
+ currentMatch,
+ literalSearch,
+ MAX_COUNT,
+ MatchCounter,
+ normalizeDoc,
+ normalizeSearch,
+ scanNormalized,
+ scanQuery,
+} from "./searchMatchCount";
+
+type QueryConfig = ConstructorParameters[0];
+
+const stateOf = (doc: string) => EditorState.create({ doc });
+
+/**
+ * The matches the counter finds, having checked them against the search panel's own cursor.
+ *
+ * The cursor decides which ranges the editor highlights and which one `find next` lands on, so
+ * a count that doesn't agree with it is a wrong count, however fast it was to produce.
+ */
+function matchesOf(doc: string, config: QueryConfig) {
+ const state = stateOf(doc);
+ const query = new SearchQuery(config);
+ const matches = new MatchCounter().matches(state, query);
+ expect(matches).toEqual(scanQuery(state, query));
+ return matches;
+}
+
+const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
+
+describe("counting", () => {
+ test("counts every match, whatever the case", () => {
+ expect(countOf("one Two three two", { search: "two" })).toBe(2);
+ expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
+ });
+
+ test("skips matches overlapping an earlier one", () => {
+ expect(countOf("aaaaa", { search: "aa" })).toBe(2);
+ expect(countOf("ababa", { search: "aba" })).toBe(1);
+ });
+
+ test("treats a query as text, not as a pattern", () => {
+ expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
+ });
+
+ test("unquotes escapes unless the query is literal", () => {
+ expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
+ expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
+ });
+
+ test("counts regexp and whole word queries through the cursor", () => {
+ expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
+ expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
+ expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
+ expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
+ });
+
+ test("stops counting at the cap", () => {
+ expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
+ });
+
+ test("reports where the matches are", () => {
+ expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
+ { from: 0, to: 2 },
+ { from: 4, to: 6 },
+ ]);
+ });
+
+ test("finds nothing to match with an empty needle", () => {
+ expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
+ });
+});
+
+describe("normalization", () => {
+ test("finds what a character decomposes into", () => {
+ // The é is one character holding an `e`, and the match covers the whole of it
+ expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
+ expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
+ expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
+ expect(countOf("one two", { search: "one two" })).toBe(1);
+ expect(countOf("full width", { search: "full" })).toBe(1);
+ });
+
+ test("matches a decomposed query against composed text, and the reverse", () => {
+ expect(countOf("café", { search: "café" })).toBe(1);
+ expect(countOf("café", { search: "café" })).toBe(1);
+ expect(countOf("café", { search: "café" })).toBe(1);
+ });
+
+ test("keeps offsets straight after an expansion", () => {
+ expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
+ expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
+ });
+
+ test("normalizes the query whole, the document by character", () => {
+ expect(normalizeSearch("CAFÉ", false)).toBe("café");
+ expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
+ // Whole-string NFKD would fold this to a final sigma, which the cursor never does
+ expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
+ });
+
+ test("leaves a document that normalizes to itself untouched", () => {
+ const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
+ expect(text).toBe("plain 日本 🎉 text");
+ expect(expansions).toEqual([]);
+ });
+});
+
+describe("current match", () => {
+ const matches = [
+ { from: 0, to: 2 },
+ { from: 4, to: 6 },
+ { from: 8, to: 10 },
+ ];
+
+ test("counts from one, and reports 0 off a match", () => {
+ expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
+ expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
+ expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
+ expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
+ expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
+ expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
+ });
+
+ test("moving the selection doesn't scan again", () => {
+ const state = stateOf("a1 b2 c3");
+ const query = new SearchQuery({ search: "\\d", regexp: true });
+ const counter = new MatchCounter();
+ const found = counter.matches(state, query);
+
+ // The document a selection-only transaction leaves behind is the one already scanned
+ const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
+ expect(counter.matches(moved, query)).toBe(found);
+ expect(currentMatch(found, moved.selection.main)).toBe(2);
+ });
+});
+
+/**
+ * The mapping from normalized offsets back to document offsets is the part of this that can go
+ * quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
+ */
+describe("against the cursor, on awkward text", () => {
+ const ALPHABET = [
+ ..."abcABC .\\\n".split(""),
+ "é",
+ "é",
+ "fi",
+ "…",
+ " ",
+ "İ",
+ "Σ",
+ "ς",
+ "日",
+ "🎉",
+ "Ⅻ",
+ "f",
+ "①",
+ "́",
+ ];
+
+ /** Seeded, so a failure is the same failure next run */
+ function random(seed: number) {
+ let state = seed;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 2 ** 32;
+ };
+ }
+
+ for (const caseSensitive of [false, true]) {
+ test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
+ const next = random(caseSensitive ? 20260831 : 7);
+
+ for (let round = 0; round < 400; round++) {
+ const doc = Array.from(
+ { length: 2 + Math.floor(next() * 60) },
+ () => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
+ ).join("");
+
+ // Half the queries are lifted out of the document, so matches are actually found
+ const start = Math.floor(next() * doc.length);
+ const search =
+ next() < 0.5
+ ? doc.slice(start, start + 1 + Math.floor(next() * 3))
+ : Array.from(
+ { length: 1 + Math.floor(next() * 2) },
+ () => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
+ ).join("");
+ if (search === "") continue;
+
+ const state = stateOf(doc);
+ const query = new SearchQuery({ search, caseSensitive });
+ const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
+ expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
+ }
+ });
+ }
+});
diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.ts b/apps/yaak-client/components/core/Editor/searchMatchCount.ts
new file mode 100644
index 0000000..5f05fa2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/searchMatchCount.ts
@@ -0,0 +1,325 @@
+import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
+import type { EditorState, Extension, Text } from "@codemirror/state";
+import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
+
+/** Matches are counted no further than this, since an exact total stops being useful long before */
+export const MAX_COUNT = 9999;
+
+/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
+const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
+const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
+
+export interface Match {
+ from: number;
+ to: number;
+}
+
+/** A character whose normalized form is a different length, shifting every offset past it */
+interface Expansion {
+ normFrom: number;
+ normTo: number;
+ docFrom: number;
+ docTo: number;
+}
+
+/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
+export interface NormalizedDoc {
+ text: string;
+ expansions: Expansion[];
+}
+
+/**
+ * Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
+ * case-sensitive — in a single pass rather than one call per code point.
+ *
+ * The cursor spends 90% of its time asking ICU about one character at a time, which is what
+ * makes counting matches in a large response slow. Doing it a character at a time still matters
+ * for the result, since it keeps NFKD from reordering marks across characters, so the
+ * granularity stays and only the repeated work goes: each distinct character is normalized once
+ * and the answer reused, and ASCII runs never reach ICU at all.
+ */
+export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
+ const rewritten = new Map();
+ const expansions: Expansion[] = [];
+ let shift = 0;
+
+ const normalized = text.replace(
+ caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
+ (chunk: string, at: number) => {
+ // An ASCII run only ever folds case, which can't change its length
+ if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
+
+ let out = rewritten.get(chunk);
+ if (out === undefined) {
+ out = chunk.normalize("NFKD");
+ if (!caseSensitive) out = out.toLowerCase();
+ rewritten.set(chunk, out);
+ }
+
+ if (out.length !== chunk.length) {
+ expansions.push({
+ normFrom: at + shift,
+ normTo: at + shift + out.length,
+ docFrom: at,
+ docTo: at + chunk.length,
+ });
+ shift += out.length - chunk.length;
+ }
+
+ return out;
+ },
+ );
+
+ return { text: normalized, expansions };
+}
+
+/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
+export function normalizeSearch(search: string, caseSensitive: boolean): string {
+ const normalized = search.normalize("NFKD");
+ return caseSensitive ? normalized : normalized.toLowerCase();
+}
+
+/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
+export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
+ const matches: Match[] = [];
+ if (needle === "") return matches;
+
+ const { text } = doc;
+ let pos = text.indexOf(needle);
+ while (pos >= 0) {
+ let end = pos + needle.length;
+ // However the query was cut, a match ends on a whole code point, as the cursor's do
+ if (isLowSurrogate(text.charCodeAt(end))) end++;
+
+ matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
+ if (matches.length > MAX_COUNT) break;
+
+ pos = text.indexOf(needle, resumeAfter(doc, end));
+ }
+
+ return matches;
+}
+
+/** The same through the query's own cursor, which handles regexps and whole words */
+export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
+ const matches: Match[] = [];
+ const cursor = query.getCursor(state);
+
+ for (let result = cursor.next(); !result.done; result = cursor.next()) {
+ matches.push({ from: result.value.from, to: result.value.to });
+ if (matches.length > MAX_COUNT) break;
+ }
+
+ return matches;
+}
+
+const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
+
+/** The last character expansion beginning at or before `offset`, if there is one */
+function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
+ let low = 0;
+ let high = expansions.length - 1;
+ let found: Expansion | null = null;
+
+ while (low <= high) {
+ const mid = (low + high) >> 1;
+ if (expansions[mid]!.normFrom <= offset) {
+ found = expansions[mid]!;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+
+ return found;
+}
+
+function docStart(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ if (expansion == null) return offset;
+ // A match starting inside a character's expansion starts at the character
+ return offset < expansion.normTo
+ ? expansion.docFrom
+ : offset - (expansion.normTo - expansion.docTo);
+}
+
+/**
+ * Where scanning picks up after a match ending at `offset`.
+ *
+ * The cursor moves through the document a character at a time, so once a match ends inside a
+ * character's expansion the rest of that expansion is behind it — "…" holds three dots but only
+ * ever counts as one match of ".".
+ */
+function resumeAfter(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
+ ? expansion.normTo
+ : offset;
+}
+
+function docEnd(doc: NormalizedDoc, offset: number): number {
+ const expansion = expansionAt(doc, offset);
+ if (expansion == null) return offset;
+ if (offset <= expansion.normFrom) return expansion.docFrom;
+ // A match ending inside a character's expansion covers the whole character
+ return offset < expansion.normTo
+ ? expansion.docTo
+ : offset - (expansion.normTo - expansion.docTo);
+}
+
+/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
+export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
+ let index = 0;
+ for (const match of matches) {
+ index++;
+ if (match.from <= selection.from && match.to >= selection.to) return index;
+ }
+ return 0;
+}
+
+/** The text a plain query looks for, or null when only the cursor can answer it */
+export function literalSearch(query: SearchQuery): string | null {
+ if (query.regexp || query.wholeWord || query.test != null) return null;
+ // Mirrors SearchQuery's own unquoting, which the published type doesn't expose
+ return query.literal
+ ? query.search
+ : query.search.replace(/\\([nrt\\])/g, (_, ch) =>
+ ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
+ );
+}
+
+/**
+ * Finds the matches for the search panel, keeping the normalized document and the matches it
+ * last found, so neither moving the selection nor typing another character starts over.
+ */
+export class MatchCounter {
+ private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
+ private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
+
+ matches(state: EditorState, query: SearchQuery): Match[] {
+ const last = this.last;
+ if (last != null && last.doc === state.doc && last.query.eq(query)) {
+ return last.matches;
+ }
+
+ const matches = this.scan(state, query);
+ this.last = { doc: state.doc, query, matches };
+ return matches;
+ }
+
+ private scan(state: EditorState, query: SearchQuery): Match[] {
+ const search = literalSearch(query);
+ if (search == null) return scanQuery(state, query);
+
+ const doc = this.normalizedDoc(state.doc, query.caseSensitive);
+ return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
+ }
+
+ private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
+ const cached = this.doc;
+ if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
+ return cached.normalized;
+ }
+
+ const normalized = normalizeDoc(doc.toString(), caseSensitive);
+ this.doc = { doc, caseSensitive, normalized };
+ return normalized;
+ }
+}
+
+/**
+ * A CodeMirror extension that displays the total number of search matches
+ * inside the built-in search panel.
+ */
+export function searchMatchCount(): Extension {
+ return ViewPlugin.fromClass(
+ class {
+ private countEl: HTMLElement | null = null;
+ private counter = new MatchCounter();
+
+ constructor(private view: EditorView) {
+ this.updateCount();
+ }
+
+ update(update: ViewUpdate) {
+ // Recompute when doc changes, search state changes, or selection moves
+ const query = getSearchQuery(update.state);
+ const prevQuery = getSearchQuery(update.startState);
+ const open = searchPanelOpen(update.state);
+ const prevOpen = searchPanelOpen(update.startState);
+
+ if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
+ this.updateCount();
+ }
+ }
+
+ private updateCount() {
+ const state = this.view.state;
+ const open = searchPanelOpen(state);
+ const query = getSearchQuery(state);
+
+ if (!open) {
+ this.removeCountEl();
+ return;
+ }
+
+ this.ensureCountEl();
+ if (this.countEl == null) return;
+
+ if (!query.search) {
+ this.countEl.textContent = "0/0";
+ return;
+ }
+
+ const matches = this.counter.matches(state, query);
+ if (matches.length > MAX_COUNT) {
+ this.countEl.textContent = `${MAX_COUNT}+`;
+ } else if (matches.length === 0) {
+ this.countEl.textContent = "0/0";
+ } else {
+ const current = currentMatch(matches, state.selection.main);
+ this.countEl.textContent = `${current}/${matches.length}`;
+ }
+ }
+
+ private ensureCountEl() {
+ // Find the search panel in the editor DOM
+ const panel = this.view.dom.querySelector(".cm-search");
+ if (!panel) {
+ this.countEl = null;
+ return;
+ }
+
+ if (this.countEl && this.countEl.parentElement === panel) {
+ return; // Already attached
+ }
+
+ this.countEl = document.createElement("span");
+ this.countEl.className = "cm-search-match-count";
+
+ // Reorder: insert prev button, then next button, then count after the search input
+ const searchInput = panel.querySelector("input");
+ const prevBtn = panel.querySelector('button[name="prev"]');
+ const nextBtn = panel.querySelector('button[name="next"]');
+ if (searchInput && searchInput.parentElement === panel) {
+ searchInput.after(this.countEl);
+ if (prevBtn) this.countEl.after(prevBtn);
+ if (nextBtn && prevBtn) prevBtn.after(nextBtn);
+ } else {
+ panel.prepend(this.countEl);
+ }
+ }
+
+ private removeCountEl() {
+ if (this.countEl) {
+ this.countEl.remove();
+ this.countEl = null;
+ }
+ }
+
+ destroy() {
+ this.removeCountEl();
+ }
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/singleLine.ts b/apps/yaak-client/components/core/Editor/singleLine.ts
new file mode 100644
index 0000000..20c4b14
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/singleLine.ts
@@ -0,0 +1,47 @@
+import type { Extension, TransactionSpec } from "@codemirror/state";
+import { EditorSelection, EditorState, Transaction } from "@codemirror/state";
+
+/**
+ * A CodeMirror extension that forces single-line input by stripping
+ * all newline characters from user input, including pasted content.
+ *
+ * This extension uses a transaction filter to intercept user input,
+ * removes any newline characters, and adjusts the selection to the end
+ * of the inserted text.
+ *
+ * IME composition events are ignored to preserve proper input behavior
+ * for non-Latin languages.
+ *
+ * @returns A CodeMirror extension that enforces single-line editing.
+ */
+export function singleLineExtensions(): Extension {
+ return EditorState.transactionFilter.of(
+ (tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
+ if (!tr.isUserEvent("input") || tr.isUserEvent("input.type.compose")) return tr;
+
+ const changes: { from: number; to: number; insert: string }[] = [];
+
+ tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {
+ let insert = "";
+ for (const line of inserted.iterLines()) {
+ insert += line.replace(/\n/g, "");
+ }
+
+ if (insert !== inserted.toString()) {
+ changes.push({ from: fromB, to: toA, insert });
+ }
+ });
+
+ const lastChange = changes[changes.length - 1];
+ if (lastChange == null) return tr;
+
+ const selection = EditorSelection.cursor(lastChange.from + lastChange.insert.length);
+
+ return {
+ changes,
+ selection,
+ userEvent: tr.annotation(Transaction.userEvent) ?? undefined,
+ };
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/sniffValue.test.ts b/apps/yaak-client/components/core/Editor/sniffValue.test.ts
new file mode 100644
index 0000000..0e55936
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/sniffValue.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, test } from "vite-plus/test";
+import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
+
+/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
+function base64Of(magic: number[], length = 2_000): string {
+ const bytes = new Uint8Array(length);
+ bytes.set(magic);
+ for (let i = magic.length; i < length; i++) {
+ bytes[i] = i % 251;
+ }
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary);
+}
+
+/** One character, one byte. These are all ASCII signatures. */
+function ascii(s: string): number[] {
+ const bytes: number[] = [];
+ for (let i = 0; i < s.length; i++) {
+ bytes.push(s.charCodeAt(i));
+ }
+ return bytes;
+}
+
+/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
+const IGNORED = [0x00, 0x00, 0x00, 0x20];
+
+const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
+const JPEG = [0xff, 0xd8, 0xff, 0xe0];
+const PDF = ascii("%PDF-1.7");
+
+describe("magic numbers", () => {
+ const cases: [string, number[], string, string][] = [
+ ["PNG", PNG, "image/png", "PNG"],
+ ["JPEG", JPEG, "image/jpeg", "JPEG"],
+ ["GIF", ascii("GIF89a"), "image/gif", "GIF"],
+ ["PDF", PDF, "application/pdf", "PDF"],
+ ["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
+ ["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
+ ["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
+ ["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
+ ["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
+ ["OGG", ascii("OggS"), "audio/ogg", "OGG"],
+ ["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
+ ["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
+ ["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
+ ["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
+ ["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
+ ["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
+ ["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
+ ["BMP", ascii("BMxx"), "image/bmp", "BMP"],
+ ["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
+ ];
+
+ for (const [name, magic, mime, label] of cases) {
+ test(`recognises ${name}`, () => {
+ expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
+ });
+ }
+
+ test("recognises nothing in arbitrary base64", () => {
+ expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
+ });
+
+ test("recognises nothing in text that isn't base64", () => {
+ expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
+ });
+
+ test("recognises nothing in a JWT-shaped value", () => {
+ expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
+ });
+
+ test("reads the url-safe alphabet", () => {
+ const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
+ expect(sniffValue(urlSafe)?.label).toBe("PNG");
+ });
+
+ test("ignores leading whitespace, and counts it in the offset", () => {
+ expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
+ });
+});
+
+describe("data URIs", () => {
+ test("takes the declared type over the bytes", () => {
+ const uri = `data:image/svg+xml;base64,${btoa(" ")}`;
+ expect(sniffValue(uri)).toEqual({
+ mime: "image/svg+xml",
+ label: "SVG",
+ offset: "data:image/svg+xml;base64,".length,
+ encoding: "base64",
+ });
+ });
+
+ test("points past the header, so the payload can be decoded from there", () => {
+ const payload = base64Of(PNG);
+ const uri = `data:image/png;base64,${payload}`;
+ const sniffed = sniffValue(uri)!;
+ expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
+ });
+
+ test("falls back to the bytes when the declared type says nothing", () => {
+ const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
+ expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
+ });
+
+ test("handles a header with no type at all", () => {
+ expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
+ });
+
+ test("handles extra parameters", () => {
+ const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
+ expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
+ });
+
+ test("handles a percent-encoded payload", () => {
+ expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
+ mime: "text/html",
+ label: "HTML",
+ offset: "data:text/html,".length,
+ encoding: "percent",
+ });
+ });
+
+ test("defaults a bare percent-encoded payload to text", () => {
+ expect(sniffValue("data:,hello%20there")).toMatchObject({
+ mime: "text/plain",
+ encoding: "percent",
+ });
+ });
+});
+
+describe("decoding only the head", () => {
+ test("reads no more of the value than the bytes asked for", () => {
+ const big = base64Of(PNG, 3_000_000);
+ // Truncating to the head must not change what is found, which is the whole point:
+ // callers only ever hand it a slice
+ expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
+ });
+
+ test("decodes whole 4-character groups only", () => {
+ // 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
+ expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
+ expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
+ });
+
+ test("declines a value too short to hold a group", () => {
+ expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
+ });
+
+ test("declines text outside the alphabet", () => {
+ expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
+ });
+});
+
+describe("labels", () => {
+ test.each([
+ ["image/png", "PNG"],
+ ["image/svg+xml", "SVG"],
+ ["text/plain", "TEXT"],
+ ["application/vnd.ms-excel", "MS-EXCEL"],
+ ["image/x-icon", "ICON"],
+ ["application/octet-stream", "APPLICATION"],
+ ["font/woff2", "WOFF2"],
+ ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
+ ])("%s reads as %s", (mime, label) => {
+ expect(labelForMime(mime)).toBe(label);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/sniffValue.ts b/apps/yaak-client/components/core/Editor/sniffValue.ts
new file mode 100644
index 0000000..6a23a6a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/sniffValue.ts
@@ -0,0 +1,219 @@
+/**
+ * What a collapsed value turns out to be.
+ *
+ * Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
+ * base64 gives up its type in the first few bytes, so neither needs the rest — which is the
+ * point, since the whole reason these values are collapsed is that touching all of one costs
+ * enough to stall the UI.
+ */
+export interface SniffedValue {
+ /** Full media type, e.g. `image/png`. What the dialog routes on. */
+ mime: string;
+ /** A word for the tag, e.g. `PNG`. */
+ label: string;
+ /** Where the encoded payload starts in the value, past any `data:` header. */
+ offset: number;
+ encoding: "base64" | "percent";
+}
+
+/**
+ * How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
+ * length, and far more than the 24 base64 characters the magic numbers are read from.
+ */
+export const SNIFF_HEAD_CHARS = 256;
+
+/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
+const SNIFF_BYTES = 12;
+
+/** `data:[][; …],` — the payload follows the comma. */
+const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
+
+/** Media types that name no format, so the bytes are worth a look even when one is declared. */
+const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
+
+interface Signature {
+ label: string;
+ mime: string;
+ offset?: number;
+ /** Byte values, where `null` matches anything */
+ magic: readonly (number | null)[];
+}
+
+/**
+ * A magic number written as the ASCII it reads as, with `?` for a byte that varies.
+ *
+ * Indexed rather than iterated, because a signature is a sequence of bytes: one character here
+ * is one byte, and splitting into code points would be the wrong unit for that.
+ */
+function ascii(pattern: string): (number | null)[] {
+ const bytes: (number | null)[] = [];
+ for (let i = 0; i < pattern.length; i++) {
+ bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
+ }
+ return bytes;
+}
+
+const SIGNATURES: readonly Signature[] = [
+ { label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
+ { label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
+ { label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
+ { label: "BMP", mime: "image/bmp", magic: ascii("BM") },
+ { label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
+ { label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
+ { label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
+ { label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
+ { label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
+ { label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
+ { label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
+ { label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
+ // A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
+ { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
+ { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
+ { label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
+ { label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
+ { label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
+ { label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
+ { label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
+ { label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
+ { label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
+ { label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
+ { label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
+];
+
+/**
+ * ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
+ * than in the signature itself. Anything unlisted is some flavour of MP4.
+ */
+const ISO_BRANDS: Record = {
+ avif: { mime: "image/avif", label: "AVIF" },
+ avis: { mime: "image/avif", label: "AVIF" },
+ heic: { mime: "image/heic", label: "HEIC" },
+ heix: { mime: "image/heic", label: "HEIC" },
+ hevc: { mime: "image/heic", label: "HEIC" },
+ mif1: { mime: "image/heif", label: "HEIF" },
+ msf1: { mime: "image/heif", label: "HEIF" },
+ "M4A ": { mime: "audio/mp4", label: "M4A" },
+ "qt ": { mime: "video/quicktime", label: "MOV" },
+};
+
+function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
+ if (bytes.length < offset + magic.length) return false;
+ return magic.every((b, i) => b == null || bytes[offset + i] === b);
+}
+
+function readAscii(bytes: Uint8Array, from: number, length: number): string {
+ return String.fromCharCode(...bytes.subarray(from, from + length));
+}
+
+/** The format the first bytes of a file identify it as, or null if they identify nothing. */
+export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
+ if (readAscii(bytes, 4, 4) === "ftyp") {
+ return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
+ }
+ const sig = SIGNATURES.find((s) => matches(bytes, s));
+ return sig == null ? null : { mime: sig.mime, label: sig.label };
+}
+
+/**
+ * The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
+ *
+ * Only the characters those bytes need are decoded. The trailing partial group is dropped
+ * rather than padded, since `atob` rejects a group of one or two characters outright and we
+ * have no use for the byte a three-character group would add.
+ */
+export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
+ const wanted = Math.ceil(bytes / 3) * 4;
+ let b64 = text.slice(offset, offset + wanted);
+ b64 = b64.slice(0, b64.length - (b64.length % 4));
+ if (b64.length === 0) return null;
+
+ // The URL-safe alphabet stands for the same bytes
+ b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
+ if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
+
+ try {
+ const binary = atob(b64);
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ out[i] = binary.charCodeAt(i);
+ }
+ return out;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * What a value is, from its head alone, or null if nothing recognises it.
+ *
+ * `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
+ * ignored. The offsets in the result are relative to the start of the whole value.
+ */
+export function sniffValue(head: string): SniffedValue | null {
+ const lead = head.length - head.trimStart().length;
+ const value = head.slice(lead);
+
+ const dataUri = DATA_URI.exec(value);
+ if (dataUri != null) {
+ const declared = dataUri[1]!.toLowerCase();
+ const offset = lead + dataUri[0].length;
+ if (!dataUri[2]!.split(";").includes("base64")) {
+ // A percent-encoded payload states its own type or is text by definition
+ const mime = declared === "" ? "text/plain" : declared;
+ return { mime, label: labelForMime(mime), offset, encoding: "percent" };
+ }
+ if (!UNINFORMATIVE.includes(declared)) {
+ return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
+ }
+ return sniffBase64At(head, offset);
+ }
+
+ return sniffBase64At(head, lead);
+}
+
+function sniffBase64At(head: string, offset: number): SniffedValue | null {
+ const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
+ const sniffed = bytes == null ? null : sniffBytes(bytes);
+ return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
+}
+
+/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
+const ENCODED_RUN_CHARS = 64;
+
+/**
+ * Whether a value is one unbroken run of base64, rather than text that merely opens like it.
+ *
+ * A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
+ * up about once in every 65,000 values. That was harmless while a value had to be long enough
+ * to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
+ * harmless when the sniff itself decides to hide something, so that rule asks for this as well:
+ * prose leaves the alphabet within a few characters, and an encoded blob never does.
+ */
+export function isEncodedRun(head: string, offset: number): boolean {
+ const run = head.slice(offset);
+ return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
+}
+
+/** Subtypes whose own name makes a poor label */
+const LABEL_OVERRIDES: Record = {
+ "text/plain": "TEXT",
+};
+
+/**
+ * A word short enough for a tag. The distinguishing part of a subtype is its last dotted
+ * segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
+ * as MS-EXCEL. Anything still too long falls back to the top-level type.
+ */
+export function labelForMime(mime: string): string {
+ const override = LABEL_OVERRIDES[mime];
+ if (override != null) {
+ return override;
+ }
+
+ const [type, subtype] = mime.split("/");
+ const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
+ if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
+ return word.toUpperCase();
+ }
+ return (type ?? mime).toUpperCase();
+}
diff --git a/apps/yaak-client/components/core/Editor/text/extension.ts b/apps/yaak-client/components/core/Editor/text/extension.ts
new file mode 100644
index 0000000..a398f2a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./text";
+
+export const textLanguage = LRLanguage.define({
+ name: "text",
+ parser,
+ languageData: {},
+});
+
+export function text() {
+ return new LanguageSupport(textLanguage);
+}
diff --git a/apps/yaak-client/components/core/Editor/text/text.grammar b/apps/yaak-client/components/core/Editor/text/text.grammar
new file mode 100644
index 0000000..57dc055
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.grammar
@@ -0,0 +1,5 @@
+@top Template { Text }
+
+@tokens {
+ Text { ![]+ }
+}
diff --git a/apps/yaak-client/components/core/Editor/text/text.terms.ts b/apps/yaak-client/components/core/Editor/text/text.terms.ts
new file mode 100644
index 0000000..ac3b3af
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.terms.ts
@@ -0,0 +1,3 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Template = 1,
+ Text = 2;
diff --git a/apps/yaak-client/components/core/Editor/text/text.ts b/apps/yaak-client/components/core/Editor/text/text.ts
new file mode 100644
index 0000000..b09b79c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/text/text.ts
@@ -0,0 +1,16 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import { LRParser } from "@lezer/lr";
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "[OQOPOOQOOOOO",
+ stateData: "V~OQPO~O",
+ goto: "QPP",
+ nodeNames: "⚠ Template Text",
+ maxTerm: 3,
+ skippedNodes: [0],
+ repeatNodeCount: 0,
+ tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
+ tokenizers: [0],
+ topRules: { Template: [0, 1] },
+ tokenPrec: 0,
+});
diff --git a/apps/yaak-client/components/core/Editor/timeline/extension.ts b/apps/yaak-client/components/core/Editor/timeline/extension.ts
new file mode 100644
index 0000000..b1a0041
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./timeline";
+
+export const timelineLanguage = LRLanguage.define({
+ name: "timeline",
+ parser,
+ languageData: {},
+});
+
+export function timeline() {
+ return new LanguageSupport(timelineLanguage);
+}
diff --git a/apps/yaak-client/components/core/Editor/timeline/highlight.ts b/apps/yaak-client/components/core/Editor/timeline/highlight.ts
new file mode 100644
index 0000000..6f8c9e9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
+ IncomingText: t.tagName, // < lines - info color (matches timeline icons)
+ InfoText: t.comment, // * lines - subtle color (matches timeline icons)
+});
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.grammar b/apps/yaak-client/components/core/Editor/timeline/timeline.grammar
new file mode 100644
index 0000000..f8def8d
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.grammar
@@ -0,0 +1,21 @@
+@top Timeline { line* }
+
+line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
+
+@skip {} {
+ OutgoingLine { OutgoingText Newline }
+ IncomingLine { IncomingText Newline }
+ InfoLine { InfoText Newline }
+ PlainLine { PlainText Newline }
+}
+
+@tokens {
+ OutgoingText { "> " ![\n]* }
+ IncomingText { "< " ![\n]* }
+ InfoText { "* " ![\n]* }
+ PlainText { ![\n]+ }
+ Newline { "\n" }
+ @precedence { OutgoingText, IncomingText, InfoText, PlainText }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts b/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts
new file mode 100644
index 0000000..8f03d42
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.terms.ts
@@ -0,0 +1,11 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Timeline = 1,
+ OutgoingLine = 2,
+ OutgoingText = 3,
+ Newline = 4,
+ IncomingLine = 5,
+ IncomingText = 6,
+ InfoLine = 7,
+ InfoText = 8,
+ PlainLine = 9,
+ PlainText = 10;
diff --git a/apps/yaak-client/components/core/Editor/timeline/timeline.ts b/apps/yaak-client/components/core/Editor/timeline/timeline.ts
new file mode 100644
index 0000000..fee8020
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/timeline/timeline.ts
@@ -0,0 +1,21 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import { LRParser } from "@lezer/lr";
+import { highlight } from "./highlight";
+export const parser = LRParser.deserialize({
+ version: 14,
+ states:
+ "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
+ stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
+ goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
+ nodeNames:
+ "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
+ maxTerm: 13,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 1,
+ tokenData:
+ "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
+ tokenizers: [0],
+ topRules: { Timeline: [0, 1] },
+ tokenPrec: 36,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/completion.test.ts b/apps/yaak-client/components/core/Editor/twig/completion.test.ts
new file mode 100644
index 0000000..ff8fd94
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/completion.test.ts
@@ -0,0 +1,155 @@
+/* oxlint-disable no-template-curly-in-string */
+import { CompletionContext } from "@codemirror/autocomplete";
+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";
+
+const options: TwigCompletionOption[] = [
+ { name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
+ { name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
+];
+const source = twigCompletion({ options });
+
+// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
+function complete(input: string, label: string, extensions: Extension = []) {
+ const cursor = input.indexOf("|");
+ if (cursor < 0) throw new Error("Missing cursor marker");
+ let state = EditorState.create({
+ doc: input.slice(0, cursor) + input.slice(cursor + 1),
+ selection: { anchor: cursor },
+ extensions,
+ });
+ const result = source(new CompletionContext(state, cursor, false));
+ const completion = result?.options.find((option) => option.label === label);
+ expect(completion).toBeDefined();
+ const view = {
+ get state() {
+ return state;
+ },
+ dispatch(spec: TransactionSpec) {
+ state = state.update(spec).state;
+ },
+ } as EditorView;
+ if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
+ completion.apply(view, completion, result.from, cursor);
+ return state;
+}
+
+function expectValidTag(state: EditorState, expected: string) {
+ expect(state.doc.toString()).toBe(expected);
+ const tags: string[] = [];
+ parser.parse(expected).iterate({
+ enter(node) {
+ expect(node.type.isError).toBe(false);
+ if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
+ },
+ });
+ expect(tags).toHaveLength(1);
+}
+
+describe("twig completion", () => {
+ test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
+ "completes across multiline whitespace: %s",
+ (input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
+ );
+
+ test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
+ "completes a function across multiline whitespace: %s",
+ (input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
+ );
+
+ test("preserves surrounding lines and literal pipes", () => {
+ expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
+ "before\n${[ someVar ]}\na | b",
+ );
+ });
+
+ test("does not search past non-whitespace for an opener or closer", () => {
+ expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
+ "prefix\n${[ someVar ]}\nother ]}",
+ );
+ expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
+ "${[ someVar ]}\nother ]}",
+ );
+ });
+
+ test.each(["${[|", "${[ |", "${[ |"])(
+ "offers completion immediately after a template opener: %s",
+ (input) => {
+ expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
+ },
+ );
+
+ test.each(["$", "{", "${", '{"value": "'])(
+ "does not automatically offer empty-name completions after %s",
+ (doc) => {
+ const state = EditorState.create({ doc });
+ expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
+ },
+ );
+
+ test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
+ "completes a namespace before wrapping the final function: %s",
+ (input) => {
+ const namespace = complete(input, "uuid.*");
+ expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
+ const cursor = namespace.selection.main.head;
+ const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
+ expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
+ },
+ );
+
+ test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
+ "completes a variable in a single-line value: %s",
+ (input) => {
+ const state = complete(input, "someVar", singleLineExtensions());
+ expectValidTag(state, "${[ someVar ]}");
+ expect(state.selection.main.head).toBe(state.doc.length);
+ },
+ );
+
+ test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
+ "reuses closing delimiters: %s",
+ (input) => {
+ expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
+ },
+ );
+
+ test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
+ "completes a function: %s",
+ (input) => {
+ expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
+ },
+ );
+
+ test("preserves surrounding JSON and its closing punctuation", () => {
+ expectValidTag(
+ complete('{"value":"${[some|","other":1}', "someVar", json()),
+ '{"value":"${[ someVar ]}","other":1}',
+ );
+ });
+
+ test("preserves surrounding text and other complete tags", () => {
+ expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
+ "${[ someVar ]} ${[ someVar ]} trailing",
+ );
+ });
+
+ test("does not consume closing punctuation without a template opener", () => {
+ expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
+ });
+
+ test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
+ const state = EditorState.create({ doc: "some" });
+ const result = source(new CompletionContext(state, 4, false));
+ expect(result?.validFor).toBeInstanceOf(RegExp);
+ const validFor = result?.validFor as RegExp;
+ expect(validFor.test("someVar")).toBe(true);
+ expect(validFor.test("some ${[some")).toBe(false);
+ expect(validFor.test("uuid.")).toBe(false);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/completion.ts b/apps/yaak-client/components/core/Editor/twig/completion.ts
new file mode 100644
index 0000000..ae0a979
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/completion.ts
@@ -0,0 +1,156 @@
+import type { Completion, CompletionContext } from "@codemirror/autocomplete";
+import { startCompletion } from "@codemirror/autocomplete";
+import type { EditorState } from "@codemirror/state";
+import type { TemplateFunction } from "@yaakapp-internal/plugins";
+
+const openTag = "${[ ";
+const closeTag = " ]}";
+
+export type TwigCompletionOptionVariable = {
+ type: "variable";
+};
+
+export type TwigCompletionOptionNamespace = {
+ type: "namespace";
+};
+
+export type TwigCompletionOptionFunction = TemplateFunction & {
+ type: "function";
+};
+
+export type TwigCompletionOption = (
+ | TwigCompletionOptionFunction
+ | TwigCompletionOptionVariable
+ | TwigCompletionOptionNamespace
+) & {
+ name: string;
+ label: string | HTMLElement;
+ description?: string;
+ onClick: (rawTag: string, startPos: number) => void;
+ value: string | null;
+ invalid?: boolean;
+};
+
+export interface TwigCompletionConfig {
+ options: TwigCompletionOption[];
+}
+
+const MIN_MATCH_NAME = 1;
+
+// Only inspect adjacent whitespace, including newlines, rather than copying the
+// entire document to look for a delimiter in large request bodies.
+function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
+ while (direction === -1 ? pos > 0 : pos < state.doc.length) {
+ const from = direction === -1 ? pos - 1 : pos;
+ if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
+ pos += direction;
+ }
+ return pos;
+}
+
+function findOpenTag(state: EditorState, from: number) {
+ const end = skipWhitespace(state, from, -1);
+ return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
+}
+
+export function twigCompletion({ options }: TwigCompletionConfig) {
+ return function completions(context: CompletionContext) {
+ const toStartOfName = context.matchBefore(/[\w_.]*/);
+ const toMatch = toStartOfName ?? null;
+
+ if (toMatch === null) return null;
+
+ const matchLen = toMatch.to - toMatch.from;
+ const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
+ if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
+ return null;
+ }
+
+ const completions: Completion[] = options
+ .flatMap((o): Completion[] => {
+ const matchSegments = toMatch.text.replace(/^\$/, "").split(".");
+ const optionSegments = o.name.split(".");
+
+ // If not on the last segment, only complete the namespace
+ if (matchSegments.length < optionSegments.length) {
+ const prefix = optionSegments.slice(0, matchSegments.length).join(".");
+ return [
+ {
+ label: `${prefix}.*`,
+ type: "namespace",
+ detail: "namespace",
+ apply: (view, _completion, from, to) => {
+ const insert = `${prefix}.`;
+ view.dispatch({
+ changes: { from, to, insert: insert },
+ selection: { anchor: from + insert.length },
+ });
+ // Leave the autocomplete open so the user can continue typing the rest of the namespace
+ startCompletion(view);
+ },
+ },
+ ];
+ }
+
+ // If on the last segment, wrap the entire tag
+ const inner = o.type === "function" ? `${o.name}()` : o.name;
+ return [
+ {
+ label: o.name,
+ info: o.description,
+ detail: o.type,
+ type: o.type === "variable" ? "variable" : "function",
+ apply: (view, _completion, from, to) => {
+ // Keep the completion range name-only for filtering, but replace a manually
+ // typed opener when accepting (through either the keyboard or the menu).
+ const opener = findOpenTag(view.state, from);
+ if (opener !== null) {
+ from = opener;
+ const hasParentheses =
+ o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
+ const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
+ if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
+ }
+ const insert = openTag + inner + closeTag;
+ view.dispatch({
+ changes: { from, to, insert: insert },
+ selection: { anchor: from + insert.length },
+ });
+ },
+ },
+ ];
+ })
+ .filter((v) => v != null);
+
+ const uniqueCompletions = uniqueBy(completions, "label");
+ const sortedCompletions = uniqueCompletions.sort((a, b) => {
+ const boostDiff = defaultBoost(b) - defaultBoost(a);
+ if (boostDiff !== 0) return boostDiff;
+ return a.label.localeCompare(b.label);
+ });
+
+ return {
+ matchLen,
+ // Delimiters change the replacement range; dots change namespace options.
+ validFor: /^\w*$/,
+ from: toMatch.from,
+ options: sortedCompletions,
+ };
+ };
+}
+
+export function uniqueBy(arr: T[], key: K): T[] {
+ const map = new Map();
+ for (const item of arr) {
+ map.set(item[key], item); // overwrites → keeps last
+ }
+ return [...map.values()];
+}
+
+export function defaultBoost(o: Completion) {
+ if (o.type === "variable") return 4;
+ if (o.type === "constant") return 3;
+ if (o.type === "function") return 2;
+ if (o.type === "namespace") return 1;
+ return 0;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/extension.ts b/apps/yaak-client/components/core/Editor/twig/extension.ts
new file mode 100644
index 0000000..bbd4c8c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/extension.ts
@@ -0,0 +1,85 @@
+import type { LanguageSupport } from "@codemirror/language";
+import { LRLanguage } from "@codemirror/language";
+import type { Extension } from "@codemirror/state";
+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";
+
+export function twig({
+ base,
+ environmentVariables,
+ completionOptions,
+ autocomplete,
+ onClickVariable,
+ onClickMissingVariable,
+ extraExtensions,
+}: {
+ base: LanguageSupport;
+ environmentVariables: WrappedEnvironmentVariable[];
+ completionOptions: TwigCompletionOption[];
+ autocomplete?: GenericCompletionConfig;
+ onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;
+ onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
+ onClickPathParameter: (name: string) => void;
+ extraExtensions: Extension[];
+}) {
+ const language = mixLanguage(base);
+
+ const variableOptions: TwigCompletionOption[] =
+ environmentVariables.map((v) => ({
+ name: v.variable.name,
+ value: v.variable.value,
+ type: "variable",
+ label: v.variable.name,
+ description: `Inherited from ${v.source}`,
+ onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
+ })) ?? [];
+
+ const options = [...variableOptions, ...completionOptions];
+ const completions = twigCompletion({ options });
+
+ return [
+ language,
+ base.support,
+ language.data.of({ autocomplete: completions }),
+ base.language.data.of({ autocomplete: completions }),
+ language.data.of({ autocomplete: genericCompletion(autocomplete) }),
+ base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
+ templateTagsPlugin(options, onClickMissingVariable),
+ ...extraExtensions,
+ ];
+}
+
+const mixedLanguagesCache: Record = {};
+
+function mixLanguage(base: LanguageSupport): LRLanguage {
+ // It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up
+ const cached = mixedLanguagesCache[base.language.name];
+ if (cached != null) {
+ return cached;
+ }
+
+ const parser = twigParser.configure({
+ wrap: parseMixed((node) => {
+ // If the base language is text, we can overwrite at the top
+ if (base.language.name !== textLanguage.name && !node.type.isTop) {
+ return null;
+ }
+
+ return {
+ parser: base.language.parser,
+ overlay: (node) => node.type.name === "Text",
+ };
+ }),
+ });
+
+ const language = LRLanguage.define({ name: "twig", parser });
+ mixedLanguagesCache[base.language.name] = language;
+ return language;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/highlight.ts b/apps/yaak-client/components/core/Editor/twig/highlight.ts
new file mode 100644
index 0000000..d3a614e
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/highlight.ts
@@ -0,0 +1,7 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ TagOpen: t.bracket,
+ TagClose: t.bracket,
+ TagContent: t.keyword,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts
new file mode 100644
index 0000000..2f76dbd
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts
@@ -0,0 +1,106 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Range } from "@codemirror/state";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+
+class PathPlaceholderWidget extends WidgetType {
+ readonly #clickListenerCallback: () => void;
+
+ constructor(
+ readonly rawText: string,
+ readonly startPos: number,
+ readonly onClick: () => void,
+ ) {
+ super();
+ this.#clickListenerCallback = () => {
+ this.onClick?.();
+ };
+ }
+
+ eq(other: PathPlaceholderWidget) {
+ return this.startPos === other.startPos && this.rawText === other.rawText;
+ }
+
+ toDOM() {
+ const elt = document.createElement("span");
+ elt.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
+ elt.textContent = this.rawText;
+ elt.addEventListener("click", this.#clickListenerCallback);
+ return elt;
+ }
+
+ destroy(dom: HTMLElement) {
+ dom.removeEventListener("click", this.#clickListenerCallback);
+ super.destroy(dom);
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+function pathParameters(
+ view: EditorView,
+ onClickPathParameter: (name: string) => void,
+): DecorationSet {
+ const widgets: Range[] = [];
+ const tree = syntaxTree(view.state);
+ for (const { from, to } of view.visibleRanges) {
+ tree.iterate({
+ from,
+ to,
+ enter(node) {
+ if (node.name === "Text") {
+ // Find the `url` node and then jump into it to find the placeholders
+ for (let i = node.from; i < node.to; i++) {
+ const innerTree = tree.resolveInner(i);
+ if (innerTree.node.name === "url") {
+ innerTree.node.cursor().iterate((node) => {
+ if (node.name !== "Placeholder") return;
+ const globalFrom = node.from;
+ const globalTo = node.to;
+ const rawText = view.state.doc.sliceString(globalFrom, globalTo);
+ const onClick = () => onClickPathParameter(rawText);
+ const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
+ const deco = Decoration.replace({ widget, inclusive: false });
+ widgets.push(deco.range(globalFrom, globalTo));
+ });
+ break;
+ }
+ }
+ }
+ },
+ });
+ }
+
+ // Widgets must be sorted start to end
+ widgets.sort((a, b) => a.from - b.from);
+
+ return Decoration.set(widgets);
+}
+
+export function pathParametersPlugin(onClickPathParameter: (name: string) => void) {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = pathParameters(view, onClickPathParameter);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = pathParameters(update.view, onClickPathParameter);
+ }
+ },
+ {
+ decorations(v) {
+ return v.decorations;
+ },
+ provide(plugin) {
+ return EditorView.atomicRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ });
+ },
+ },
+ );
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/templateTags.ts b/apps/yaak-client/components/core/Editor/twig/templateTags.ts
new file mode 100644
index 0000000..4ca4511
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/templateTags.ts
@@ -0,0 +1,224 @@
+import { syntaxTree } from "@codemirror/language";
+import type { Range } from "@codemirror/state";
+import type { DecorationSet, ViewUpdate } from "@codemirror/view";
+import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
+import type { SyntaxNodeRef } from "@lezer/common";
+import { applyFormInputDefaults, validateTemplateFunctionArgs } from "@yaakapp-internal/lib";
+import type { FormInput, JsonPrimitive, TemplateFunction } from "@yaakapp-internal/plugins";
+import { parseTemplate } from "@yaakapp-internal/templates";
+import type { TwigCompletionOption } from "./completion";
+import { collectArgumentValues } from "./util";
+
+class TemplateTagWidget extends WidgetType {
+ readonly #clickListenerCallback: () => void;
+
+ constructor(
+ readonly option: TwigCompletionOption,
+ readonly rawTag: string,
+ readonly startPos: number,
+ ) {
+ super();
+ this.#clickListenerCallback = () => {
+ this.option.onClick?.(this.rawTag, this.startPos);
+ };
+ }
+
+ eq(other: TemplateTagWidget) {
+ return (
+ this.option.name === other.option.name &&
+ this.option.type === other.option.type &&
+ this.option.value === other.option.value &&
+ this.rawTag === other.rawTag &&
+ this.startPos === other.startPos
+ );
+ }
+
+ toDOM() {
+ const elt = document.createElement("span");
+ elt.className = `x-theme-templateTag template-tag ${
+ this.option.invalid
+ ? "x-theme-templateTag--danger"
+ : this.option.type === "variable"
+ ? "x-theme-templateTag--primary"
+ : "x-theme-templateTag--info"
+ }`;
+ elt.title = this.option.invalid ? "Not Found" : (this.option.value ?? "");
+ elt.setAttribute("data-tag-type", this.option.type);
+ if (typeof this.option.label === "string") elt.textContent = this.option.label;
+ else elt.appendChild(this.option.label);
+ elt.addEventListener("click", this.#clickListenerCallback);
+ return elt;
+ }
+
+ destroy(dom: HTMLElement) {
+ dom.removeEventListener("click", this.#clickListenerCallback);
+ super.destroy(dom);
+ }
+
+ ignoreEvent() {
+ return false;
+ }
+}
+
+function templateTags(
+ view: EditorView,
+ options: TwigCompletionOption[],
+ onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
+): DecorationSet {
+ const widgets: Range[] = [];
+ const tree = syntaxTree(view.state);
+ for (const { from, to } of view.visibleRanges) {
+ tree.iterate({
+ from,
+ to,
+ enter(node) {
+ if (node.name === "Tag") {
+ // Don't decorate if the cursor is inside the match
+ if (isSelectionInsideNode(view, node)) return;
+
+ const rawTag = view.state.doc.sliceString(node.from, node.to);
+
+ // TODO: Search `node.tree` instead of using Regex here
+ const inner = rawTag.replace(/^\$\{\[\s*/, "").replace(/\s*]}$/, "");
+ let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
+
+ if (inner.includes("\n")) {
+ return;
+ }
+
+ // The beta named the function `Response` but was changed in stable.
+ // Keep this here for a while because there's no easy way to migrate
+ if (name === "Response") {
+ name = "response";
+ }
+
+ let option = options.find(
+ (o) => o.name === name || (o.type === "function" && o.aliases?.includes(name)),
+ );
+
+ if (option == null) {
+ const from = node.from; // Cache here so the reference doesn't change
+ option = {
+ type: "variable",
+ invalid: true,
+ name: inner,
+ value: null,
+ label: inner,
+ onClick: () => {
+ onClickMissingVariable(name, rawTag, from);
+ },
+ };
+ }
+
+ if (option.type === "function") {
+ const tokens = parseTemplate(rawTag);
+ const rawValues = collectArgumentValues(tokens, option);
+ const values = applyFormInputDefaults(option.args, rawValues);
+ const label = makeFunctionLabel(option, values);
+ const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
+ option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
+ }
+
+ const widget = new TemplateTagWidget(option, rawTag, node.from);
+ const deco = Decoration.replace({ widget, inclusive: true });
+ widgets.push(deco.range(node.from, node.to));
+ }
+ },
+ });
+ }
+
+ // Widgets must be sorted start to end
+ widgets.sort((a, b) => a.from - b.from);
+
+ return Decoration.set(widgets);
+}
+
+export function templateTagsPlugin(
+ options: TwigCompletionOption[],
+ onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
+) {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = templateTags(view, options, onClickMissingVariable);
+ }
+
+ update(update: ViewUpdate) {
+ this.decorations = templateTags(update.view, options, onClickMissingVariable);
+ }
+ },
+ {
+ decorations(v) {
+ return v.decorations;
+ },
+ provide(plugin) {
+ return EditorView.atomicRanges.of((view) => {
+ return view.plugin(plugin)?.decorations || Decoration.none;
+ });
+ },
+ },
+ );
+}
+
+function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
+ for (const r of view.state.selection.ranges) {
+ if (r.from > node.from && r.to < node.to) return true;
+ }
+ return false;
+}
+
+function makeFunctionLabel(
+ fn: TemplateFunction,
+ values: { [p: string]: JsonPrimitive | undefined },
+): HTMLElement | string {
+ if (fn.args.length === 0) return fn.name;
+
+ const $outer = document.createElement("span");
+ $outer.className = "fn";
+ const $bOpen = document.createElement("span");
+ $bOpen.className = "fn-bracket";
+ $bOpen.textContent = "(";
+ $outer.appendChild(document.createTextNode(fn.name));
+ $outer.appendChild($bOpen);
+
+ const $inner = document.createElement("span");
+ $inner.className = "fn-inner";
+ $inner.title = "";
+ fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
+ const v = String(values[name] || "");
+ if (!v) return;
+ if (all.length > 1) {
+ const $c = document.createElement("span");
+ $c.className = "fn-arg-name";
+ $c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
+ $inner.appendChild($c);
+ }
+
+ const $v = document.createElement("span");
+ $v.className = "fn-arg-value";
+ $v.textContent = v.includes(" ") ? `'${v}'` : v;
+ $inner.appendChild($v);
+ });
+ fn.args.forEach((a: FormInput, i: number) => {
+ if (!("name" in a)) return;
+ const v = values[a.name];
+ if (v == null) return;
+ if (i > 0) $inner.title += "\n";
+ $inner.title += `${a.name} = ${JSON.stringify(v)}`;
+ });
+
+ if ($inner.childNodes.length === 0) {
+ $inner.appendChild(document.createTextNode("…"));
+ }
+
+ $outer.appendChild($inner);
+
+ const $bClose = document.createElement("span");
+ $bClose.className = "fn-bracket";
+ $bClose.textContent = ")";
+ $outer.appendChild($bClose);
+
+ return $outer;
+}
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.grammar b/apps/yaak-client/components/core/Editor/twig/twig.grammar
new file mode 100644
index 0000000..6ca447c
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.grammar
@@ -0,0 +1,17 @@
+@top Template { (Tag | Text)* }
+
+@local tokens {
+ TagClose { "]}" }
+ @else TagContent
+}
+
+@skip { } {
+ TagOpen { "${[" }
+ Tag { TagOpen (TagContent)+ TagClose }
+}
+
+@tokens {
+ Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.terms.ts b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts
new file mode 100644
index 0000000..7ac3c79
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.terms.ts
@@ -0,0 +1,7 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const Template = 1,
+ Tag = 2,
+ TagOpen = 3,
+ TagContent = 4,
+ TagClose = 5,
+ Text = 6;
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.test.ts b/apps/yaak-client/components/core/Editor/twig/twig.test.ts
new file mode 100644
index 0000000..2ce95f0
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.test.ts
@@ -0,0 +1,108 @@
+/* oxlint-disable no-template-curly-in-string */
+
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./twig";
+
+function getNodeNames(input: string): string[] {
+ const tree = parser.parse(input);
+ const nodes: string[] = [];
+ const cursor = tree.cursor();
+ do {
+ if (cursor.name !== "Template") {
+ nodes.push(cursor.name);
+ }
+ } while (cursor.next());
+ return nodes;
+}
+
+function hasTag(input: string): boolean {
+ return getNodeNames(input).includes("Tag");
+}
+
+function hasError(input: string): boolean {
+ return getNodeNames(input).includes("⚠");
+}
+
+describe("twig grammar", () => {
+ describe("${[var]} format (valid template tags)", () => {
+ test("parses simple variable as Tag", () => {
+ expect(hasTag("${[var]}")).toBe(true);
+ expect(hasError("${[var]}")).toBe(false);
+ });
+
+ test("parses variable with whitespace as Tag", () => {
+ expect(hasTag("${[ var ]}")).toBe(true);
+ expect(hasError("${[ var ]}")).toBe(false);
+ });
+
+ test("parses embedded variable as Tag", () => {
+ expect(hasTag("hello ${[name]} world")).toBe(true);
+ expect(hasError("hello ${[name]} world")).toBe(false);
+ });
+
+ test("parses function call as Tag", () => {
+ expect(hasTag("${[fn()]}")).toBe(true);
+ expect(hasError("${[fn()]}")).toBe(false);
+ });
+ });
+
+ describe("${var} format (should be plain text, not tags)", () => {
+ test("parses ${var} as plain Text without errors", () => {
+ expect(hasTag("${var}")).toBe(false);
+ expect(hasError("${var}")).toBe(false);
+ });
+
+ test("parses embedded ${var} as plain Text", () => {
+ expect(hasTag("hello ${name} world")).toBe(false);
+ expect(hasError("hello ${name} world")).toBe(false);
+ });
+
+ test("parses JSON with ${var} as plain Text", () => {
+ const json = '{"key": "${value}"}';
+ expect(hasTag(json)).toBe(false);
+ expect(hasError(json)).toBe(false);
+ });
+
+ test("parses multiple ${var} as plain Text", () => {
+ expect(hasTag("${a} and ${b}")).toBe(false);
+ expect(hasError("${a} and ${b}")).toBe(false);
+ });
+ });
+
+ describe("mixed content", () => {
+ test("distinguishes ${var} from ${[var]} in same string", () => {
+ const input = "${plain} and ${[tag]}";
+ expect(hasTag(input)).toBe(true);
+ expect(hasError(input)).toBe(false);
+ });
+
+ test("parses JSON with ${[var]} as having Tag", () => {
+ const json = '{"key": "${[value]}"}';
+ expect(hasTag(json)).toBe(true);
+ expect(hasError(json)).toBe(false);
+ });
+ });
+
+ describe("edge cases", () => {
+ test("handles $ at end of string", () => {
+ expect(hasError("hello$")).toBe(false);
+ expect(hasTag("hello$")).toBe(false);
+ });
+
+ test("handles ${ at end of string without crash", () => {
+ // Incomplete syntax may produce errors, but should not crash
+ expect(() => parser.parse("hello${")).not.toThrow();
+ });
+
+ test("handles ${[ without closing without crash", () => {
+ // Unclosed tag may produce partial match, but should not crash
+ expect(() => parser.parse("${[unclosed")).not.toThrow();
+ });
+
+ test("handles empty ${[]}", () => {
+ // Empty tags may or may not be valid depending on grammar
+ // Just ensure no crash
+ expect(() => parser.parse("${[]}")).not.toThrow();
+ });
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/twig.ts b/apps/yaak-client/components/core/Editor/twig/twig.ts
new file mode 100644
index 0000000..78af862
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/twig.ts
@@ -0,0 +1,20 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import { LocalTokenGroup, LRParser } from "@lezer/lr";
+import { highlight } from "./highlight";
+export const parser = LRParser.deserialize({
+ version: 14,
+ states:
+ "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
+ stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
+ goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
+ nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
+ maxTerm: 10,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 2,
+ tokenData:
+ "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
+ tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
+ topRules: { Template: [0, 1] },
+ tokenPrec: 0,
+});
diff --git a/apps/yaak-client/components/core/Editor/twig/util.ts b/apps/yaak-client/components/core/Editor/twig/util.ts
new file mode 100644
index 0000000..330ea85
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/twig/util.ts
@@ -0,0 +1,37 @@
+import type { FormInput, TemplateFunction } from "@yaakapp-internal/plugins";
+import type { Tokens } from "@yaakapp-internal/templates";
+
+/**
+ * Process the initial tokens from the template and merge those with the default values pulled from
+ * the template function definition.
+ */
+export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
+ const initial: Record = {};
+ const initialArgs =
+ initialTokens.tokens[0]?.type === "tag" && initialTokens.tokens[0]?.val.type === "fn"
+ ? initialTokens.tokens[0]?.val.args
+ : [];
+
+ const processArg = (arg: FormInput) => {
+ if ("inputs" in arg && arg.inputs) {
+ arg.inputs.forEach(processArg);
+ }
+ if (!("name" in arg)) return;
+
+ const initialArg = initialArgs.find((a) => a.name === arg.name);
+ const initialArgValue =
+ initialArg?.value.type === "str"
+ ? initialArg?.value.text
+ : initialArg?.value.type === "bool"
+ ? initialArg.value.value
+ : undefined;
+ const value = initialArgValue ?? arg.defaultValue;
+ if (value != null) {
+ initial[arg.name] = value;
+ }
+ };
+
+ templateFunction.args.forEach(processArg);
+
+ return initial;
+}
diff --git a/apps/yaak-client/components/core/Editor/url/completion.test.ts b/apps/yaak-client/components/core/Editor/url/completion.test.ts
new file mode 100644
index 0000000..57e403a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/completion.test.ts
@@ -0,0 +1,49 @@
+import type { Completion } from "@codemirror/autocomplete";
+import { EditorState, type TransactionSpec } from "@codemirror/state";
+import type { EditorView } from "@codemirror/view";
+import { describe, expect, test } from "vite-plus/test";
+import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
+
+describe("applyUrlCompletion", () => {
+ test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
+ expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
+ "http://rickandmortyapi.com/api/character",
+ );
+ });
+
+ test("inserts a protocol when there is no existing suffix", () => {
+ expect(applyCompletion("htt", "http://", 3)).toBe("http://");
+ });
+
+ test("replaces the full URL when accepting a saved URL", () => {
+ expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
+ "https://new.example/api",
+ );
+ });
+});
+
+describe("getUrlCompletionConfig", () => {
+ test("always includes protocols alongside saved URL options", () => {
+ const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
+
+ expect(config.options.map((option) => option.label)).toEqual([
+ "http://",
+ "https://",
+ "https://example.com",
+ ]);
+ expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
+ });
+});
+
+function applyCompletion(document: string, label: string, cursor: number) {
+ let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
+ const view = {
+ state,
+ dispatch: (spec: TransactionSpec) => {
+ state = state.update(spec).state;
+ },
+ } as unknown as EditorView;
+
+ applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
+ return state.doc.toString();
+}
diff --git a/apps/yaak-client/components/core/Editor/url/completion.ts b/apps/yaak-client/components/core/Editor/url/completion.ts
new file mode 100644
index 0000000..4a7c9a2
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/completion.ts
@@ -0,0 +1,51 @@
+import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
+import type { EditorView } from "@codemirror/view";
+import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
+import {
+ genericCompletion,
+ type GenericCompletion,
+ type GenericCompletionConfig,
+} from "../genericCompletion";
+
+const protocolOptions: GenericCompletionOption[] = [
+ { label: "http://", type: "constant" },
+ { label: "https://", type: "constant" },
+];
+
+export function getUrlCompletionConfig(
+ options: GenericCompletionOption[],
+ minMatch = 3,
+): GenericCompletionConfig {
+ const urlOptions = [
+ ...protocolOptions,
+ ...options.filter(
+ (option) => !protocolOptions.some((protocol) => protocol.label === option.label),
+ ),
+ ];
+ return {
+ minMatch,
+ options: urlOptions.map((option) => ({
+ ...option,
+ apply: applyUrlCompletion,
+ })),
+ };
+}
+
+export function applyUrlCompletion(
+ view: EditorView,
+ completion: Completion,
+ from: number,
+ to: number,
+) {
+ const isProtocol = /^https?:\/\/$/.test(completion.label);
+ const replaceTo = isProtocol
+ ? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
+ : view.state.doc.length;
+
+ view.dispatch({
+ ...insertCompletionText(view.state, completion.label, from, replaceTo),
+ annotations: pickedCompletion.of(completion),
+ });
+}
+
+export const completions = genericCompletion(getUrlCompletionConfig([], 1));
diff --git a/apps/yaak-client/components/core/Editor/url/extension.ts b/apps/yaak-client/components/core/Editor/url/extension.ts
new file mode 100644
index 0000000..e900b82
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/extension.ts
@@ -0,0 +1,12 @@
+import { LanguageSupport, LRLanguage } from "@codemirror/language";
+import { parser } from "./url";
+
+const urlLanguage = LRLanguage.define({
+ name: "url",
+ parser,
+ languageData: {},
+});
+
+export function url() {
+ return new LanguageSupport(urlLanguage, []);
+}
diff --git a/apps/yaak-client/components/core/Editor/url/highlight.ts b/apps/yaak-client/components/core/Editor/url/highlight.ts
new file mode 100644
index 0000000..69c8ac9
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/highlight.ts
@@ -0,0 +1,10 @@
+import { styleTags, tags as t } from "@lezer/highlight";
+
+export const highlight = styleTags({
+ Protocol: t.comment,
+ Placeholder: t.emphasis,
+ // PathSegment: t.tagName,
+ // Host: t.variableName,
+ // Path: t.bool,
+ // Query: t.string,
+});
diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar
new file mode 100644
index 0000000..5e9c80d
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.grammar
@@ -0,0 +1,24 @@
+// Host is optional so URLs starting with `/` go straight to Path. Without this,
+// the parser error-recovers past the leading `/` and consumes the first segment as
+// Host (since Host's char class includes `:` for `host:port`), eating an initial
+// `:name` placeholder like `/:foo/:bar`.
+@top url { Protocol? Host? Path? Query? }
+
+Path { ("/" PathSegment)+ }
+
+Placeholder { ":" pathChars }
+PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
+
+Query { "?" queryPair ("&" queryPair)* }
+
+@tokens {
+ Protocol { $[a-zA-Z]+ "://" }
+ Host { $[a-zA-Z0-9-_.:\[\]]+ }
+ @precedence { Protocol, Host }
+
+ pathChars { ![/?#:]+ }
+
+ queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
+}
+
+@external propSource highlight from "./highlight"
diff --git a/apps/yaak-client/components/core/Editor/url/url.terms.ts b/apps/yaak-client/components/core/Editor/url/url.terms.ts
new file mode 100644
index 0000000..3af7569
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.terms.ts
@@ -0,0 +1,9 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+export const
+ url = 1,
+ Protocol = 2,
+ Host = 3,
+ Path = 4,
+ PathSegment = 5,
+ Placeholder = 6,
+ Query = 7
diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts
new file mode 100644
index 0000000..8585d9a
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "vite-plus/test";
+import { parser } from "./url";
+
+function expectValidParse(input: string) {
+ expect(parser.parse(input).toString()).not.toContain("⚠");
+}
+
+function placeholderValues(input: string): string[] {
+ const values: string[] = [];
+ parser
+ .parse(input)
+ .cursor()
+ .iterate((node) => {
+ if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
+ });
+ return values;
+}
+
+describe("URL grammar Placeholder", () => {
+ test("recognizes path placeholders", () => {
+ expectValidParse("https://x.com/users/:id");
+ expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
+ });
+
+ test("treats a colon suffix as literal path text", () => {
+ expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
+ expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
+ });
+
+ test("treats repeated colon suffixes as literal path text", () => {
+ expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
+ expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
+ });
+
+ test("does not recognize a colon in the middle of a plain path segment", () => {
+ expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
+ expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
+ });
+
+ test("does not recognize query parameters as path placeholders", () => {
+ expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
+ });
+
+ test("recognizes placeholders in a path fragment after a templated base URL", () => {
+ // Mixed Twig parsing can feed the URL parser only the text after a template tag,
+ // as in `${[ URL ]}/x/:foo/:hello`.
+ expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
+ ":foo",
+ ":hello",
+ ]);
+ });
+});
diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts
new file mode 100644
index 0000000..359d4de
--- /dev/null
+++ b/apps/yaak-client/components/core/Editor/url/url.ts
@@ -0,0 +1,18 @@
+// This file was generated by lezer-generator. You probably shouldn't edit it.
+import {LRParser} from "@lezer/lr"
+import {highlight} from "./highlight"
+export const parser = LRParser.deserialize({
+ version: 14,
+ states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
+ stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
+ goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
+ nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
+ maxTerm: 17,
+ propSources: [highlight],
+ skippedNodes: [0],
+ repeatNodeCount: 3,
+ tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!QZ!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
+ tokenizers: [0, 1, 2],
+ topRules: {"url":[0,1]},
+ tokenPrec: 99
+})
diff --git a/apps/yaak-client/components/core/EventViewer.tsx b/apps/yaak-client/components/core/EventViewer.tsx
new file mode 100644
index 0000000..77ecfc9
--- /dev/null
+++ b/apps/yaak-client/components/core/EventViewer.tsx
@@ -0,0 +1,312 @@
+import type { Virtualizer } from "@tanstack/react-virtual";
+import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import { format } from "date-fns";
+import type { ReactNode } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useEventViewerKeyboard } from "../../hooks/useEventViewerKeyboard";
+import { CopyIconButton } from "../CopyIconButton";
+import { AutoScroller } from "./AutoScroller";
+import { Button } from "./Button";
+import { IconButton } from "./IconButton";
+import type { SelectProps } from "./Select";
+import { Select } from "./Select";
+import { Separator } from "./Separator";
+
+interface EventViewerProps {
+ /** Array of events to display */
+ events: T[];
+
+ /** Get unique key for each event */
+ getEventKey: (event: T, index: number) => string;
+
+ /** Render the event row - receives event, index, isActive, and onClick */
+ renderRow: (props: {
+ event: T;
+ index: number;
+ isActive: boolean;
+ onClick: () => void;
+ }) => ReactNode;
+
+ /** Render the detail pane for the selected event */
+ renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
+
+ /** Optional header above the event list (e.g., connection status) */
+ header?: ReactNode;
+
+ /** Error message to display as a banner */
+ error?: string | null;
+
+ /** Key for SplitLayout state persistence */
+ splitLayoutStorageKey: string;
+
+ /** Default ratio for the split (0.0 - 1.0) */
+ defaultRatio?: number;
+
+ /** Enable keyboard navigation (arrow keys) */
+ enableKeyboardNav?: boolean;
+
+ /** Loading state */
+ isLoading?: boolean;
+
+ /** Message to show while loading */
+ loadingMessage?: string;
+
+ /** Message to show when no events */
+ emptyMessage?: string;
+
+ /** Callback when active index changes (for controlled state in parent) */
+ onActiveIndexChange?: (index: number | null) => void;
+}
+
+export function EventViewer({
+ events,
+ getEventKey,
+ renderRow,
+ renderDetail,
+ header,
+ error,
+ splitLayoutStorageKey,
+ defaultRatio = 0.4,
+ enableKeyboardNav = true,
+ isLoading = false,
+ loadingMessage = "Loading events...",
+ emptyMessage = "No events recorded",
+ onActiveIndexChange,
+}: EventViewerProps) {
+ const [activeIndex, setActiveIndexInternal] = useState(null);
+ const [isPanelOpen, setIsPanelOpen] = useState(false);
+
+ // Wrap setActiveIndex to notify parent
+ const setActiveIndex = useCallback(
+ (indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
+ setActiveIndexInternal((prev) => {
+ const newIndex =
+ typeof indexOrUpdater === "function" ? indexOrUpdater(prev) : indexOrUpdater;
+ onActiveIndexChange?.(newIndex);
+ return newIndex;
+ });
+ },
+ [onActiveIndexChange],
+ );
+ const containerRef = useRef(null);
+ const virtualizerRef = useRef | null>(null);
+
+ const activeEvent = useMemo(
+ () => (activeIndex != null ? events[activeIndex] : null),
+ [activeIndex, events],
+ );
+
+ // Check if the event list container is focused
+ const isContainerFocused = useCallback(() => {
+ return containerRef.current?.contains(document.activeElement) ?? false;
+ }, []);
+
+ // Keyboard navigation
+ useEventViewerKeyboard({
+ totalCount: events.length,
+ activeIndex,
+ setActiveIndex,
+ virtualizer: virtualizerRef.current,
+ isContainerFocused,
+ enabled: enableKeyboardNav,
+ closePanel: () => setIsPanelOpen(false),
+ openPanel: () => setIsPanelOpen(true),
+ });
+
+ // Handle virtualizer ready callback
+ const handleVirtualizerReady = useCallback(
+ (virtualizer: Virtualizer) => {
+ virtualizerRef.current = virtualizer;
+ },
+ [],
+ );
+
+ // Handle row click - select and open panel, scroll into view
+ const handleRowClick = useCallback(
+ (index: number) => {
+ setActiveIndex(index);
+ setIsPanelOpen(true);
+ // Scroll to ensure selected item is visible after panel opens
+ requestAnimationFrame(() => {
+ virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
+ });
+ },
+ [setActiveIndex],
+ );
+
+ const handleClose = useCallback(() => {
+ setIsPanelOpen(false);
+ }, []);
+
+ if (isLoading) {
+ return {loadingMessage}
;
+ }
+
+ if (events.length === 0 && !error) {
+ return {emptyMessage}
;
+ }
+
+ return (
+
+
(
+
+ {header ??
}
+
+ {error}
+
+ )
+ }
+ render={(event, index) => (
+
+ {renderRow({
+ event,
+ index,
+ isActive: index === activeIndex,
+ onClick: () => handleRowClick(index),
+ })}
+
+ )}
+ />
+
+ )}
+ secondSlot={
+ activeEvent != null && renderDetail && isPanelOpen
+ ? ({ style }) => (
+
+
+
+
+
+ {renderDetail({
+ event: activeEvent,
+ index: activeIndex ?? 0,
+ onClose: handleClose,
+ })}
+
+
+ )
+ : null
+ }
+ />
+
+ );
+}
+
+export type EventDetailAction =
+ | {
+ type?: "button";
+ /** Unique key for React */
+ key: string;
+ /** Button label */
+ label: string;
+ /** Optional icon */
+ icon?: ReactNode;
+ /** Click handler */
+ onClick: () => void;
+ }
+ | {
+ type: "select";
+ /** Unique key for React */
+ key: string;
+ /** Select label */
+ label: string;
+ /** Selected value */
+ value: string;
+ /** Select options */
+ options: SelectProps["options"];
+ /** Change handler */
+ onChange: (value: string) => void;
+ };
+
+interface EventDetailHeaderProps {
+ title: string;
+ prefix?: ReactNode;
+ timestamp?: string;
+ actions?: EventDetailAction[];
+ copyText?: string | (() => Promise);
+ onClose?: () => void;
+}
+
+export function EventDetailHeader({
+ title,
+ prefix,
+ timestamp,
+ actions,
+ copyText,
+ onClose,
+}: EventDetailHeaderProps) {
+ const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), "HH:mm:ss.SSS") : null;
+
+ return (
+
+
+ {prefix}
+ {title}
+
+
+ {actions?.map((action) =>
+ action.type === "select" ? (
+
+
+
+ ) : (
+
+ {action.icon}
+ {action.label}
+
+ ),
+ )}
+ {copyText != null && (
+
+ )}
+ {formattedTime && (
+ {formattedTime}
+ )}
+ {onClose != null && (
+ 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
+ )}
+ >
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/EventViewerRow.tsx b/apps/yaak-client/components/core/EventViewerRow.tsx
new file mode 100644
index 0000000..6247dfc
--- /dev/null
+++ b/apps/yaak-client/components/core/EventViewerRow.tsx
@@ -0,0 +1,38 @@
+import classNames from "classnames";
+import { format } from "date-fns";
+import type { ReactNode } from "react";
+
+interface EventViewerRowProps {
+ isActive: boolean;
+ onClick: () => void;
+ icon: ReactNode;
+ content: ReactNode;
+ timestamp?: string;
+}
+
+export function EventViewerRow({
+ isActive,
+ onClick,
+ icon,
+ content,
+ timestamp,
+}: EventViewerRowProps) {
+ return (
+
+
+ {icon}
+ {content}
+ {timestamp && {format(`${timestamp}Z`, "HH:mm:ss.SSS")}
}
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Hotkey.tsx b/apps/yaak-client/components/core/Hotkey.tsx
new file mode 100644
index 0000000..faa86ce
--- /dev/null
+++ b/apps/yaak-client/components/core/Hotkey.tsx
@@ -0,0 +1,45 @@
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useFormattedHotkey } from "../../hooks/useHotKey";
+
+interface Props {
+ action: HotkeyAction | null;
+ className?: string;
+ variant?: "text" | "with-bg";
+}
+
+export function Hotkey({ action, className, variant }: Props) {
+ const labelParts = useFormattedHotkey(action);
+ if (labelParts === null) {
+ return null;
+ }
+
+ return ;
+}
+
+interface HotkeyRawProps {
+ labelParts: string[];
+ className?: string;
+ variant?: "text" | "with-bg";
+}
+
+export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
+ return (
+
+ {labelParts.map((char, index) => (
+ // oxlint-disable-next-line react/no-array-index-key
+
+ {char}
+
+ ))}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/HotkeyLabel.tsx b/apps/yaak-client/components/core/HotkeyLabel.tsx
new file mode 100644
index 0000000..ab2e736
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyLabel.tsx
@@ -0,0 +1,15 @@
+import classNames from "classnames";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useHotkeyLabel } from "../../hooks/useHotKey";
+
+interface Props {
+ action: HotkeyAction;
+ className?: string;
+}
+
+export function HotkeyLabel({ action, className }: Props) {
+ const label = useHotkeyLabel(action);
+ return (
+ {label}
+ );
+}
diff --git a/apps/yaak-client/components/core/HotkeyList.test.tsx b/apps/yaak-client/components/core/HotkeyList.test.tsx
new file mode 100644
index 0000000..47d99f4
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyList.test.tsx
@@ -0,0 +1,25 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, test, vi } from "vite-plus/test";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { HotkeyList } from "./HotkeyList";
+
+vi.mock("./Hotkey", () => ({
+ Hotkey: ({ action }: { action: HotkeyAction }) =>
+ action === "sidebar.selected.move" ? null : {action} ,
+}));
+
+vi.mock("./HotkeyLabel", () => ({
+ HotkeyLabel: ({ action }: { action: HotkeyAction }) => {action} ,
+}));
+
+describe("HotkeyList", () => {
+ test("keeps a grid cell for actions without a shortcut", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain(
+ 'sidebar.selected.move
request.send ',
+ );
+ });
+});
diff --git a/apps/yaak-client/components/core/HotkeyList.tsx b/apps/yaak-client/components/core/HotkeyList.tsx
new file mode 100644
index 0000000..91f9025
--- /dev/null
+++ b/apps/yaak-client/components/core/HotkeyList.tsx
@@ -0,0 +1,31 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { Fragment } from "react";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { Hotkey } from "./Hotkey";
+import { HotkeyLabel } from "./HotkeyLabel";
+
+interface Props {
+ hotkeys: HotkeyAction[];
+ bottomSlot?: ReactNode;
+ className?: string;
+}
+
+export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
+ return (
+
+
+ {hotkeys.map((hotkey) => (
+
+
+ {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
+
+
+
+
+ ))}
+ {bottomSlot}
+
+
+ );
+};
diff --git a/apps/yaak-client/components/core/HttpMethodTag.tsx b/apps/yaak-client/components/core/HttpMethodTag.tsx
new file mode 100644
index 0000000..0184c2b
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpMethodTag.tsx
@@ -0,0 +1,91 @@
+import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
+import { settingsAtom } from "@yaakapp-internal/models";
+import classNames from "classnames";
+import { useAtomValue } from "jotai";
+import { 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 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();
+
+ const settings = useAtomValue(settingsAtom);
+ const colored = forceColor || settings.coloredMethods;
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx
new file mode 100644
index 0000000..93709aa
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpResponseDurationTag.tsx
@@ -0,0 +1,45 @@
+import type { HttpResponse } from "@yaakapp-internal/models";
+import { useEffect, useRef, useState } from "react";
+
+interface Props {
+ response: HttpResponse;
+}
+
+export function HttpResponseDurationTag({ response }: Props) {
+ const [fallbackElapsed, setFallbackElapsed] = useState(0);
+ const timeout = useRef>(undefined);
+
+ // Calculate the duration of the response for use when the response hasn't finished yet
+ useEffect(() => {
+ clearInterval(timeout.current);
+ if (response.state === "closed") return;
+ timeout.current = setInterval(() => {
+ setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime());
+ }, 100);
+ return () => clearInterval(timeout.current);
+ }, [response.createdAt, response.state]);
+
+ const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : "--";
+ const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
+
+ const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed;
+
+ return (
+
+ {formatMillis(elapsed)}
+
+ );
+}
+
+export function formatMillis(ms: number) {
+ if (ms < 1000) {
+ return `${ms} ms`;
+ }
+ if (ms < 60_000) {
+ const seconds = (ms / 1000).toFixed(ms < 10_000 ? 1 : 0);
+ return `${seconds} s`;
+ }
+ const minutes = Math.floor(ms / 60_000);
+ const seconds = Math.round((ms % 60_000) / 1000);
+ return `${minutes}m ${seconds}s`;
+}
diff --git a/apps/yaak-client/components/core/HttpStatusTag.tsx b/apps/yaak-client/components/core/HttpStatusTag.tsx
new file mode 100644
index 0000000..a705f52
--- /dev/null
+++ b/apps/yaak-client/components/core/HttpStatusTag.tsx
@@ -0,0 +1,55 @@
+import type { HttpResponse, HttpResponseState } from "@yaakapp-internal/models";
+import classNames from "classnames";
+
+interface Props {
+ response: HttpResponse;
+ className?: string;
+ showReason?: boolean;
+ short?: boolean;
+}
+
+export function HttpStatusTag({ response, ...props }: Props) {
+ const { status, state, statusReason } = response;
+ return ;
+}
+
+export function HttpStatusTagRaw({
+ status,
+ state,
+ className,
+ showReason,
+ statusReason,
+ short,
+}: Omit & {
+ status: number | string;
+ state?: HttpResponseState;
+ statusReason?: string | null;
+}) {
+ let colorClass: string;
+ let label = `${status}`;
+ const statusN = typeof status === "number" ? status : parseInt(status, 10);
+
+ if (state === "initialized") {
+ label = short ? "CONN" : "CONNECTING";
+ colorClass = "text-text-subtle";
+ } else if (statusN < 100) {
+ label = short ? "ERR" : "ERROR";
+ colorClass = "text-danger";
+ } else if (statusN < 200) {
+ colorClass = "text-info";
+ } else if (statusN < 300) {
+ colorClass = "text-success";
+ } else if (statusN < 400) {
+ colorClass = "text-primary";
+ } else if (statusN < 500) {
+ colorClass = "text-warning";
+ } else {
+ colorClass = "text-danger";
+ }
+
+ return (
+
+ {label} {showReason && statusReason}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/IconButton.tsx b/apps/yaak-client/components/core/IconButton.tsx
new file mode 100644
index 0000000..26b642a
--- /dev/null
+++ b/apps/yaak-client/components/core/IconButton.tsx
@@ -0,0 +1,37 @@
+import {
+ IconButton as BaseIconButton,
+ type IconButtonProps as BaseIconButtonProps,
+} from "@yaakapp-internal/ui";
+import { forwardRef, useImperativeHandle, useRef } from "react";
+import type { HotkeyAction } from "../../hooks/useHotKey";
+import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey";
+
+export type IconButtonProps = BaseIconButtonProps & {
+ hotkeyAction?: HotkeyAction;
+ hotkeyLabelOnly?: boolean;
+ hotkeyPriority?: number;
+};
+
+export const IconButton = forwardRef(function IconButton(
+ { hotkeyAction, hotkeyPriority, hotkeyLabelOnly, title, ...props }: IconButtonProps,
+ ref,
+) {
+ const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join("");
+ const fullTitle = hotkeyTrigger ? `${title ?? ""} ${hotkeyTrigger}`.trim() : title;
+
+ const buttonRef = useRef(null);
+ useImperativeHandle(
+ ref,
+ () => buttonRef.current,
+ );
+
+ useHotKey(
+ hotkeyAction ?? null,
+ () => {
+ buttonRef.current?.click();
+ },
+ { priority: hotkeyPriority, enable: !hotkeyLabelOnly },
+ );
+
+ return ;
+});
diff --git a/apps/yaak-client/components/core/IconTooltip.tsx b/apps/yaak-client/components/core/IconTooltip.tsx
new file mode 100644
index 0000000..3886048
--- /dev/null
+++ b/apps/yaak-client/components/core/IconTooltip.tsx
@@ -0,0 +1,30 @@
+import { Icon, type IconProps } from "@yaakapp-internal/ui";
+import type { TooltipProps } from "./Tooltip";
+import { Tooltip } from "./Tooltip";
+
+type Props = Omit & {
+ icon?: IconProps["icon"];
+ iconSize?: IconProps["size"];
+ iconColor?: IconProps["color"];
+ className?: string;
+ tabIndex?: number;
+};
+
+export function IconTooltip({
+ content,
+ icon = "info",
+ iconColor,
+ iconSize,
+ ...tooltipProps
+}: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Input.tsx b/apps/yaak-client/components/core/Input.tsx
new file mode 100644
index 0000000..c006327
--- /dev/null
+++ b/apps/yaak-client/components/core/Input.tsx
@@ -0,0 +1,677 @@
+import type { EditorView } from "@codemirror/view";
+import type { Color } from "@yaakapp-internal/plugins";
+import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } 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,
+} from "../../lib/setupOrConfigureEncryption";
+import { Button } from "./Button";
+import type { DropdownItem } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+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 interface InputHandle {
+ focus: () => void;
+ isFocused: () => boolean;
+ value: () => string;
+ selectAll: () => void;
+ dispatch: EditorView["dispatch"];
+}
+
+export function Input({ type, ...props }: InputProps) {
+ // If it's a password and template functions are supported (ie. secure(...)) then
+ // use the encrypted input component.
+ if (type === "password" && props.autocompleteFunctions) {
+ return ;
+ }
+ return ;
+}
+
+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,
+ 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 },
+ });
+ },
+ }),
+ [],
+ );
+
+ // 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],
+ );
+
+ return (
+
+
+ {label}
+
+
+ {tint != null && (
+
+ )}
+ {leftSlot}
+
+
+
+ {type === "password" && !disableObscureToggle && (
+ setObscured((o) => !o)}
+ />
+ )}
+ {pickerOptions.length > 0 && (
+
+ )}
+ {rightSlot}
+
+
+ );
+}
+
+/**
+ * Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
+ * typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
+ * short name (e.g. a User-Agent).
+ *
+ * Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
+ * options, so this would only add a stop between fields. Hidden when the nearest container is
+ * narrow and the input needs the width more — written as a `max` query so it stays visible for
+ * inputs that have no container ancestor at all.
+ */
+function OptionsPickerDropdown({
+ options,
+ onSelect,
+}: {
+ options: GenericCompletion[];
+ onSelect: (value: string) => void;
+}) {
+ const items = useMemo(
+ () =>
+ options.map((o) => ({
+ label: o.label,
+ onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
+ })),
+ [options, onSelect],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function validateRequire(v: string) {
+ return v.length > 0;
+}
+
+type PasswordFieldType = "text" | "encrypted";
+
+function EncryptionInput({
+ defaultValue,
+ onChange,
+ autocompleteFunctions,
+ autocompleteVariables,
+ forceUpdateKey: ogForceUpdateKey,
+ setRef,
+ ...props
+}: InputProps) {
+ const isEncryptionEnabled = useIsEncryptionEnabled();
+ const [state, setState] = useStateWithDeps<{
+ fieldType: PasswordFieldType;
+ value: string | null;
+ security: ReturnType | null;
+ obscured: boolean;
+ error: string | null;
+ }>(
+ {
+ fieldType: isEncryptionEnabled ? "encrypted" : "text",
+ value: null,
+ security: null,
+ obscured: true,
+ error: null,
+ },
+ [ogForceUpdateKey],
+ );
+
+ const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (state.value != null) {
+ // We already configured it
+ return;
+ }
+
+ const security = analyzeTemplate(defaultValue ?? "");
+ if (analyzeTemplate(defaultValue ?? "") === "global_secured") {
+ // Lazily update value to decrypted representation
+ templateToInsecure.mutate(defaultValue ?? "", {
+ onSuccess: (value) => {
+ setState({ fieldType: "encrypted", security, value, obscured: true, error: null });
+ // We're calling this here because we want the input to be fully initialized so the caller
+ // can do stuff like change the selection.
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ },
+ onError: (value) => {
+ setState({
+ fieldType: "encrypted",
+ security,
+ value: null,
+ error: String(value),
+ obscured: true,
+ });
+ },
+ });
+ } else if (isEncryptionEnabled && !defaultValue) {
+ // Default to encrypted field for new encrypted inputs
+ setState({ fieldType: "encrypted", security, value: "", obscured: true, error: null });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ } else if (isEncryptionEnabled) {
+ // Don't obscure plain text when encryption is enabled
+ setState({
+ fieldType: "text",
+ security,
+ value: defaultValue ?? "",
+ obscured: false,
+ error: null,
+ });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ } else {
+ // Don't obscure plain text when encryption is disabled
+ setState({
+ fieldType: "text",
+ security,
+ value: defaultValue ?? "",
+ obscured: true,
+ error: null,
+ });
+ requestAnimationFrame(() => setRef?.(inputRef.current));
+ }
+ }, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
+
+ const handleChange = useCallback(
+ (value: string, fieldType: PasswordFieldType) => {
+ if (fieldType === "encrypted") {
+ templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });
+ } else {
+ onChange?.(value);
+ }
+ setState((s) => {
+ // We can't analyze when encrypted because we don't have the raw value, so assume it's secured
+ const security = fieldType === "encrypted" ? "global_secured" : analyzeTemplate(value);
+ // Reset obscured value when the field type is being changed
+ const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== "text";
+ return { fieldType, value, security, obscured, error: s.error };
+ });
+ },
+ [onChange, setState],
+ );
+
+ const handleInputChange = useCallback(
+ (value: string) => {
+ if (state.fieldType != null) {
+ handleChange(value, state.fieldType);
+ }
+ },
+ [handleChange, state],
+ );
+
+ const setInputRef = useCallback((h: InputHandle | null) => {
+ inputRef.current = h;
+ }, []);
+
+ const handleFieldTypeChange = useCallback(
+ (newFieldType: PasswordFieldType) => {
+ const { value, fieldType } = state;
+ if (value == null || fieldType === newFieldType) {
+ return;
+ }
+
+ withEncryptionEnabled(async () => {
+ const newValue = await convertTemplateToInsecure(value);
+ handleChange(newValue, newFieldType);
+ });
+ },
+ [handleChange, state],
+ );
+
+ const dropdownItems = useMemo(
+ () => [
+ {
+ label: state.obscured ? "Show" : "Hide",
+ disabled: isEncryptionEnabled && state.fieldType === "text",
+ leftSlot: ,
+ onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
+ },
+ {
+ label: "Copy",
+ leftSlot: ,
+ hidden: !state.value,
+ onSelect: () => copyToClipboard(state.value ?? ""),
+ },
+ { type: "separator" },
+ {
+ label: state.fieldType === "text" ? "Encrypt Field" : "Decrypt Field",
+ leftSlot: ,
+ onSelect: () => handleFieldTypeChange(state.fieldType === "text" ? "encrypted" : "text"),
+ },
+ ],
+ [
+ handleFieldTypeChange,
+ isEncryptionEnabled,
+ setState,
+ state.fieldType,
+ state.obscured,
+ state.value,
+ ],
+ );
+
+ let tint: InputProps["tint"];
+ if (!isEncryptionEnabled) {
+ tint = undefined;
+ } else if (state.fieldType === "encrypted") {
+ tint = "info";
+ } else if (state.security === "local_secured") {
+ tint = "secondary";
+ } else if (state.security === "insecure") {
+ tint = "notice";
+ }
+
+ const rightSlot = useMemo(() => {
+ let icon: IconProps["icon"];
+ if (isEncryptionEnabled) {
+ icon = state.security === "insecure" ? "shield_off" : "shield_check";
+ } else {
+ icon = state.obscured ? "eye_closed" : "eye";
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
+
+ const type = state.obscured ? "password" : "text";
+
+ if (state.error) {
+ return (
+ }
+ onClick={() => {
+ setupOrConfigureEncryption();
+ }}
+ >
+ {state.error.replace(/^Render Error: /i, "")}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const templateToSecure = createFastMutation({
+ mutationKey: ["template-to-secure"],
+ mutationFn: convertTemplateToSecure,
+});
+
+const templateToInsecure = createFastMutation({
+ mutationKey: ["template-to-insecure"],
+ mutationFn: convertTemplateToInsecure,
+ disableToastError: true,
+});
diff --git a/apps/yaak-client/components/core/JsonAttributeTree.tsx b/apps/yaak-client/components/core/JsonAttributeTree.tsx
new file mode 100644
index 0000000..58e6a4d
--- /dev/null
+++ b/apps/yaak-client/components/core/JsonAttributeTree.tsx
@@ -0,0 +1,146 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+import { useMemo, useState } from "react";
+import { Icon } from "@yaakapp-internal/ui";
+
+interface Props {
+ depth?: number;
+ // oxlint-disable-next-line no-explicit-any -- none
+ attrValue: any;
+ attrKey?: string | number;
+ attrKeyJsonPath?: string;
+ className?: string;
+}
+
+export const JsonAttributeTree = ({
+ depth = 0,
+ attrKey,
+ attrValue,
+ attrKeyJsonPath,
+ className,
+}: Props) => {
+ attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
+
+ const [isExpanded, setIsExpanded] = useState(true);
+ const toggleExpanded = () => setIsExpanded((v) => !v);
+
+ const { isExpandable, children, label, labelClassName } = useMemo<{
+ isExpandable: boolean;
+ children: ReactNode;
+ label?: string;
+ labelClassName?: string;
+ }>(() => {
+ const jsonType = Object.prototype.toString.call(attrValue);
+ if (jsonType === "[object Object]") {
+ return {
+ children: isExpanded
+ ? Object.keys(attrValue)
+ .sort((a, b) => a.localeCompare(b))
+ .flatMap((k) => (
+
+ ))
+ : null,
+ isExpandable: Object.keys(attrValue).length > 0,
+ label: isExpanded ? `{${Object.keys(attrValue).length || " "}}` : "{⋯}",
+ labelClassName: "text-text-subtlest",
+ };
+ }
+ if (jsonType === "[object Array]") {
+ return {
+ children: isExpanded
+ ? // oxlint-disable-next-line no-explicit-any -- none
+ attrValue.flatMap((v: any, i: number) => (
+
+ ))
+ : null,
+ isExpandable: attrValue.length > 0,
+ label: isExpanded ? `[${attrValue.length || " "}]` : "[⋯]",
+ labelClassName: "text-text-subtlest",
+ };
+ }
+ return {
+ children: null,
+ isExpandable: false,
+ label: jsonType === "[object String]" ? `"${attrValue}"` : `${attrValue}`,
+ labelClassName: classNames(
+ jsonType === "[object Boolean]" && "text-primary",
+ jsonType === "[object Number]" && "text-info",
+ jsonType === "[object String]" && "text-notice",
+ jsonType === "[object Null]" && "text-danger",
+ ),
+ };
+ }, [attrValue, attrKeyJsonPath, isExpanded, depth]);
+
+ const labelEl = (
+
+ {label}
+
+ );
+ return (
+
+
+ {isExpandable ? (
+
+
+
+ {attrKey === undefined ? "$" : attrKey}:
+
+ {labelEl}
+
+ ) : (
+ <>
+
+ {attrKey}:
+
+ {labelEl}
+ >
+ )}
+
+ {children &&
{children}
}
+
+ );
+};
+
+function joinObjectKey(baseKey: string | undefined, key: string): string {
+ const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
+
+ if (baseKey == null) return quotedKey;
+ return `${baseKey}.${quotedKey}`;
+}
+
+function joinArrayKey(baseKey: string | undefined, index: number): string {
+ return `${baseKey ?? ""}[${index}]`;
+}
diff --git a/apps/yaak-client/components/core/KeyValueRow.tsx b/apps/yaak-client/components/core/KeyValueRow.tsx
new file mode 100644
index 0000000..75847fc
--- /dev/null
+++ b/apps/yaak-client/components/core/KeyValueRow.tsx
@@ -0,0 +1,106 @@
+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}
+ ))}
+
+
+ );
+}
+
+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}
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/yaak-client/components/core/Label.tsx b/apps/yaak-client/components/core/Label.tsx
new file mode 100644
index 0000000..a1f2cbb
--- /dev/null
+++ b/apps/yaak-client/components/core/Label.tsx
@@ -0,0 +1,49 @@
+import classNames from "classnames";
+import type { HTMLAttributes, ReactNode } from "react";
+import { IconTooltip } from "./IconTooltip";
+
+export function Label({
+ htmlFor,
+ className,
+ children,
+ visuallyHidden,
+ tags = [],
+ required,
+ rightSlot,
+ help,
+ ...props
+}: HTMLAttributes & {
+ htmlFor: string | null;
+ required?: boolean;
+ tags?: string[];
+ visuallyHidden?: boolean;
+ rightSlot?: ReactNode;
+ children: ReactNode;
+ help?: ReactNode;
+}) {
+ return (
+
+
+ {children}
+ {required === true && * }
+
+ {tags.map((tag, i) => (
+ // oxlint-disable-next-line react/no-array-index-key
+
+ ({tag})
+
+ ))}
+ {help && }
+ {rightSlot && {rightSlot}
}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/Link.tsx b/apps/yaak-client/components/core/Link.tsx
new file mode 100644
index 0000000..297e205
--- /dev/null
+++ b/apps/yaak-client/components/core/Link.tsx
@@ -0,0 +1,63 @@
+import { Link as RouterLink } from "@tanstack/react-router";
+import { platform } from "@yaakapp-internal/platform";
+import classNames from "classnames";
+import type { HTMLAttributes } from "react";
+import { appInfo } from "../../lib/appInfo";
+import { Icon } from "@yaakapp-internal/ui";
+
+interface Props extends HTMLAttributes {
+ href: string;
+ noUnderline?: boolean;
+}
+
+export function Link({ href, children, noUnderline, className, ...other }: Props) {
+ const isExternal = href.match(/^https?:\/\//);
+
+ className = classNames(
+ className,
+ "relative",
+ "inline-flex items-center hover:underline group",
+ !noUnderline && "underline",
+ );
+
+ if (isExternal) {
+ const isYaakLink = href.startsWith("https://yaak.app");
+ let finalHref = href;
+ if (isYaakLink) {
+ const url = new URL(href);
+ url.searchParams.set("ref", appInfo.identifier);
+ finalHref = url.toString();
+ }
+ return (
+ // eslint-disable-next-line react/jsx-no-target-blank
+ {
+ e.preventDefault();
+ void platform.openUrl(finalHref);
+ }}
+ className={className}
+ {...other}
+ >
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function FeedbackLink() {
+ return Feedback;
+}
diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx
new file mode 100644
index 0000000..58a6e74
--- /dev/null
+++ b/apps/yaak-client/components/core/PairEditor.tsx
@@ -0,0 +1,971 @@
+import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ pointerWithin,
+ useDraggable,
+ useDroppable,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import classNames from "classnames";
+import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
+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 { showPrompt } from "../../lib/prompt";
+import { SelectFile } from "../SelectFile";
+import { Button } from "./Button";
+import { Checkbox } from "./Checkbox";
+import type { DropdownItem } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+import type { EditorProps } from "./Editor/Editor";
+import type { GenericCompletionConfig } from "./Editor/genericCompletion";
+import { Editor } from "./Editor/LazyEditor";
+import { Icon } from "@yaakapp-internal/ui";
+import { IconButton } from "./IconButton";
+import type { InputHandle, InputProps } from "./Input";
+import { Input } from "./Input";
+import { ensurePairId } from "./PairEditor.util";
+import type { RadioDropdownItem } from "./RadioDropdown";
+import { RadioDropdown } from "./RadioDropdown";
+import { platform } from "@yaakapp-internal/platform";
+
+export interface PairEditorHandle {
+ /**
+ * Focus a row's name field once it's able to take focus. Focus can't land immediately when the
+ * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
+ * active — so this retries for up to ~1s. A newer focus request cancels a pending one.
+ */
+ focusName(id: string): void;
+ /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
+ focusValue(id: string): void;
+}
+
+/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
+const MAX_FOCUS_ATTEMPTS = 60;
+
+export type PairEditorProps = {
+ allowFileValues?: boolean;
+ allowMultilineValues?: boolean;
+ className?: string;
+ forcedEnvironmentId?: string;
+ forceUpdateKey?: string;
+ nameAutocomplete?: GenericCompletionConfig;
+ nameAutocompleteFunctions?: boolean;
+ nameAutocompleteVariables?: boolean;
+ namePlaceholder?: string;
+ nameValidate?: InputProps["validate"];
+ noScroll?: boolean;
+ onChange: (pairs: PairWithId[]) => void;
+ pairs: EditablePair[];
+ stateKey: InputProps["stateKey"];
+ setRef?: (n: PairEditorHandle) => void;
+ valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
+ valueAutocompleteFunctions?: boolean;
+ valueAutocompleteVariables?: boolean | "environment";
+ valuePlaceholder?: string;
+ valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]);
+ valueValidate?: InputProps["validate"];
+};
+
+export type Pair = {
+ id?: string;
+ enabled?: boolean;
+ name: string;
+ value: string;
+ contentType?: string;
+ filename?: string;
+ isFile?: boolean;
+};
+
+export type PairWithId = Pair & {
+ id: string;
+};
+
+/**
+ * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
+ * `Pair` stays the shape that gets written to models.
+ */
+export type EditablePair = Pair & {
+ /**
+ * When set, name edits are held until the field blurs and then committed through this, instead
+ * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
+ * the field. For names that can't be written directly, like a URL path placeholder that lives
+ * in the URL itself.
+ */
+ commitName?: (name: string) => boolean;
+};
+
+type EditablePairWithId = EditablePair & {
+ id: string;
+};
+
+/** Strip the editor-only fields, so they can never reach a model write */
+function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
+ return pair;
+}
+
+/** Max number of pairs to show before prompting the user to reveal the rest */
+const MAX_INITIAL_PAIRS = 30;
+
+// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
+// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
+// fast path below would swap in the new data without rebuilding the row editors, leaving any
+// still-focused input showing the old owner's text.
+export function PairEditor(props: PairEditorProps) {
+ return ;
+}
+
+function PairEditorInner({
+ allowFileValues,
+ allowMultilineValues,
+ className,
+ forcedEnvironmentId,
+ forceUpdateKey,
+ nameAutocomplete,
+ nameAutocompleteFunctions,
+ nameAutocompleteVariables,
+ namePlaceholder,
+ nameValidate,
+ noScroll,
+ onChange,
+ pairs: originalPairs,
+ stateKey,
+ valueAutocomplete,
+ valueAutocompleteFunctions,
+ valueAutocompleteVariables,
+ valuePlaceholder,
+ valueType,
+ valueValidate,
+ setRef,
+}: PairEditorProps) {
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const [isDragging, setIsDragging] = useState(null);
+ const [pairs, setPairs] = useState([]);
+ const [showAll, toggleShowAll] = useToggle(false);
+ // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
+ // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
+ const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
+
+ const rowsRef = useRef>({});
+
+ const pendingFocusFrame = useRef(null);
+ useEffect(
+ () => () => {
+ if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
+ },
+ [],
+ );
+
+ const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
+ if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
+
+ let attemptsLeft = MAX_FOCUS_ATTEMPTS;
+ const attempt = () => {
+ pendingFocusFrame.current = null;
+ const row = rowsRef.current[id];
+ const landed = field === "name" ? row?.focusName() : row?.focusValue();
+ if (landed || --attemptsLeft <= 0) return;
+ pendingFocusFrame.current = requestAnimationFrame(attempt);
+ };
+ attempt();
+ }, []);
+
+ const handle = useMemo(
+ () => ({
+ focusName(id: string) {
+ focusWhenReady(id, "name");
+ },
+ focusValue(id: string) {
+ focusWhenReady(id, "value");
+ },
+ }),
+ [focusWhenReady],
+ );
+
+ const initPairEditorRow = useCallback(
+ (id: string, n: RowHandle | null) => {
+ const isLast = id === pairs[pairs.length - 1]?.id;
+ if (isLast) return; // Never add the last pair
+
+ rowsRef.current[id] = n;
+ const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
+
+ // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
+ const ready = validHandles.length >= pairs.length - 1;
+ if (ready) {
+ setRef?.(handle);
+ }
+ },
+ [handle, pairs, setRef],
+ );
+
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
+ useEffect(() => {
+ // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
+ const newPairs: EditablePairWithId[] = [];
+ for (let i = 0; i < originalPairs.length; i++) {
+ const p = originalPairs[i];
+ if (!p) continue; // Make TS happy
+ if (isPairEmpty(p)) continue;
+ newPairs.push(ensurePairId(p));
+ }
+
+ // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
+ // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
+ // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
+ // selection — like tabbing from a placeholder's name into its value.
+ const trailingPair = pairs[pairs.length - 1];
+ const sameRows =
+ trailingPair != null &&
+ isPairEmpty(trailingPair) &&
+ pairs.length === newPairs.length + 1 &&
+ newPairs.every((p, i) => p.id === pairs[i]?.id);
+ if (sameRows) {
+ setPairs([...newPairs, trailingPair]);
+ return;
+ }
+
+ // Add empty last pair if there is none
+ const lastPair = newPairs[newPairs.length - 1];
+ if (lastPair == null || !isPairEmpty(lastPair)) {
+ newPairs.push(emptyPair());
+ }
+
+ setPairs(newPairs);
+ regenerateLocalForceUpdateKey();
+ }, [forceUpdateKey]);
+
+ const setPairsAndSave = useCallback(
+ (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
+ setPairs((oldPairs) => {
+ const pairs = fn(oldPairs);
+ onChange(pairs.map(toPairData));
+ return pairs;
+ });
+ },
+ [onChange],
+ );
+
+ const handleChange = useCallback(
+ (pair: EditablePairWithId) =>
+ setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
+ [setPairsAndSave],
+ );
+
+ const handleDelete = useCallback(
+ (pair: Pair, focusPrevious: boolean) => {
+ if (focusPrevious) {
+ const index = pairs.findIndex((p) => p.id === pair.id);
+ const id = pairs[index - 1]?.id ?? null;
+ rowsRef.current[id ?? "n/a"]?.focusName();
+ }
+ return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
+ },
+ [setPairsAndSave, pairs],
+ );
+
+ const handleFocusName = useCallback(
+ (pair: Pair) => {
+ const isLast = pair.id === pairs[pairs.length - 1]?.id;
+ if (isLast) setPairs([...pairs, emptyPair()]);
+ },
+ [pairs],
+ );
+
+ const handleFocusValue = useCallback(
+ (pair: Pair) => {
+ const isLast = pair.id === pairs[pairs.length - 1]?.id;
+ if (isLast) setPairs([...pairs, emptyPair()]);
+ },
+ [pairs],
+ );
+
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+
+ // dnd-kit: show the “between rows” marker while hovering
+ const onDragMove = useCallback(
+ (e: DragMoveEvent) => {
+ const overId = e.over?.id as string | undefined;
+ if (!overId) return setHoveredIndex(null);
+
+ const overPair = pairs.find((p) => p.id === overId);
+ if (overPair == null) return setHoveredIndex(null);
+
+ const side = computeSideForDragMove(overPair.id, e);
+ const overIndex = pairs.findIndex((p) => p.id === overId);
+ const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
+
+ setHoveredIndex(hoveredIndex);
+ },
+ [pairs],
+ );
+
+ const onDragStart = useCallback(
+ (e: DragStartEvent) => {
+ const pair = pairs.find((p) => p.id === e.active.id);
+ setIsDragging(pair ?? null);
+ },
+ [pairs],
+ );
+
+ const onDragCancel = useCallback(() => setIsDragging(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) return;
+
+ const from = pairs.findIndex((p) => p.id === activeId);
+ const baseTo = pairs.findIndex((p) => p.id === overId);
+ const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
+
+ if (from !== -1 && to !== -1 && from !== to) {
+ setPairsAndSave((ps) => {
+ const next = [...ps];
+ const [moved] = next.splice(from, 1);
+ if (moved === undefined) return ps; // Make TS happy
+ next.splice(to > from ? to - 1 : to, 0, moved);
+ return next;
+ });
+ }
+ },
+ [pairs, hoveredIndex, setPairsAndSave],
+ );
+
+ return (
+
+
+
+ {pairs.map((p, i) => {
+ if (!showAll && i > MAX_INITIAL_PAIRS) return null;
+
+ const isLast = i === pairs.length - 1;
+ return (
+
+ {hoveredIndex === i && }
+
+
+ );
+ })}
+ {!showAll && pairs.length > MAX_INITIAL_PAIRS && (
+
+ Show {pairs.length - MAX_INITIAL_PAIRS} More
+
+ )}
+
+ {isDragging && (
+
+ )}
+
+
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+ );
+}
+
+type PairEditorRowProps = {
+ className?: string;
+ pair: EditablePairWithId;
+ forceFocusNamePairId?: string | null;
+ forceFocusValuePairId?: string | null;
+ onChange?: (pair: EditablePairWithId) => void;
+ onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
+ onFocusName?: (pair: EditablePairWithId) => void;
+ onFocusValue?: (pair: EditablePairWithId) => void;
+ onSubmit?: (pair: EditablePairWithId) => void;
+ isLast?: boolean;
+ disabled?: boolean;
+ disableDrag?: boolean;
+ index: number;
+ isDraggingGlobal?: boolean;
+ setRef?: (id: string, n: RowHandle | null) => void;
+} & Pick<
+ PairEditorProps,
+ | "allowFileValues"
+ | "allowMultilineValues"
+ | "forcedEnvironmentId"
+ | "forceUpdateKey"
+ | "nameAutocomplete"
+ | "nameAutocompleteVariables"
+ | "namePlaceholder"
+ | "nameValidate"
+ | "nameAutocompleteFunctions"
+ | "stateKey"
+ | "valueAutocomplete"
+ | "valueAutocompleteFunctions"
+ | "valueAutocompleteVariables"
+ | "valuePlaceholder"
+ | "valueType"
+ | "valueValidate"
+>;
+
+interface RowHandle {
+ focusName(): boolean;
+ focusValue(): boolean;
+}
+
+export function PairEditorRow({
+ allowFileValues,
+ allowMultilineValues,
+ className,
+ disableDrag,
+ disabled,
+ forceUpdateKey,
+ forcedEnvironmentId,
+ index,
+ isLast,
+ nameAutocomplete,
+ nameAutocompleteFunctions,
+ nameAutocompleteVariables,
+ namePlaceholder,
+ nameValidate,
+ isDraggingGlobal,
+ onChange,
+ onDelete,
+ onFocusName,
+ onFocusValue,
+ pair,
+ stateKey,
+ valueAutocomplete,
+ valueAutocompleteFunctions,
+ valueAutocompleteVariables,
+ valuePlaceholder,
+ valueType,
+ valueValidate,
+ setRef,
+}: PairEditorRowProps) {
+ const nameInputRef = useRef
(null);
+ const valueInputRef = useRef(null);
+ const handle = useRef({
+ focusName() {
+ nameInputRef.current?.focus();
+ return nameInputRef.current?.isFocused() ?? false;
+ },
+ focusValue() {
+ valueInputRef.current?.focus();
+ return valueInputRef.current?.isFocused() ?? false;
+ },
+ });
+
+ const initNameInputRef = useCallback(
+ (n: InputHandle | null) => {
+ nameInputRef.current = n;
+ if (nameInputRef.current && valueInputRef.current) {
+ setRef?.(pair.id, handle.current);
+ }
+ },
+ [pair.id, setRef],
+ );
+
+ const initValueInputRef = useCallback(
+ (n: InputHandle | null) => {
+ valueInputRef.current = n;
+ if (nameInputRef.current && valueInputRef.current) {
+ setRef?.(pair.id, handle.current);
+ }
+ },
+ [pair.id, setRef],
+ );
+
+ const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
+ const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
+ const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
+
+ const handleChangeEnabled = useMemo(
+ () => (enabled: boolean) => onChange?.({ ...pair, enabled }),
+ [onChange, pair],
+ );
+
+ // The name being typed into a deferred-commit field, before it's committed or reverted
+ const pendingName = useRef(null);
+
+ const handleChangeName = useMemo(
+ () => (name: string) => {
+ // Keep the edit local until commit. Writing on every keystroke would reset the editor from
+ // beneath the cursor, since the pairs are derived from the name being edited.
+ if (pair.commitName != null) pendingName.current = name;
+ else onChange?.({ ...pair, name });
+ },
+ [onChange, pair],
+ );
+
+ const revertName = useCallback(() => {
+ const nameInput = nameInputRef.current;
+ if (nameInput != null) {
+ const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
+ nameInput.dispatch({ changes });
+ }
+ pendingName.current = null;
+ }, [pair.name]);
+
+ const handleBlurName = useCallback(() => {
+ if (pair.commitName == null) return;
+
+ const name = pendingName.current;
+ pendingName.current = null;
+ if (name == null || name === pair.name) return;
+ if (!pair.commitName(name)) revertName();
+ }, [pair, revertName]);
+
+ const handleChangeValueText = useMemo(
+ () => (value: string) => onChange?.({ ...pair, value, isFile: false }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueFile = useMemo(
+ () =>
+ ({ filePath }: { filePath: string | null }) =>
+ onChange?.({ ...pair, value: filePath ?? "", isFile: true }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueContentType = useMemo(
+ () => (contentType: string) => onChange?.({ ...pair, contentType }),
+ [onChange, pair],
+ );
+
+ const handleChangeValueFilename = useMemo(
+ () => (filename: string) => onChange?.({ ...pair, filename }),
+ [onChange, pair],
+ );
+
+ const handleEditMultiLineValue = useCallback(
+ () =>
+ showDialog({
+ id: "pair-edit-multiline",
+ size: "dynamic",
+ title: <>Edit {pair.name}>,
+ render: ({ hide }) => (
+
+ ),
+ }),
+ [handleChangeValueText, pair.contentType, pair.name, pair.value],
+ );
+
+ const defaultItems = useMemo(
+ (): DropdownItem[] => [
+ {
+ label: "Edit Multi-line",
+ onSelect: handleEditMultiLineValue,
+ hidden: !allowMultilineValues,
+ },
+ {
+ label: "Delete",
+ onSelect: handleDelete,
+ color: "danger",
+ },
+ ],
+ [allowMultilineValues, handleDelete, handleEditMultiLineValue],
+ );
+
+ const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
+ const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
+
+ // Filter out the current pair name
+ const valueAutocompleteVariablesFiltered = useMemo(() => {
+ if (valueAutocompleteVariables === "environment") {
+ return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
+ }
+ return valueAutocompleteVariables;
+ }, [pair.name, valueAutocompleteVariables]);
+
+ const handleSetRef = useCallback(
+ (n: HTMLDivElement | null) => {
+ setDraggableRef(n);
+ setDroppableRef(n);
+ },
+ [setDraggableRef, setDroppableRef],
+ );
+
+ // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
+ // imply it's already a real row. `Input` handles the rest, including having no options to show.
+ const showOptionsPicker = !isLast;
+
+ return (
+
+
+ {!isLast && !disableDrag ? (
+
+
+
+ ) : (
+
+ )}
+
+ {allowFileValues ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+const fileItems: RadioDropdownItem[] = [
+ { label: "Text", value: "text" },
+ { label: "File", value: "file" },
+];
+
+function FileActionsDropdown({
+ pair,
+ onChangeFile,
+ onChangeText,
+ onChangeContentType,
+ onChangeFilename,
+ onDelete,
+ editMultiLine,
+}: {
+ pair: Pair;
+ onChangeFile: ({ filePath }: { filePath: string | null }) => void;
+ onChangeText: (text: string) => void;
+ onChangeContentType: (contentType: string) => void;
+ onChangeFilename: (filename: string) => void;
+ onDelete: () => void;
+ editMultiLine: () => void;
+}) {
+ const onChange = useCallback(
+ (v: string) => {
+ if (v === "file") onChangeFile({ filePath: "" });
+ else onChangeText("");
+ },
+ [onChangeFile, onChangeText],
+ );
+
+ const itemsAfter = useMemo(
+ () => [
+ {
+ label: "Edit Multi-Line",
+ leftSlot: ,
+ hidden: pair.isFile,
+ onSelect: editMultiLine,
+ },
+ {
+ label: "Set Content-Type",
+ leftSlot: ,
+ onSelect: async () => {
+ const contentType = await showPrompt({
+ id: "content-type",
+ title: "Override Content-Type",
+ label: "Content-Type",
+ required: false,
+ placeholder: "text/plain",
+ defaultValue: pair.contentType ?? "",
+ confirmText: "Set",
+ description: "Leave blank to auto-detect",
+ });
+ if (contentType == null) return;
+ onChangeContentType(contentType);
+ },
+ },
+ {
+ label: "Set File Name",
+ leftSlot: ,
+ onSelect: async () => {
+ console.log("PAIR", pair);
+ const defaultFilename = await platform.files.basename(pair.value ?? "");
+ const filename = await showPrompt({
+ id: "filename",
+ title: "Override Filename",
+ label: "Filename",
+ required: false,
+ placeholder: defaultFilename ?? "myfile.png",
+ defaultValue: pair.filename,
+ confirmText: "Set",
+ description: "Leave blank to use the name of the selected file",
+ });
+ if (filename == null) return;
+ onChangeFilename(filename);
+ },
+ },
+ {
+ label: "Unset File",
+ leftSlot: ,
+ hidden: pair.isFile,
+ onSelect: async () => {
+ onChangeFile({ filePath: null });
+ },
+ },
+ {
+ label: "Delete",
+ onSelect: onDelete,
+ variant: "danger",
+ leftSlot: ,
+ color: "danger",
+ },
+ ],
+ [
+ editMultiLine,
+ onChangeContentType,
+ onChangeFile,
+ onDelete,
+ pair.contentType,
+ pair.isFile,
+ onChangeFilename,
+ pair.filename,
+ pair,
+ ],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function emptyPair(): EditablePairWithId {
+ return ensurePairId({ enabled: true, name: "", value: "" });
+}
+
+function isPairEmpty(pair: Pair): boolean {
+ return !pair.name && !pair.value;
+}
+
+function MultilineEditDialog({
+ defaultValue,
+ contentType,
+ onChange,
+ hide,
+}: {
+ defaultValue: string;
+ contentType: string | null;
+ onChange: (value: string) => void;
+ hide: () => void;
+}) {
+ const [value, setValue] = useState(defaultValue);
+ const language = languageFromContentType(contentType, value);
+ return (
+
+
+
+ {
+ onChange(value);
+ hide();
+ }}
+ >
+ Done
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx
new file mode 100644
index 0000000..27feb01
--- /dev/null
+++ b/apps/yaak-client/components/core/PairEditor.util.tsx
@@ -0,0 +1,10 @@
+import { generateId } from "../../lib/generateId";
+import type { Pair, PairWithId } from "./PairEditor";
+
+// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
+export function ensurePairId(p: T): T & PairWithId {
+ if (typeof p.id === "string") {
+ return p as T & PairWithId;
+ }
+ return { ...p, id: p.id ?? generateId() };
+}
diff --git a/apps/yaak-client/components/core/PairOrBulkEditor.tsx b/apps/yaak-client/components/core/PairOrBulkEditor.tsx
new file mode 100644
index 0000000..977c7da
--- /dev/null
+++ b/apps/yaak-client/components/core/PairOrBulkEditor.tsx
@@ -0,0 +1,38 @@
+import classNames from "classnames";
+import { useKeyValue } from "../../hooks/useKeyValue";
+import { BulkPairEditor } from "./BulkPairEditor";
+import { IconButton } from "./IconButton";
+import type { PairEditorProps } from "./PairEditor";
+import { PairEditor } from "./PairEditor";
+
+interface Props extends PairEditorProps {
+ preferenceName: string;
+ forcedEnvironmentId?: string;
+}
+
+export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
+ const { value: useBulk, set: setUseBulk } = useKeyValue({
+ namespace: "global",
+ key: ["bulk_edit", preferenceName],
+ fallback: false,
+ });
+
+ return (
+
+ {useBulk ?
:
}
+
+ setUseBulk((b) => !b)}
+ icon={useBulk ? "table" : "file_code"}
+ />
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PillButton.tsx b/apps/yaak-client/components/core/PillButton.tsx
new file mode 100644
index 0000000..ba2b642
--- /dev/null
+++ b/apps/yaak-client/components/core/PillButton.tsx
@@ -0,0 +1,14 @@
+import classNames from "classnames";
+import type { ButtonProps } from "./Button";
+import { Button } from "./Button";
+
+export function PillButton({ className, ...props }: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/apps/yaak-client/components/core/PlainInput.tsx b/apps/yaak-client/components/core/PlainInput.tsx
new file mode 100644
index 0000000..c38672e
--- /dev/null
+++ b/apps/yaak-client/components/core/PlainInput.tsx
@@ -0,0 +1,244 @@
+import { HStack } from "@yaakapp-internal/ui";
+import classNames from "classnames";
+import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
+import {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ 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";
+
+export type PlainInputProps = Omit<
+ InputProps,
+ | "wrapLines"
+ | "onKeyDown"
+ | "type"
+ | "stateKey"
+ | "autocompleteVariables"
+ | "autocompleteFunctions"
+ | "autocomplete"
+ | "extraExtensions"
+ | "forcedEnvironmentId"
+> &
+ Pick, "inputMode" | "onKeyDownCapture" | "step"> & {
+ onFocusRaw?: InputHTMLAttributes["onFocus"];
+ type?: "text" | "password" | "number";
+ hideObscureToggle?: boolean;
+ labelRightSlot?: ReactNode;
+ };
+
+export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
+ {
+ autoFocus,
+ autoSelect,
+ className,
+ containerClassName,
+ defaultValue,
+ disabled,
+ forceUpdateKey: forceUpdateKeyFromAbove,
+ help,
+ hideLabel,
+ hideObscureToggle,
+ label,
+ labelClassName,
+ labelPosition = "top",
+ labelRightSlot,
+ inputMode,
+ leftSlot,
+ name,
+ onBlur,
+ onChange,
+ onFocus,
+ onFocusRaw,
+ onKeyDownCapture,
+ onPaste,
+ placeholder,
+ required,
+ rightSlot,
+ size = "md",
+ step,
+ tint,
+ type = "text",
+ validate,
+ },
+ ref,
+) {
+ // Track a local key for updates. If the default value is changed when the input is not in focus,
+ // regenerate this to force the field to update.
+ const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
+ const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
+
+ const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
+ const [focused, setFocused] = useState(false);
+ const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]);
+ const textareaRef = useRef(null);
+ const inputRef = useRef(null);
+
+ useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
+ ref,
+ () => inputRef.current,
+ );
+
+ const handleFocus = useCallback(
+ (e: FocusEvent) => {
+ onFocusRaw?.(e);
+ setFocused(true);
+ if (autoSelect) {
+ inputRef.current?.select();
+ textareaRef.current?.select();
+ }
+ onFocus?.();
+ },
+ [autoSelect, onFocus, onFocusRaw],
+ );
+
+ const handleBlur = useCallback(() => {
+ setFocused(false);
+ onBlur?.();
+ }, [onBlur]);
+
+ // Force input to update when receiving change and not in focus
+ useLayoutEffect(() => {
+ const isFocused = document.activeElement === inputRef.current;
+ if (defaultValue != null && !isFocused) {
+ regenerateFocusedUpdateKey();
+ }
+ }, [regenerateFocusedUpdateKey, defaultValue]);
+
+ const id = useRef(`input-${generateId()}`);
+ const commonClassName = classNames(
+ className,
+ "bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
+ "px-2 text-xs font-mono cursor-text",
+ );
+
+ const handleChange = useCallback(
+ (value: string) => {
+ onChange?.(value);
+ setHasChanged(true);
+ const isValid = (value: string) => {
+ if (required && !validateRequire(value)) return false;
+ if (typeof validate === "boolean") return validate;
+ if (typeof validate === "function" && !validate(value)) return false;
+ return true;
+ };
+ inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
+ },
+ [onChange, required, setHasChanged, validate],
+ );
+
+ const wrapperRef = useRef(null);
+
+ return (
+
+
+ {label}
+
+
+ {tint != null && (
+
+ )}
+ {leftSlot}
+
+ handleChange(e.target.value)}
+ onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
+ className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ required={required}
+ step={step}
+ placeholder={placeholder}
+ onKeyDownCapture={onKeyDownCapture}
+ />
+
+ {type === "password" && !hideObscureToggle && (
+ setObscured((o) => !o)}
+ />
+ )}
+ {rightSlot}
+
+
+ );
+});
+
+function validateRequire(v: string) {
+ return v.length > 0;
+}
diff --git a/apps/yaak-client/components/core/Prompt.tsx b/apps/yaak-client/components/core/Prompt.tsx
new file mode 100644
index 0000000..0c0d122
--- /dev/null
+++ b/apps/yaak-client/components/core/Prompt.tsx
@@ -0,0 +1,66 @@
+import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
+import { HStack } from "@yaakapp-internal/ui";
+import type { FormEvent } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { generateId } from "../../lib/generateId";
+import { DynamicForm } from "../DynamicForm";
+import { Button } from "./Button";
+
+export interface PromptProps {
+ inputs: FormInput[];
+ onCancel: () => void;
+ onResult: (value: Record | null) => void;
+ confirmText?: string;
+ cancelText?: string;
+ onValuesChange?: (values: Record) => void;
+ onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
+}
+
+export function Prompt({
+ onCancel,
+ inputs: initialInputs,
+ onResult,
+ confirmText = "Confirm",
+ cancelText = "Cancel",
+ onValuesChange,
+ onInputsUpdated,
+}: PromptProps) {
+ const [value, setValue] = useState>({});
+ const [inputs, setInputs] = useState(initialInputs);
+ const handleSubmit = useCallback(
+ (e: FormEvent) => {
+ e.preventDefault();
+ onResult(value);
+ },
+ [onResult, value],
+ );
+
+ // Register callback for external input updates (from plugin dynamic resolution)
+ useEffect(() => {
+ onInputsUpdated?.(setInputs);
+ }, [onInputsUpdated]);
+
+ // Notify of value changes for dynamic resolution
+ useEffect(() => {
+ onValuesChange?.(value);
+ }, [value, onValuesChange]);
+
+ const id = `prompt.form.${useRef(generateId()).current}`;
+
+ return (
+
+
+
+
+ {cancelText || "Cancel"}
+
+
+ {confirmText || "Done"}
+
+
+
+ );
+}
diff --git a/apps/yaak-client/components/core/RadioCards.tsx b/apps/yaak-client/components/core/RadioCards.tsx
new file mode 100644
index 0000000..c787642
--- /dev/null
+++ b/apps/yaak-client/components/core/RadioCards.tsx
@@ -0,0 +1,64 @@
+import classNames from "classnames";
+import type { ReactNode } from "react";
+
+export interface RadioCardOption {
+ value: T;
+ label: ReactNode;
+ description?: ReactNode;
+}
+
+export interface RadioCardsProps {
+ value: T | null;
+ onChange: (value: T) => void;
+ options: RadioCardOption[];
+ name: string;
+}
+
+export function RadioCards({
+ value,
+ onChange,
+ options,
+ name,
+}: RadioCardsProps) {
+ return (
+
+ {options.map((option) => {
+ const selected = value === option.value;
+ return (
+
+ onChange(option.value)}
+ className="sr-only"
+ />
+
+
+ {option.label}
+ {option.description && (
+ {option.description}
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/RadioDropdown.tsx b/apps/yaak-client/components/core/RadioDropdown.tsx
new file mode 100644
index 0000000..a1a642e
--- /dev/null
+++ b/apps/yaak-client/components/core/RadioDropdown.tsx
@@ -0,0 +1,69 @@
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
+import { Dropdown } from "./Dropdown";
+import { Icon } from "@yaakapp-internal/ui";
+
+export type RadioDropdownItem =
+ | {
+ type?: "default";
+ label: ReactNode;
+ shortLabel?: ReactNode;
+ value: T;
+ rightSlot?: ReactNode;
+ }
+ | DropdownItemSeparator;
+
+export interface RadioDropdownProps {
+ value: T;
+ onChange: (value: T) => void;
+ itemsBefore?: DropdownItem[];
+ items: RadioDropdownItem[];
+ itemsAfter?: DropdownItem[];
+ children: DropdownProps["children"];
+}
+
+export function RadioDropdown({
+ value,
+ items,
+ itemsAfter,
+ itemsBefore,
+ onChange,
+ children,
+}: RadioDropdownProps) {
+ const dropdownItems = useMemo(
+ () => [
+ ...((itemsBefore
+ ? [
+ ...itemsBefore,
+ {
+ type: "separator",
+ hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
+ },
+ ]
+ : []) as DropdownItem[]),
+ ...items.map((item) => {
+ if (item.type === "separator") {
+ return item;
+ }
+ return {
+ key: item.value,
+ label: item.label,
+ rightSlot: item.rightSlot,
+ onSelect: () => onChange(item.value),
+ leftSlot: ,
+ } as DropdownItem;
+ }),
+ ...((itemsAfter
+ ? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
+ : []) as DropdownItem[]),
+ ],
+ [itemsBefore, items, itemsAfter, value, onChange],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx
new file mode 100644
index 0000000..aa9aca8
--- /dev/null
+++ b/apps/yaak-client/components/core/SegmentedControl.tsx
@@ -0,0 +1,136 @@
+import type { IconProps } from "@yaakapp-internal/ui";
+import { HStack } 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";
+
+interface Props {
+ options: { value: T; label: string; icon?: IconProps["icon"] }[];
+ onChange: (value: T) => void;
+ value: T;
+ name: string;
+ size?: IconButtonProps["size"];
+ label: string;
+ className?: string;
+ hideLabel?: boolean;
+ labelClassName?: string;
+ help?: ReactNode;
+}
+
+export function SegmentedControl({
+ value,
+ onChange,
+ options,
+ size = "xs",
+ label,
+ hideLabel,
+ labelClassName,
+ help,
+ className,
+}: Props