Refactor desktop app into separate client and proxy apps

This commit is contained in:
Gregory Schier
2026-03-06 09:23:19 -08:00
parent e26705f016
commit 6915778c06
613 changed files with 1356 additions and 812 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,39 @@
.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 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;
}
/* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) {
@apply rounded-b;
}
}
/* Let content grow and disable individual scrolling for sync */
.cm-editor {
@apply h-auto relative !important;
position: relative !important;
}
.cm-scroller {
@apply overflow-visible !important;
}
}

View 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)}
/>
);
}

View File

@@ -0,0 +1,488 @@
.cm-wrapper {
@apply h-full overflow-hidden;
.cm-editor {
@apply w-full block text-base;
/* Regular cursor */
.cm-cursor {
@apply border-text !important;
/* Widen the cursor a bit */
@apply border-l-[2px];
}
/* Vim-mode cursor */
.cm-fat-cursor {
@apply outline-0 bg-text !important;
@apply text-surface !important;
}
/* 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 !important;
}
}
.cm-selectionBackground {
@apply bg-selection !important;
}
/* 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 !important;
.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 dark:shadow;
-webkit-text-security: none;
* {
@apply cursor-default;
}
.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 !important;
}
}
}
&.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 !important;
@apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected {
@apply outline-text;
@apply bg-text !important;
&,
* {
@apply text-surface font-semibold !important;
}
}
}
/* 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;
@apply cursor-default !important;
}
.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 !important;
}
.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 overflow-hidden bg-surface-highlight border border-border shadow !important;
.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 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;
}
a {
@apply cursor-default !important;
&::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 text-text-subtle border border-border-subtle z-50 pointer-events-auto;
& * {
@apply font-mono text-editor !important;
}
.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 flex-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-none;
}
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 !important;
}
input {
@apply bg-surface border-border-subtle focus:border-border-focus;
@apply border outline-none;
}
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;
}
}

View File

@@ -0,0 +1,702 @@
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 type { EditorKeymap } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { GraphQLSchema } from 'graphql';
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import {
Children,
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 { 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 { HStack } from '../Stacks';
import './Editor.css';
import {
baseExtensions,
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;
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,
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],
);
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
// biome-ignore lint/correctness/useExhaustiveDependencies: none
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
language,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
language,
autocomplete,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts
// biome-ignore lint/correctness/useExhaustiveDependencies: Only reinitialize when necessary
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
return;
}
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
useTemplating,
language,
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,
),
...getExtensions({
container,
readOnly,
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);
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,
readOnly,
singleLine,
hideGutter,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly' | '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.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 }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ //
// 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());
}
saveCachedEditorState(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;
};
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 the hash of it instead.
// This will be checked on restore and put back in if it matches.
stateObj.docHash = md5(stateObj.doc);
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) {
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 !== md5(doc)) {
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,
}),
});
}
}

View 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>
);
}

View File

@@ -0,0 +1,326 @@
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 { json } from '@codemirror/lang-json';
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 { 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 { pairs } from './pairs/extension';
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';
import { searchMatchCount } from './searchMatchCount';
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: json,
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',
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'>) {
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(linter(jsonParseLinter()));
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' }),
];
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]),
];

View File

@@ -0,0 +1,183 @@
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 IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
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): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos);
const m = upto.match(IDENT);
if (!m) return null;
const from = pos - m[0].length;
return { from, to: pos, text: m[0] };
}
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(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[]): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: 'property',
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
view.dispatch({
changes: { from, to, insert: `${name}:` },
selection: { anchor: from + name.length + 1 },
});
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(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 w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position
if (inValue && fieldName) {
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 options: Completion[] = fieldNameCompletions(fieldNames);
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] })]);
}

View File

@@ -0,0 +1,75 @@
@top Query { Expr }
@skip { space+ }
@tokens {
space { std.whitespace+ }
LParen { "(" }
RParen { ")" }
Colon { ":" }
Not { "-" | "NOT" }
// Keywords (case-insensitive)
And { "AND" }
Or { "OR" }
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// field/word characters (keep generous for URLs/paths)
Word { $[A-Za-z0-9_]+ }
@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 {
Word
}
FieldValue {
Phrase
| Term
}
Term {
Word
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,27 @@
// biome-ignore-all lint: Disable for generated file
// 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:
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData:
'$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~',
goto: '#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne',
nodeNames:
'⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or',
maxTerm: 25,
nodeProps: [
['openedBy', 8, 'LParen'],
['closedBy', 9, 'RParen'],
],
propSources: [highlight],
skippedNodes: [0, 20],
repeatNodeCount: 3,
tokenData:
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
tokenizers: [0],
topRules: { Query: [0, 1] },
tokenPrec: 145,
});

View File

@@ -0,0 +1,21 @@
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/Word': t.attributeName,
'FieldValue/Term/Word': t.attributeValue,
});

View File

@@ -0,0 +1,298 @@
// 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 isIdent = (c: string) => /[A-Za-z0-9_\-./]/.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 && isIdent(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++;
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 (isIdent(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);
}

View File

@@ -0,0 +1,39 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { defaultBoost } from './twig/completion';
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletionOption[];
}
/**
* 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,
};
};
}

View 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;
// biome-ignore lint/suspicious/noAssignInExpressions: none
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()];

View File

@@ -0,0 +1,36 @@
import type { Diagnostic } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import { parse as jsonLintParse } from '@prantlf/jsonlint';
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
export function jsonParseLinter() {
return (view: EditorView): Diagnostic[] => {
try {
const doc = view.state.doc.toString();
// 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));
jsonLintParse(escapedDoc);
// biome-ignore lint/suspicious/noExplicitAny: none
} catch (err: any) {
if (!('location' in err)) {
return [];
}
// const line = location?.start?.line;
// const column = location?.start?.column;
if (err.location.start.offset) {
return [
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: 'error',
message: err.message,
},
];
}
}
return [];
};
}

View 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, []);
}

View File

@@ -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,
});

View File

@@ -0,0 +1,9 @@
@top pairs { (Key Sep Value "\n")* }
@tokens {
Sep { ":" }
Key { ":"? ![:]+ }
Value { ![\n]+ }
}
@external propSource highlight from "./highlight"

View File

@@ -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;

View 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#vVRQSSOYhYZ!UZ![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"',
},
});

View File

@@ -0,0 +1,116 @@
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
/**
* 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;
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 (!query.search) {
if (this.countEl) {
this.countEl.textContent = '0/0';
}
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = '0/0';
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
}
}
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();
}
},
);
}

View 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,
};
},
);
}

View 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);
}

View File

@@ -0,0 +1,5 @@
@top Template { Text }
@tokens {
Text { ![]+ }
}

View File

@@ -0,0 +1,3 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const Template = 1,
Text = 2;

View 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,
});

View File

@@ -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);
}

View File

@@ -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)
});

View File

@@ -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"

View File

@@ -0,0 +1,12 @@
// 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

View 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: "!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
})

View File

@@ -0,0 +1,127 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
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;
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;
if (!context.explicit && 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) => {
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,
validFor: () => true, // Not really sure why this is all it needs
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;
}

View 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;
}

View File

@@ -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,
});

View File

@@ -0,0 +1,108 @@
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 = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === 'url') {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + 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;
});
},
},
);
}

View 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;
}

View 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"

View File

@@ -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;

View File

@@ -0,0 +1,108 @@
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: We're testing this, specifically
import { describe, expect, test } from 'vitest';
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();
});
});
});

View 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,
});

View 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;
}

View File

@@ -0,0 +1,9 @@
import { genericCompletion } from '../genericCompletion';
export const completions = genericCompletion({
options: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
minMatch: 1,
});

View 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, []);
}

View 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,
});

View File

@@ -0,0 +1,19 @@
@top url { Protocol? Host Path? Query? }
Path { ("/" (Placeholder | PathSegment))+ }
Query { "?" queryPair ("&" queryPair)* }
@tokens {
Protocol { $[a-zA-Z]+ "://" }
Host { $[a-zA-Z0-9-_.:\[\]]+ }
@precedence { Protocol, Host }
Placeholder { ":" ![/?#]+ }
PathSegment { ![?#/]+ }
@precedence { Placeholder, PathSegment }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
}
@external propSource highlight from "./highlight"

View 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,
Port = 4,
Path = 5,
Placeholder = 6,
PathSegment = 7,
Query = 8;

View File

@@ -0,0 +1,20 @@
// 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:
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
stateData: '!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~',
goto: 'nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[',
nodeNames: '⚠ url Protocol Host Path Placeholder PathSegment Query',
maxTerm: 14,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
tokenizers: [0, 1, 2],
topRules: { url: [0, 1] },
tokenPrec: 63,
});