From d72b7d7d3024234e2a8bbf55d1e0a98cfe4e9e39 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 15 Jul 2026 08:33:14 -0700 Subject: [PATCH] Fix URL autocomplete replacement (#510) --- .../components/HttpRequestPane.tsx | 12 +--- .../components/WebsocketRequestPane.tsx | 12 +--- .../core/Editor/genericCompletion.ts | 8 ++- .../core/Editor/url/completion.test.ts | 49 ++++++++++++++++ .../components/core/Editor/url/completion.ts | 58 ++++++++++++++++--- 5 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 apps/yaak-client/components/core/Editor/url/completion.test.ts diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index 944c4904..34d2926d 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -39,6 +39,7 @@ import { BinaryFileEditor } from "./BinaryFileEditor"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; +import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; import { InlineCode } from "@yaakapp-internal/ui"; import type { Pair } from "./core/PairEditor"; @@ -285,16 +286,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocomplete: GenericCompletionConfig = useMemo( - () => ({ - minMatch: 3, - options: - autocompleteUrls.length > 0 - ? autocompleteUrls - : [ - { label: "http://", type: "constant" }, - { label: "https://", type: "constant" }, - ], - }), + () => getUrlCompletionConfig(autocompleteUrls), [autocompleteUrls], ); diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 113fff8a..2929aa93 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -26,6 +26,7 @@ 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 type { Pair } from "./core/PairEditor"; @@ -130,16 +131,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocomplete: GenericCompletionConfig = useMemo( - () => ({ - minMatch: 3, - options: - autocompleteUrls.length > 0 - ? autocompleteUrls - : [ - { label: "http://", type: "constant" }, - { label: "https://", type: "constant" }, - ], - }), + () => getUrlCompletionConfig(autocompleteUrls), [autocompleteUrls], ); diff --git a/apps/yaak-client/components/core/Editor/genericCompletion.ts b/apps/yaak-client/components/core/Editor/genericCompletion.ts index 4eaef118..630da4cd 100644 --- a/apps/yaak-client/components/core/Editor/genericCompletion.ts +++ b/apps/yaak-client/components/core/Editor/genericCompletion.ts @@ -1,10 +1,14 @@ -import type { CompletionContext } from "@codemirror/autocomplete"; +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: GenericCompletionOption[]; + options: GenericCompletion[]; } /** 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 00000000..57e403a9 --- /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 index 0b6612e1..4a7c9a26 100644 --- a/apps/yaak-client/components/core/Editor/url/completion.ts +++ b/apps/yaak-client/components/core/Editor/url/completion.ts @@ -1,9 +1,51 @@ -import { genericCompletion } from "../genericCompletion"; +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"; -export const completions = genericCompletion({ - options: [ - { label: "http://", type: "constant" }, - { label: "https://", type: "constant" }, - ], - minMatch: 1, -}); +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));