snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
41
apps/yaak-client/components/core/Editor/DiffViewer.css
Normal file
41
apps/yaak-client/components/core/Editor/DiffViewer.css
Normal file
@@ -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!;
|
||||
}
|
||||
}
|
||||
64
apps/yaak-client/components/core/Editor/DiffViewer.tsx
Normal file
64
apps/yaak-client/components/core/Editor/DiffViewer.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MergeView | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames("cm-wrapper cm-multiline h-full w-full", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
515
apps/yaak-client/components/core/Editor/Editor.css
Normal file
515
apps/yaak-client/components/core/Editor/Editor.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
770
apps/yaak-client/components/core/Editor/Editor.tsx
Normal file
770
apps/yaak-client/components/core/Editor/Editor.tsx
Normal file
@@ -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<EditorKeymap, Extension> = {
|
||||
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<string>;
|
||||
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 <EditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
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<EditorProps["onChange"]>(onChange);
|
||||
useEffect(() => {
|
||||
handleChange.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
|
||||
useEffect(() => {
|
||||
handlePaste.current = onPaste;
|
||||
}, [onPaste]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
|
||||
useEffect(() => {
|
||||
handlePasteOverwrite.current = onPasteOverwrite;
|
||||
}, [onPasteOverwrite]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
|
||||
useEffect(() => {
|
||||
handleFocus.current = onFocus;
|
||||
}, [onFocus]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
|
||||
useEffect(() => {
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
|
||||
useEffect(() => {
|
||||
handleKeyDown.current = onKeyDown;
|
||||
}, [onKeyDown]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configurePlaceholder() {
|
||||
if (cm.current === null) return;
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder));
|
||||
const effects = placeholderCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[placeholder],
|
||||
);
|
||||
|
||||
// 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(
|
||||
<IconButton
|
||||
showConfirm
|
||||
key="format"
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magic_wand"
|
||||
variant="border"
|
||||
className={classNames(actionClassName)}
|
||||
onClick={async () => {
|
||||
if (cm.current === null) return;
|
||||
const { doc } = cm.current.view.state;
|
||||
const formatted = await format(doc.toString());
|
||||
// Update editor and blur because the cursor will reset anyway
|
||||
cm.current.view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: formatted },
|
||||
});
|
||||
cm.current.view.contentDOM.blur();
|
||||
// Fire change event
|
||||
onChange?.(formatted);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
Children.map(actions, (existingChild) => {
|
||||
if (!isValidElement<{ className?: string }>(existingChild)) return null;
|
||||
const existingProps = existingChild.props;
|
||||
|
||||
return cloneElement(existingChild, {
|
||||
...existingProps,
|
||||
className: classNames(existingProps.className, actionClassName),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}, [actions, format, onChange]);
|
||||
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initEditorRef}
|
||||
className={classNames(
|
||||
className,
|
||||
"cm-wrapper text-base",
|
||||
disabled && "opacity-disabled",
|
||||
type === "password" && "cm-obscure-text",
|
||||
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
|
||||
singleLine ? "cm-singleline" : "cm-multiline",
|
||||
readOnly && "cm-readonly",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (singleLine || containerOnly) {
|
||||
return cmContainer;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full x-theme-editor bg-surface">
|
||||
{cmContainer}
|
||||
{decoratedActions && (
|
||||
<HStack
|
||||
space={1}
|
||||
justifyContent="end"
|
||||
className={classNames(
|
||||
"absolute bottom-2 left-0 right-0",
|
||||
"pointer-events-none", // No pointer events, so we don't block the editor
|
||||
)}
|
||||
>
|
||||
{decoratedActions}
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensions({
|
||||
stateKey,
|
||||
container,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
onChange,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
|
||||
stateKey: EditorProps["stateKey"];
|
||||
container: HTMLDivElement | null;
|
||||
onChange: RefObject<EditorProps["onChange"]>;
|
||||
onPaste: RefObject<EditorProps["onPaste"]>;
|
||||
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
|
||||
onFocus: RefObject<EditorProps["onFocus"]>;
|
||||
onBlur: RefObject<EditorProps["onBlur"]>;
|
||||
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>("#cm-portal") ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions, // Must be first
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
},
|
||||
blur: () => {
|
||||
onBlur.current?.();
|
||||
},
|
||||
keydown: (e, view) => {
|
||||
// Check if the hotkey matches the editor.autocomplete action
|
||||
if (eventMatchesHotkey(e, "editor.autocomplete")) {
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
onKeyDown.current?.(e);
|
||||
},
|
||||
paste: (e, v) => {
|
||||
const textData = e.clipboardData?.getData("text/plain") ?? "";
|
||||
onPaste.current?.(textData);
|
||||
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
|
||||
onPasteOverwrite.current?.(e, textData);
|
||||
}
|
||||
},
|
||||
}),
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
|
||||
...(singleLine ? [singleLineExtensions()] : []),
|
||||
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
||||
|
||||
// ------------------------ //
|
||||
// Things that must be last //
|
||||
// ------------------------ //
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.startState === update.state) return;
|
||||
|
||||
if (onChange && update.docChanged) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
|
||||
saveCachedEditorStateDebounced(stateKey, update.state);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const placeholderElFromText = (text: string | undefined) => {
|
||||
const el = document.createElement("div");
|
||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
||||
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
|
||||
return el;
|
||||
};
|
||||
|
||||
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
|
||||
// so debounce it per state key and flush when the editor unmounts.
|
||||
//
|
||||
// The cost scales with the document, and every update pays it in full:
|
||||
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
|
||||
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
|
||||
// only samples read-only ones, so editing a large body still hashes all of it
|
||||
// - `sessionStorage.setItem` is synchronous and blocks the main thread
|
||||
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
|
||||
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
|
||||
// half once documents get into the megabytes.
|
||||
const SAVE_STATE_DEBOUNCE_MS = 500;
|
||||
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
|
||||
|
||||
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
|
||||
if (!stateKey) return;
|
||||
let saver = stateSavers.get(stateKey);
|
||||
if (saver == null) {
|
||||
saver = debounce(
|
||||
(s: EditorState) => saveCachedEditorState(stateKey, s),
|
||||
SAVE_STATE_DEBOUNCE_MS,
|
||||
);
|
||||
stateSavers.set(stateKey, saver);
|
||||
}
|
||||
saver(state);
|
||||
}
|
||||
|
||||
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
|
||||
// for every state key the session has ever shown. A pending saver holds the last EditorState,
|
||||
// which holds the whole document.
|
||||
function flushCachedEditorState(stateKey: string | null) {
|
||||
if (!stateKey) return;
|
||||
const saver = stateSavers.get(stateKey);
|
||||
if (saver == null) return;
|
||||
saver.flush();
|
||||
stateSavers.delete(stateKey);
|
||||
}
|
||||
|
||||
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||
if (!stateKey || state == null) return;
|
||||
const stateObj = state.toJSON(stateFields);
|
||||
|
||||
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
|
||||
// This will be checked on restore and put back in if it matches.
|
||||
// Editable documents get the exact hash, so a collision can never restore undo history
|
||||
// belonging to other content
|
||||
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
|
||||
} catch (err) {
|
||||
console.log("Failed to save to editor state", stateKey, err);
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
try {
|
||||
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
|
||||
if (stateStr == null) return null;
|
||||
|
||||
const { docHash, ...state } = JSON.parse(stateStr);
|
||||
|
||||
// Ensure the doc matches the one that was used to save the state
|
||||
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.doc = doc;
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.log("Failed to restore editor storage", stateKey, err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeFullStateKey(stateKey: string): string {
|
||||
return `editor.${stateKey}`;
|
||||
}
|
||||
|
||||
function updateContents(view: EditorView, text: string) {
|
||||
// Replace codemirror contents
|
||||
const currentDoc = view.state.doc.toString();
|
||||
|
||||
if (currentDoc === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith(currentDoc)) {
|
||||
// If we're just appending, append only the changes. This preserves
|
||||
// things like scroll position.
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: currentDoc.length,
|
||||
insert: text.slice(currentDoc.length),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// If we're replacing everything, reset the entire content
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: 0,
|
||||
to: currentDoc.length,
|
||||
insert: text,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/yaak-client/components/core/Editor/LazyEditor.tsx
Normal file
12
apps/yaak-client/components/core/Editor/LazyEditor.tsx
Normal file
@@ -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 (
|
||||
<Suspense>
|
||||
<Editor_ {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
343
apps/yaak-client/components/core/Editor/extensions.ts
Normal file
343
apps/yaak-client/components/core/Editor/extensions.ts
Normal file
@@ -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<typeof StreamLanguage.define>[0]) => {
|
||||
return () => new LanguageSupport(StreamLanguage.define(mode));
|
||||
};
|
||||
|
||||
const syntaxExtensions: Record<
|
||||
NonNullable<EditorProps["language"]>,
|
||||
null | (() => LanguageSupport)
|
||||
> = {
|
||||
graphql: null,
|
||||
json: jsonc,
|
||||
javascript: javascript,
|
||||
// HTML as XML because HTML is oddly slow
|
||||
html: xml,
|
||||
xml: xml,
|
||||
url: url,
|
||||
pairs: pairs,
|
||||
text: text,
|
||||
timeline: timeline,
|
||||
markdown: markdown,
|
||||
c: legacyLang(c),
|
||||
clojure: legacyLang(clojure),
|
||||
csharp: legacyLang(csharp),
|
||||
go: go,
|
||||
http: legacyLang(http),
|
||||
java: java,
|
||||
kotlin: legacyLang(kotlin),
|
||||
objective_c: legacyLang(objectiveC),
|
||||
ocaml: legacyLang(oCaml),
|
||||
php: php,
|
||||
powershell: legacyLang(powerShell),
|
||||
python: python,
|
||||
r: legacyLang(r),
|
||||
ruby: legacyLang(ruby),
|
||||
shell: legacyLang(shell),
|
||||
swift: legacyLang(swift),
|
||||
};
|
||||
|
||||
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
|
||||
|
||||
export function getLanguageExtension({
|
||||
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<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
|
||||
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<Node | null> {
|
||||
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]),
|
||||
];
|
||||
211
apps/yaak-client/components/core/Editor/filter/extension.ts
Normal file
211
apps/yaak-client/components/core/Editor/filter/extension.ts
Normal file
@@ -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, { values?: string[] | (() => string[]); info?: string }>;
|
||||
} {
|
||||
const fieldNames: string[] = [];
|
||||
const fieldMap: Record<string, { values?: string[] | (() => 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] })]);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
22
apps/yaak-client/components/core/Editor/filter/filter.ts
Normal file
22
apps/yaak-client/components/core/Editor/filter/filter.ts
Normal file
@@ -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
|
||||
})
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
7
apps/yaak-client/components/core/Editor/filter/format.ts
Normal file
7
apps/yaak-client/components/core/Editor/filter/format.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
22
apps/yaak-client/components/core/Editor/filter/highlight.ts
Normal file
22
apps/yaak-client/components/core/Editor/filter/highlight.ts
Normal file
@@ -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,
|
||||
});
|
||||
308
apps/yaak-client/components/core/Editor/filter/query.ts
Normal file
308
apps/yaak-client/components/core/Editor/filter/query.ts
Normal file
@@ -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<Tok, { kind: "PHRASE" }>;
|
||||
return { type: "Phrase", value: t.text };
|
||||
}
|
||||
|
||||
// Field or bare word
|
||||
if (this.at("WORD")) {
|
||||
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
|
||||
|
||||
if (this.at("COLON")) {
|
||||
// field:value or field:"phrase"
|
||||
this.advance(); // :
|
||||
let value: string;
|
||||
if (this.at("PHRASE")) {
|
||||
const p = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
|
||||
value = p.text;
|
||||
} else if (this.at("WORD")) {
|
||||
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
|
||||
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<Tok, { kind: "WORD" }>;
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, string[]> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
43
apps/yaak-client/components/core/Editor/genericCompletion.ts
Normal file
43
apps/yaak-client/components/core/Editor/genericCompletion.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
129
apps/yaak-client/components/core/Editor/hyperlink/extension.ts
Normal file
129
apps/yaak-client/components/core/Editor/hyperlink/extension.ts
Normal file
@@ -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()];
|
||||
60
apps/yaak-client/components/core/Editor/json-lint.ts
Normal file
60
apps/yaak-client/components/core/Editor/json-lint.ts
Normal file
@@ -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<string, string> = {
|
||||
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",
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
136
apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
Normal file
136
apps/yaak-client/components/core/Editor/json/jsonPath.test.ts
Normal file
@@ -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: `<xml/>` });
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
102
apps/yaak-client/components/core/Editor/json/jsonPath.ts
Normal file
102
apps/yaak-client/components/core/Editor/json/jsonPath.ts
Normal file
@@ -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("");
|
||||
}
|
||||
500
apps/yaak-client/components/core/Editor/largeValues.test.ts
Normal file
500
apps/yaak-client/components/core/Editor/largeValues.test.ts
Normal file
@@ -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":"<tag>"`
|
||||
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);
|
||||
});
|
||||
});
|
||||
584
apps/yaak-client/components/core/Editor/largeValues.ts
Normal file
584
apps/yaak-client/components/core/Editor/largeValues.ts
Normal file
@@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
||||
|
||||
/**
|
||||
* 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<Decoration>[] = [];
|
||||
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")];
|
||||
12
apps/yaak-client/components/core/Editor/pairs/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/pairs/extension.ts
Normal file
@@ -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, []);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
@top pairs { (Key Sep Value "\n")* }
|
||||
|
||||
@tokens {
|
||||
Sep { ":" $[ \t]+ }
|
||||
Key { ":"? ![:]+ }
|
||||
Value { ![\n]+ }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
@@ -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;
|
||||
26
apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
Normal file
26
apps/yaak-client/components/core/Editor/pairs/pairs.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
29
apps/yaak-client/components/core/Editor/pairs/pairs.ts
Normal file
29
apps/yaak-client/components/core/Editor/pairs/pairs.ts
Normal file
@@ -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"',
|
||||
},
|
||||
});
|
||||
202
apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
Normal file
202
apps/yaak-client/components/core/Editor/searchMatchCount.test.ts
Normal file
@@ -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<typeof SearchQuery>[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));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
325
apps/yaak-client/components/core/Editor/searchMatchCount.ts
Normal file
325
apps/yaak-client/components/core/Editor/searchMatchCount.ts
Normal file
@@ -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<string, string>();
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
47
apps/yaak-client/components/core/Editor/singleLine.ts
Normal file
47
apps/yaak-client/components/core/Editor/singleLine.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
170
apps/yaak-client/components/core/Editor/sniffValue.test.ts
Normal file
170
apps/yaak-client/components/core/Editor/sniffValue.test.ts
Normal file
@@ -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("<svg/>")}`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
219
apps/yaak-client/components/core/Editor/sniffValue.ts
Normal file
219
apps/yaak-client/components/core/Editor/sniffValue.ts
Normal file
@@ -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:[<mime>][;<param>…],` — 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<string, { mime: string; label: string }> = {
|
||||
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<string, string> = {
|
||||
"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();
|
||||
}
|
||||
12
apps/yaak-client/components/core/Editor/text/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/text/extension.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const Template = 1,
|
||||
Text = 2;
|
||||
16
apps/yaak-client/components/core/Editor/text/text.ts
Normal file
16
apps/yaak-client/components/core/Editor/text/text.ts
Normal file
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
21
apps/yaak-client/components/core/Editor/timeline/timeline.ts
Normal file
21
apps/yaak-client/components/core/Editor/timeline/timeline.ts
Normal file
@@ -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,
|
||||
});
|
||||
155
apps/yaak-client/components/core/Editor/twig/completion.test.ts
Normal file
155
apps/yaak-client/components/core/Editor/twig/completion.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
156
apps/yaak-client/components/core/Editor/twig/completion.ts
Normal file
156
apps/yaak-client/components/core/Editor/twig/completion.ts
Normal file
@@ -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<T, K extends keyof T>(arr: T[], key: K): T[] {
|
||||
const map = new Map<T[K], T>();
|
||||
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;
|
||||
}
|
||||
85
apps/yaak-client/components/core/Editor/twig/extension.ts
Normal file
85
apps/yaak-client/components/core/Editor/twig/extension.ts
Normal file
@@ -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<string, LRLanguage> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
106
apps/yaak-client/components/core/Editor/twig/pathParameters.ts
Normal file
106
apps/yaak-client/components/core/Editor/twig/pathParameters.ts
Normal file
@@ -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<Decoration>[] = [];
|
||||
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;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
224
apps/yaak-client/components/core/Editor/twig/templateTags.ts
Normal file
224
apps/yaak-client/components/core/Editor/twig/templateTags.ts
Normal file
@@ -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<Decoration>[] = [];
|
||||
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;
|
||||
}
|
||||
17
apps/yaak-client/components/core/Editor/twig/twig.grammar
Normal file
17
apps/yaak-client/components/core/Editor/twig/twig.grammar
Normal file
@@ -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"
|
||||
@@ -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;
|
||||
108
apps/yaak-client/components/core/Editor/twig/twig.test.ts
Normal file
108
apps/yaak-client/components/core/Editor/twig/twig.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
20
apps/yaak-client/components/core/Editor/twig/twig.ts
Normal file
20
apps/yaak-client/components/core/Editor/twig/twig.ts
Normal file
@@ -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,
|
||||
});
|
||||
37
apps/yaak-client/components/core/Editor/twig/util.ts
Normal file
37
apps/yaak-client/components/core/Editor/twig/util.ts
Normal file
@@ -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<string, string | boolean> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
51
apps/yaak-client/components/core/Editor/url/completion.ts
Normal file
51
apps/yaak-client/components/core/Editor/url/completion.ts
Normal file
@@ -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<GenericCompletion>((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));
|
||||
12
apps/yaak-client/components/core/Editor/url/extension.ts
Normal file
12
apps/yaak-client/components/core/Editor/url/extension.ts
Normal file
@@ -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, []);
|
||||
}
|
||||
10
apps/yaak-client/components/core/Editor/url/highlight.ts
Normal file
10
apps/yaak-client/components/core/Editor/url/highlight.ts
Normal file
@@ -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,
|
||||
});
|
||||
24
apps/yaak-client/components/core/Editor/url/url.grammar
Normal file
24
apps/yaak-client/components/core/Editor/url/url.grammar
Normal file
@@ -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"
|
||||
9
apps/yaak-client/components/core/Editor/url/url.terms.ts
Normal file
9
apps/yaak-client/components/core/Editor/url/url.terms.ts
Normal file
@@ -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
|
||||
52
apps/yaak-client/components/core/Editor/url/url.test.ts
Normal file
52
apps/yaak-client/components/core/Editor/url/url.test.ts
Normal file
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
18
apps/yaak-client/components/core/Editor/url/url.ts
Normal file
18
apps/yaak-client/components/core/Editor/url/url.ts
Normal file
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user