mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-13 15:12:05 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a3af02eb6 | ||
|
|
f6d926f4b9 | ||
|
|
a6be9dbaee | ||
|
|
67a628d67a | ||
|
|
0bf7eaed81 | ||
|
|
784a3d3a32 |
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- name: Install source generators
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
@@ -19,7 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
@@ -42,7 +42,6 @@ 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";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -133,20 +132,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// 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("http_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],
|
||||
);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
/**
|
||||
* How much of the value to render at once. Rendering the whole thing would hit exactly the
|
||||
* layout cost this dialog exists to avoid, so it pages instead.
|
||||
*/
|
||||
const PAGE_CHARS = 20_000;
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function LargeValueDialog({ value }: Props) {
|
||||
const [page, setPage] = useState(0);
|
||||
const pageCount = Math.max(1, Math.ceil(value.length / PAGE_CHARS));
|
||||
const start = page * PAGE_CHARS;
|
||||
const slice = value.slice(start, start + PAGE_CHARS);
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] gap-3 h-full">
|
||||
<HStack space={2} className="flex-wrap">
|
||||
<span className="text-text-subtle text-sm tabular-nums">
|
||||
{value.length.toLocaleString()} characters
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<HStack space={1} alignItems="center">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
icon="chevron_left"
|
||||
title="Previous page"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-text-subtle text-sm tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
icon="chevron_right"
|
||||
title="Next page"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<CopyButton size="xs" variant="border" color="secondary" text={value} />
|
||||
</div>
|
||||
</HStack>
|
||||
<div className="overflow-auto bg-surface-highlight rounded-md p-3">
|
||||
<div className="font-mono text-sm whitespace-pre-wrap break-all select-auto">{slice}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LargeValueDialog.show = (value: string) => {
|
||||
showDialog({
|
||||
id: "large-value",
|
||||
title: "Large Value",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-10rem)] max-h-200!",
|
||||
render: () => <LargeValueDialog value={value} />,
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
pairs: HttpRequest["headers"];
|
||||
pairs: EditablePair[];
|
||||
stateKey: PairEditorProps["stateKey"];
|
||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
||||
onChange: PairEditorProps["onChange"];
|
||||
};
|
||||
|
||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||
import classNames from "classnames";
|
||||
@@ -20,8 +20,7 @@ import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEdit
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
@@ -29,7 +28,6 @@ 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";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -84,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
[],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// 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 tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
|
||||
@@ -253,6 +253,12 @@
|
||||
@apply cursor-default!;
|
||||
}
|
||||
|
||||
.cm-editor .cm-largeValue {
|
||||
@apply px-2 mx-0.5 rounded border border-border-subtle bg-surface-highlight;
|
||||
@apply text-text-subtle text-sm cursor-pointer align-middle;
|
||||
@apply hover:text-text hover:border-border;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent text-text-subtle;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { md5 } from "js-md5";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import {
|
||||
Children,
|
||||
@@ -33,6 +32,7 @@ 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";
|
||||
@@ -656,9 +656,9 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
|
||||
if (!stateKey || state == null) return;
|
||||
const stateObj = state.toJSON(stateFields);
|
||||
|
||||
// Save state in sessionStorage by removing doc and saving the hash of it instead.
|
||||
// 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.
|
||||
stateObj.docHash = md5(stateObj.doc);
|
||||
stateObj.docHash = docFingerprint(stateObj.doc);
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
@@ -678,7 +678,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
const { docHash, ...state } = JSON.parse(stateStr);
|
||||
|
||||
// Ensure the doc matches the one that was used to save the state
|
||||
if (docHash !== md5(doc)) {
|
||||
if (docHash !== docFingerprint(doc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
|
||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||
import type { EditorProps } from "./Editor";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
import { largeValues } from "./largeValues";
|
||||
import { pairs } from "./pairs/extension";
|
||||
import { searchMatchCount } from "./searchMatchCount";
|
||||
import { text } from "./text/extension";
|
||||
@@ -258,6 +259,8 @@ export const baseExtensions = [
|
||||
export const readonlyExtensions = [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
||||
// Read-only only, so we never hide part of a document someone is editing
|
||||
largeValues,
|
||||
];
|
||||
|
||||
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import {
|
||||
COLLAPSE_KEEP_CHARS,
|
||||
COLLAPSE_TOKEN_CHARS,
|
||||
largeValueField,
|
||||
largeValues,
|
||||
MAX_VISIBLE_LINE_CHARS,
|
||||
} from "./largeValues";
|
||||
|
||||
vi.mock("../../LargeValueDialog", () => ({ LargeValueDialog: { show: () => {} } }));
|
||||
|
||||
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 });
|
||||
|
||||
function collapsedRanges(state: EditorState) {
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
const iter = state.field(largeValueField).decorations.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("collapses the string itself, keeping the structure around it", () => {
|
||||
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
|
||||
const ranges = collapsedRanges(jsonState(doc));
|
||||
|
||||
expect(ranges).toHaveLength(1);
|
||||
// The collapse covers the middle of the base64 string and nothing else
|
||||
const stringStart = doc.indexOf(`"${BIG}"`);
|
||||
expect(ranges[0]!.from).toBe(stringStart + COLLAPSE_KEEP_CHARS);
|
||||
expect(ranges[0]!.to).toBe(stringStart + BIG.length + 2 - COLLAPSE_KEEP_CHARS);
|
||||
|
||||
// Everything after the value is still rendered, unlike a plain column cut
|
||||
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
|
||||
});
|
||||
|
||||
test("keeps every key visible in a minified body with several large values", () => {
|
||||
const chunk = "B".repeat(200_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);
|
||||
|
||||
expect(collapsedRanges(state)).toHaveLength(1);
|
||||
// Only the long line is touched
|
||||
expect(visibleLineLengths(state)).toEqual([1, 18, expect.any(Number), 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("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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState, Extension, Range } from "@codemirror/state";
|
||||
import { StateField } from "@codemirror/state";
|
||||
import type { Tree as SyntaxTree } from "@lezer/common";
|
||||
import type { DecorationSet } from "@codemirror/view";
|
||||
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
||||
import { LargeValueDialog } from "../../LargeValueDialog";
|
||||
|
||||
/**
|
||||
* 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 token longer than this on an over-long line is collapsed on its own, ahead of the column
|
||||
* cut, so the structure around it stays visible. Needs a grammar to find.
|
||||
*/
|
||||
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
||||
|
||||
/** How much of a collapsed range stays visible at each end */
|
||||
export const COLLAPSE_KEEP_CHARS = 100;
|
||||
|
||||
/**
|
||||
* Keeps over-long lines from reaching layout, which is what makes the editor stall on a
|
||||
* base64 blob or a minified payload.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Two rules, applied only to lines over {@link MAX_VISIBLE_LINE_CHARS}, so ordinary documents
|
||||
* are untouched:
|
||||
*
|
||||
* 1. Collapse individual tokens over {@link COLLAPSE_TOKEN_CHARS}. For a language with a
|
||||
* grammar this is the whole base64 string, so everything around it stays readable. A
|
||||
* minified body with several large values keeps all of its keys visible.
|
||||
* 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers
|
||||
* plain text and any line that isn't one big token.
|
||||
*
|
||||
* Nothing leaves the document. Copy, filter and save all still see the full text; the hidden
|
||||
* part is reachable through {@link LargeValueDialog}.
|
||||
*
|
||||
* Read-only editors only. Hiding part of a document someone is editing would mean editing
|
||||
* text they can't see.
|
||||
*/
|
||||
|
||||
interface Collapse {
|
||||
/** Bounds of the hidden part */
|
||||
hiddenFrom: number;
|
||||
hiddenTo: number;
|
||||
/** Bounds of the whole value, including any visible ends, for the dialog */
|
||||
valueFrom: number;
|
||||
valueTo: number;
|
||||
}
|
||||
|
||||
class LargeValueWidget extends WidgetType {
|
||||
constructor(private readonly collapse: Collapse) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: LargeValueWidget) {
|
||||
return (
|
||||
other.collapse.hiddenFrom === this.collapse.hiddenFrom &&
|
||||
other.collapse.hiddenTo === this.collapse.hiddenTo
|
||||
);
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
const { hiddenFrom, hiddenTo, valueFrom, valueTo } = this.collapse;
|
||||
const el = document.createElement("span");
|
||||
el.className = "cm-largeValue";
|
||||
el.textContent = `⋯ ${(hiddenTo - hiddenFrom).toLocaleString()} characters hidden ⋯`;
|
||||
el.title = "View full value";
|
||||
el.addEventListener("mousedown", (e) => {
|
||||
// Keep the editor from putting a cursor behind the dialog
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
LargeValueDialog.show(view.state.sliceDoc(valueFrom, valueTo));
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface Line {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** Lines long enough to be a problem. Most documents have none, and we stop there. */
|
||||
function findLongLines(text: string): Line[] {
|
||||
if (text.length <= MAX_VISIBLE_LINE_CHARS) {
|
||||
return []; // No line can be longer than the whole text
|
||||
}
|
||||
|
||||
const lines: Line[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const newline = text.indexOf("\n", from);
|
||||
const to = newline < 0 ? text.length : newline;
|
||||
if (to - from > MAX_VISIBLE_LINE_CHARS) {
|
||||
lines.push({ from, to });
|
||||
}
|
||||
if (newline < 0) {
|
||||
return lines;
|
||||
}
|
||||
from = newline + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the long lines, as far as parsing got in the time allowed. */
|
||||
function treeForLongLines(state: EditorState, longLines: Line[]): SyntaxTree {
|
||||
const lastLine = longLines[longLines.length - 1];
|
||||
if (lastLine == null) {
|
||||
return syntaxTree(state);
|
||||
}
|
||||
return ensureSyntaxTree(state, lastLine.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
||||
}
|
||||
|
||||
/** Tokens on this line big enough to collapse on their own, in document order. */
|
||||
function findLargeTokens(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
|
||||
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 valueFrom = Math.max(node.from, line.from);
|
||||
const valueTo = Math.min(node.to, line.to);
|
||||
const hiddenFrom = valueFrom + COLLAPSE_KEEP_CHARS;
|
||||
const hiddenTo = valueTo - COLLAPSE_KEEP_CHARS;
|
||||
if (hiddenTo > hiddenFrom) {
|
||||
collapses.push({ hiddenFrom, hiddenTo, valueFrom, valueTo });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return collapses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.hiddenFrom;
|
||||
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.hiddenTo;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collapsesForLine(tree: SyntaxTree, line: Line): Collapse[] {
|
||||
const tokens = findLargeTokens(tree, line);
|
||||
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.hiddenTo <= cut);
|
||||
kept.push({ hiddenFrom: cut, hiddenTo: line.to, valueFrom: cut, valueTo: line.to });
|
||||
return kept;
|
||||
}
|
||||
|
||||
function buildDecorations(state: EditorState, longLines: Line[]): DecorationSet {
|
||||
if (longLines.length === 0) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
const tree = treeForLongLines(state, longLines);
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
for (const line of longLines) {
|
||||
for (const collapse of collapsesForLine(tree, line)) {
|
||||
ranges.push(
|
||||
Decoration.replace({ widget: new LargeValueWidget(collapse) }).range(
|
||||
collapse.hiddenFrom,
|
||||
collapse.hiddenTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Decoration.set(ranges);
|
||||
}
|
||||
|
||||
interface LargeValueState {
|
||||
longLines: Line[];
|
||||
decorations: DecorationSet;
|
||||
}
|
||||
|
||||
export const largeValueField = StateField.define<LargeValueState>({
|
||||
create(state) {
|
||||
const longLines = findLongLines(state.doc.toString());
|
||||
return { longLines, decorations: buildDecorations(state, longLines) };
|
||||
},
|
||||
|
||||
update(value, tr) {
|
||||
if (tr.docChanged) {
|
||||
const longLines = findLongLines(tr.state.doc.toString());
|
||||
return { longLines, decorations: buildDecorations(tr.state, longLines) };
|
||||
}
|
||||
// Parsing is incremental, so a long line may only become a known token later. Documents
|
||||
// with no long line can never gain a collapse, so they skip this entirely.
|
||||
if (value.longLines.length > 0 && syntaxTree(tr.startState) !== syntaxTree(tr.state)) {
|
||||
return { ...value, decorations: buildDecorations(tr.state, value.longLines) };
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
provide: (f) => [
|
||||
EditorView.decorations.from(f, (v) => v.decorations),
|
||||
// Step the cursor over a placeholder instead of stranding it inside
|
||||
EditorView.atomicRanges.of(
|
||||
(view) => view.state.field(f, false)?.decorations ?? Decoration.none,
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
export const largeValues: Extension = [largeValueField];
|
||||
@@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { RadioDropdown } from "./RadioDropdown";
|
||||
|
||||
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;
|
||||
@@ -53,7 +62,7 @@ export type PairEditorProps = {
|
||||
nameValidate?: InputProps["validate"];
|
||||
noScroll?: boolean;
|
||||
onChange: (pairs: PairWithId[]) => void;
|
||||
pairs: Pair[];
|
||||
pairs: EditablePair[];
|
||||
stateKey: InputProps["stateKey"];
|
||||
setRef?: (n: PairEditorHandle) => void;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
@@ -72,13 +81,35 @@ export type Pair = {
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
isFile?: boolean;
|
||||
readOnlyName?: 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;
|
||||
|
||||
@@ -106,8 +137,8 @@ export function PairEditor({
|
||||
setRef,
|
||||
}: PairEditorProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
||||
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||
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.
|
||||
@@ -115,16 +146,38 @@ export function PairEditor({
|
||||
|
||||
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
||||
|
||||
const pendingFocusFrame = useRef<number | null>(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<PairEditorHandle>(
|
||||
() => ({
|
||||
focusName(id: string) {
|
||||
rowsRef.current[id]?.focusName();
|
||||
focusWhenReady(id, "name");
|
||||
},
|
||||
focusValue(id: string) {
|
||||
rowsRef.current[id]?.focusValue();
|
||||
focusWhenReady(id, "value");
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[focusWhenReady],
|
||||
);
|
||||
|
||||
const initPairEditorRow = useCallback(
|
||||
@@ -147,7 +200,7 @@ export function PairEditor({
|
||||
// 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: PairWithId[] = [];
|
||||
const newPairs: EditablePairWithId[] = [];
|
||||
for (let i = 0; i < originalPairs.length; i++) {
|
||||
const p = originalPairs[i];
|
||||
if (!p) continue; // Make TS happy
|
||||
@@ -155,6 +208,21 @@ export function PairEditor({
|
||||
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)) {
|
||||
@@ -166,10 +234,10 @@ export function PairEditor({
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
const setPairsAndSave = useCallback(
|
||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
||||
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const pairs = fn(oldPairs);
|
||||
onChange(pairs);
|
||||
onChange(pairs.map(toPairData));
|
||||
return pairs;
|
||||
});
|
||||
},
|
||||
@@ -177,7 +245,7 @@ export function PairEditor({
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(pair: PairWithId) =>
|
||||
(pair: EditablePairWithId) =>
|
||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
@@ -362,14 +430,14 @@ export function PairEditor({
|
||||
|
||||
type PairEditorRowProps = {
|
||||
className?: string;
|
||||
pair: PairWithId;
|
||||
pair: EditablePairWithId;
|
||||
forceFocusNamePairId?: string | null;
|
||||
forceFocusValuePairId?: string | null;
|
||||
onChange?: (pair: PairWithId) => void;
|
||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
||||
onFocusName?: (pair: PairWithId) => void;
|
||||
onFocusValue?: (pair: PairWithId) => void;
|
||||
onSubmit?: (pair: PairWithId) => void;
|
||||
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;
|
||||
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
|
||||
>;
|
||||
|
||||
interface RowHandle {
|
||||
focusName(): void;
|
||||
focusValue(): void;
|
||||
focusName(): boolean;
|
||||
focusValue(): boolean;
|
||||
}
|
||||
|
||||
export function PairEditorRow({
|
||||
@@ -436,9 +504,11 @@ export function PairEditorRow({
|
||||
const handle = useRef<RowHandle>({
|
||||
focusName() {
|
||||
nameInputRef.current?.focus();
|
||||
return nameInputRef.current?.isFocused() ?? false;
|
||||
},
|
||||
focusValue() {
|
||||
valueInputRef.current?.focus();
|
||||
return valueInputRef.current?.isFocused() ?? false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,11 +541,37 @@ export function PairEditorRow({
|
||||
[onChange, pair],
|
||||
);
|
||||
|
||||
// The name being typed into a deferred-commit field, before it's committed or reverted
|
||||
const pendingName = useRef<string | null>(null);
|
||||
|
||||
const handleChangeName = useMemo(
|
||||
() => (name: string) => onChange?.({ ...pair, name }),
|
||||
() => (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],
|
||||
@@ -596,7 +692,7 @@ export function PairEditorRow({
|
||||
stateKey={`name.${pair.id}.${stateKey}`}
|
||||
disabled={disabled}
|
||||
wrapLines={false}
|
||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
||||
readOnly={isDraggingGlobal}
|
||||
size="sm"
|
||||
required={!isLast && !!pair.enabled && !!pair.value}
|
||||
validate={nameValidate}
|
||||
@@ -606,6 +702,7 @@ export function PairEditorRow({
|
||||
defaultValue={pair.name}
|
||||
label="Name"
|
||||
name={`name[${index}]`}
|
||||
onBlur={handleBlurName}
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocusName}
|
||||
placeholder={namePlaceholder ?? "name"}
|
||||
@@ -808,7 +905,7 @@ function FileActionsDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function emptyPair(): PairWithId {
|
||||
function emptyPair(): EditablePairWithId {
|
||||
return ensurePairId({ enabled: true, name: "", value: "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import type { Pair, PairWithId } from "./PairEditor";
|
||||
|
||||
export function ensurePairId(p: Pair): PairWithId {
|
||||
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
||||
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
||||
if (typeof p.id === "string") {
|
||||
return p as PairWithId;
|
||||
return p as T & PairWithId;
|
||||
}
|
||||
return { ...p, id: p.id ?? generateId() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { docFingerprint } from "./docFingerprint";
|
||||
|
||||
describe("docFingerprint", () => {
|
||||
test("is stable for the same text", () => {
|
||||
expect(docFingerprint("hello")).toBe(docFingerprint("hello"));
|
||||
});
|
||||
|
||||
test("differs on different short text", () => {
|
||||
expect(docFingerprint("hello")).not.toBe(docFingerprint("world"));
|
||||
});
|
||||
|
||||
test("differs on length alone", () => {
|
||||
expect(docFingerprint("a".repeat(1_000_000))).not.toBe(docFingerprint("a".repeat(1_000_001)));
|
||||
});
|
||||
|
||||
test("notices a change at the start of a large document", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
expect(docFingerprint(doc)).not.toBe(docFingerprint(`b${doc.slice(1)}`));
|
||||
});
|
||||
|
||||
test("notices a change at the end of a large document", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
expect(docFingerprint(doc)).not.toBe(docFingerprint(`${doc.slice(0, -1)}b`));
|
||||
});
|
||||
|
||||
test("notices a change in the middle of a large document", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
const middle = doc.length / 2;
|
||||
const changed = `${doc.slice(0, middle)}b${doc.slice(middle + 1)}`;
|
||||
expect(doc.length).toBe(changed.length);
|
||||
expect(docFingerprint(doc)).not.toBe(docFingerprint(changed));
|
||||
});
|
||||
|
||||
test("hashes small documents in full, so any change is caught", () => {
|
||||
const doc = "a".repeat(100);
|
||||
for (let i = 0; i < doc.length; i++) {
|
||||
const changed = `${doc.slice(0, i)}b${doc.slice(i + 1)}`;
|
||||
expect(docFingerprint(doc)).not.toBe(docFingerprint(changed));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
/** How much of each end and the middle to hash */
|
||||
const SAMPLE_CHARS = 512;
|
||||
|
||||
/**
|
||||
* A cheap stand-in for hashing a whole document.
|
||||
*
|
||||
* The editor caches undo history, folds and selection in sessionStorage, keyed by a hash of
|
||||
* the document so a stale entry is never restored onto different content. Hashing the whole
|
||||
* document costs about 4 ms per megabyte, and it is paid on every editor update as well as on
|
||||
* restore, which is a lot of work to protect a fold position.
|
||||
*
|
||||
* Sampling the ends and the middle alongside the exact length is enough: two different
|
||||
* documents would have to agree on length and all three samples to collide, and the cost of a
|
||||
* collision is a fold or cursor landing where it doesn't belong. Documents small enough to
|
||||
* hash outright still are.
|
||||
*/
|
||||
export function docFingerprint(text: string): string {
|
||||
if (text.length <= SAMPLE_CHARS * 3) {
|
||||
return `${text.length}:${md5(text)}`;
|
||||
}
|
||||
|
||||
const middle = Math.floor((text.length - SAMPLE_CHARS) / 2);
|
||||
return [
|
||||
text.length,
|
||||
md5(text.slice(0, SAMPLE_CHARS)),
|
||||
md5(text.slice(middle, middle + SAMPLE_CHARS)),
|
||||
md5(text.slice(-SAMPLE_CHARS)),
|
||||
].join(":");
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
||||
import {
|
||||
derivePathPlaceholderPairs,
|
||||
extractPathPlaceholders,
|
||||
renamePathPlaceholder,
|
||||
} from "./pathPlaceholders";
|
||||
|
||||
describe("extractPathPlaceholders", () => {
|
||||
test("extracts a single placeholder", () => {
|
||||
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
|
||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePathPlaceholderPairs", () => {
|
||||
const neverRename = () => false;
|
||||
|
||||
test("adds a row for a placeholder with no parameter", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
|
||||
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[parameter],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
|
||||
});
|
||||
|
||||
test("leaves query parameters alone", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
|
||||
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("commitName renames this row's placeholder", () => {
|
||||
const renames: [string, string][] = [];
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/a/:x/b/:y",
|
||||
[],
|
||||
(oldName, newName) => {
|
||||
renames.push([oldName, newName]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
urlParameterPairs[1]?.commitName?.(":z");
|
||||
expect(renames).toEqual([[":y", ":z"]]);
|
||||
});
|
||||
|
||||
test("drops empty parameters", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users",
|
||||
[
|
||||
{ name: "", value: "", enabled: true, id: "p1" },
|
||||
{ name: "q", value: "", enabled: true, id: "p2" },
|
||||
],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
|
||||
});
|
||||
|
||||
test("collapses a placeholder that appears twice into one row", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
|
||||
});
|
||||
|
||||
test("gives a derived row the same id every time, so re-deriving is stable", () => {
|
||||
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
|
||||
});
|
||||
|
||||
test("derived row ids avoid colliding with a persisted derived id", () => {
|
||||
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
|
||||
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
|
||||
// and the replacement placeholder's row must not collide with it.
|
||||
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
|
||||
const ids = urlParameterPairs.map((p) => p.id);
|
||||
expect(new Set(ids).size).toEqual(ids.length);
|
||||
});
|
||||
|
||||
test("keeps a derived row's id stable across a rename", () => {
|
||||
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
|
||||
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
|
||||
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
|
||||
before.urlParameterPairs.map((p) => p.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("keys off the placeholder names", () => {
|
||||
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
|
||||
":x,:y",
|
||||
);
|
||||
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renamePathPlaceholder", () => {
|
||||
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
|
||||
url,
|
||||
urlParameters,
|
||||
});
|
||||
|
||||
test("renames the placeholder in the URL", () => {
|
||||
expect(
|
||||
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
|
||||
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
|
||||
});
|
||||
|
||||
test("carries the parameter value over to the new name", () => {
|
||||
const patch = renamePathPlaceholder(
|
||||
model("/pets/:petId", [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":petId", value: "42" },
|
||||
]),
|
||||
":petId",
|
||||
":animalId",
|
||||
);
|
||||
expect(patch).toEqual({
|
||||
url: "/pets/:animalId",
|
||||
urlParameters: [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":animalId", value: "42" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("renames every occurrence of a repeated placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:key",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds a missing leading colon", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
|
||||
"/pets/:animalId",
|
||||
);
|
||||
});
|
||||
|
||||
test("renames a placeholder followed by a literal colon", () => {
|
||||
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
|
||||
"/tasks/:taskId:cancel",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rename a placeholder the new name is a prefix of", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:idx",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not touch a same-named segment that isn't a placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
|
||||
"/id/:key?x=:id",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats regex characters in the old name literally", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:iXd",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
|
||||
"rejects the unusable name %j",
|
||||
(name) => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a name already used by another placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
|
||||
});
|
||||
|
||||
test("allows renaming a placeholder to itself", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
|
||||
"/pets/:petId",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects renaming a placeholder that isn't in the URL", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||
import type { EditablePair } from "../components/core/PairEditor";
|
||||
|
||||
/**
|
||||
* Extract `:name`-style path placeholders from a URL string.
|
||||
*
|
||||
@@ -12,3 +15,88 @@
|
||||
export function extractPathPlaceholders(url: string): string[] {
|
||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
|
||||
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
|
||||
* in the URL still gets a single row.
|
||||
*
|
||||
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
|
||||
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
|
||||
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
|
||||
*
|
||||
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
|
||||
* editor so derived rows appear and disappear along with the URL.
|
||||
*/
|
||||
export function derivePathPlaceholderPairs(
|
||||
url: string,
|
||||
urlParameters: HttpUrlParameter[],
|
||||
renamePlaceholder: (oldName: string, newName: string) => boolean,
|
||||
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
|
||||
const placeholderNames = extractPathPlaceholders(url);
|
||||
const commitNameFor = (oldName: string) => (newName: string) =>
|
||||
renamePlaceholder(oldName, newName);
|
||||
|
||||
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
|
||||
// persisted model.
|
||||
const urlParameterPairs: EditablePair[] = urlParameters
|
||||
.filter((p) => p.name || p.value)
|
||||
.map((p) =>
|
||||
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
|
||||
);
|
||||
|
||||
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
|
||||
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
|
||||
// changed id remounts the row and drops the user's focus.
|
||||
//
|
||||
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
|
||||
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
|
||||
// so a new placeholder at that position can't collide with it.
|
||||
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
|
||||
const uniquePlaceholderNames = [...new Set(placeholderNames)];
|
||||
for (const [index, name] of uniquePlaceholderNames.entries()) {
|
||||
if (urlParameterPairs.some((p) => p.name === name)) continue;
|
||||
|
||||
let id = `path-placeholder:${index}`;
|
||||
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
|
||||
takenIds.add(id);
|
||||
|
||||
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
|
||||
}
|
||||
|
||||
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
|
||||
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
|
||||
* together, or the value detaches from the placeholder.
|
||||
*
|
||||
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
|
||||
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
|
||||
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
|
||||
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
|
||||
* selects all of its text and typing over it is the natural way to rename.
|
||||
*/
|
||||
export function renamePathPlaceholder(
|
||||
model: { url: string; urlParameters: HttpUrlParameter[] },
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): { url: string; urlParameters: HttpUrlParameter[] } | null {
|
||||
const name = newName.startsWith(":") ? newName : `:${newName}`;
|
||||
if (!/^:[^/?#:\s]+$/.test(name)) return null;
|
||||
|
||||
const placeholderNames = extractPathPlaceholders(model.url);
|
||||
if (!placeholderNames.includes(oldName)) return null;
|
||||
if (name !== oldName && placeholderNames.includes(name)) return null;
|
||||
|
||||
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
|
||||
return {
|
||||
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
|
||||
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -247,7 +247,8 @@ impl PluginManager {
|
||||
pub async fn list_bundled_plugin_dirs(&self) -> Result<Vec<String>> {
|
||||
let plugins_dir = self.get_plugins_dir();
|
||||
info!("Loading bundled plugins from {plugins_dir:?}");
|
||||
read_plugins_dir(&plugins_dir).await
|
||||
let dirs = read_plugins_dir(&plugins_dir).await?;
|
||||
Ok(dirs.into_iter().filter(|dir| !is_removed_bundled_plugin_dir(dir)).collect())
|
||||
}
|
||||
|
||||
pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
@@ -1173,6 +1174,23 @@ fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
|
||||
candidate.created_at > existing.created_at
|
||||
}
|
||||
|
||||
/// Bundled plugin directories that shipped in past versions and no longer exist. Updates
|
||||
/// can leave these behind on disk, where they'd be discovered as bundled plugins and load
|
||||
/// alongside the plugin that replaced them, producing duplicate actions in menus.
|
||||
///
|
||||
/// Ignoring them here also drops any plugin rows users already have, because bundled rows
|
||||
/// whose directory isn't in this list are filtered out by `resolve_plugins_for_runtime`.
|
||||
///
|
||||
/// `exporter-curl` was renamed to `action-copy-curl` in 19ffcd18, which is why affected
|
||||
/// installs show "Copy as cURL" twice.
|
||||
const REMOVED_BUNDLED_PLUGIN_DIRS: &[&str] = &["exporter-curl"];
|
||||
|
||||
/// Whether a plugin directory path is one of the known-removed bundled plugins.
|
||||
fn is_removed_bundled_plugin_dir(dir: &str) -> bool {
|
||||
let name = dir.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
REMOVED_BUNDLED_PLUGIN_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
|
||||
let mut result = read_dir(dir).await?;
|
||||
let mut dirs: Vec<String> = vec![];
|
||||
@@ -1198,3 +1216,30 @@ fn fix_windows_paths(p: &PathBuf) -> String {
|
||||
// 2. Convert backslashes to forward slashes for Node.js compatibility
|
||||
PathBuf::from(safe_path).to_slash_lossy().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_removed_bundled_plugin_dir;
|
||||
|
||||
#[test]
|
||||
fn ignores_removed_bundled_plugins() {
|
||||
assert!(is_removed_bundled_plugin_dir(
|
||||
"/Applications/Yaak.app/vendored/plugins/exporter-curl"
|
||||
));
|
||||
// Windows paths are slash-normalized before reaching here, but handle both
|
||||
assert!(is_removed_bundled_plugin_dir(
|
||||
r"C:\Users\me\AppData\Local\Yaak\vendored\plugins\exporter-curl"
|
||||
));
|
||||
assert!(is_removed_bundled_plugin_dir("vendored/plugins/exporter-curl/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_current_bundled_plugins() {
|
||||
assert!(!is_removed_bundled_plugin_dir(
|
||||
"/Applications/Yaak.app/vendored/plugins/action-copy-curl"
|
||||
));
|
||||
assert!(!is_removed_bundled_plugin_dir("vendored/plugins/importer-curl"));
|
||||
// Must match the whole directory name, not a substring
|
||||
assert!(!is_removed_bundled_plugin_dir("vendored/plugins/my-exporter-curl"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,27 @@ const expressionArg: TemplateFunctionArg = {
|
||||
const formatArg: TemplateFunctionArg = {
|
||||
name: "format",
|
||||
label: "Format String",
|
||||
description: "Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')",
|
||||
description:
|
||||
"date-fns format string to describe the output (eg. \"yyyy-MM-dd 'at' HH:mm:ss\"). " +
|
||||
"Wrap literal text in single quotes to escape it",
|
||||
optional: true,
|
||||
placeholder: "yyyy-MM-dd HH:mm:ss",
|
||||
type: "text",
|
||||
};
|
||||
|
||||
const formatDocsBanner: TemplateFunctionArg = {
|
||||
type: "banner",
|
||||
color: "info",
|
||||
inputs: [
|
||||
{
|
||||
type: "markdown",
|
||||
content:
|
||||
"Uses [date-fns format tokens](https://date-fns.org/docs/format), " +
|
||||
"not dayjs or Moment. Wrap literal text in single quotes to escape it.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
templateFunctions: [
|
||||
{
|
||||
@@ -79,8 +94,8 @@ export const plugin: PluginDefinition = {
|
||||
},
|
||||
{
|
||||
name: "timestamp.format",
|
||||
description: "Format a date using a dayjs-compatible format string",
|
||||
args: [dateArg, formatArg],
|
||||
description: "Format a date using a date-fns format string",
|
||||
args: [formatDocsBanner, dateArg, formatArg],
|
||||
previewArgs: [formatArg.name],
|
||||
onRender: async (_ctx, args) => formatDatetime(args.values),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user