Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "componentsV2/CodeEditor/components/EditorV2/plugins";
import { VariableAutocompletePopover } from "../VariableAutocompletePopover/VariableAutocompletePopover";
import { useVariableAutocomplete } from "../hooks/useVariableAutocomplete";
import { multilineExtensions, singleLineConstraint } from "./singleLineEditorExtensions";

export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
className,
Expand All @@ -26,6 +27,8 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
onPaste,
variables,
suggestions,
multiline = false,
readOnly = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
Expand All @@ -46,6 +49,7 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const onPasteRef = useRef(onPaste);
const onPressEnterRef = useRef(onPressEnter);
const previousDefaultValueRef = useRef(defaultValue);
const isPopoverPinnedRef = useRef(false);

Expand All @@ -55,7 +59,8 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
onBlurRef.current = onBlur;
onChangeRef.current = onChange;
onPasteRef.current = onPaste;
}, [onBlur, onChange, onPaste]);
onPressEnterRef.current = onPressEnter;
}, [onBlur, onChange, onPaste, onPressEnter]);

const [hoveredVariable, setHoveredVariable] = useState<string | null>(null);
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
Expand Down Expand Up @@ -102,9 +107,13 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
history(),
keymap.of(historyKeymap),
customKeyBinding,
EditorState.transactionFilter.of((tr) => {
return tr.newDoc.lines > 1 ? [] : [tr];
}),
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
multiline
? multilineExtensions((value) =>
onPressEnterRef.current?.(new KeyboardEvent("keydown", { key: "Enter" }), value)
)
: singleLineConstraint,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current?.(update.state.doc.toString());
Expand All @@ -118,14 +127,15 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
},
// Added Focus logic from New Code
focus: (_, view) => {
if (suggestions?.length) {
if (suggestions?.length && !readOnly) {
// Timeout ensures the editor is fully focused before opening menu
setTimeout(() => startCompletion(view), 0);
}
},
keypress: (event, view) => {
if (event.key === "Enter") {
onPressEnter?.(event, view.state.doc.toString());
// In multiline mode Enter is bound in multilineExtensions (Enter commits, Shift+Enter adds a line).
if (event.key === "Enter" && !multiline) {
onPressEnterRef.current?.(event, view.state.doc.toString());
}
},
paste: (event, view) => {
Expand All @@ -136,7 +146,8 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
// (e.g., for cURL import)
onPasteRef.current?.(event, pastedText);
// If parent didn't prevent default, handle multiline paste conversion
if (!event.defaultPrevented && pastedText.includes("\n")) {
// Multiline editors keep the newlines as-is.
if (!multiline && !event.defaultPrevented && pastedText.includes("\n")) {
event.preventDefault();
const singleLineText = pastedText.replace(/\\\s*\n\s*/g, " ").replace(/\n/g, " ");
view.dispatch(
Expand Down Expand Up @@ -171,7 +182,7 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
//Need to disable to implement the onChange handler
// Shouldn't be recreated every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [placeholder, variables, handleSetVariable, suggestions]);
}, [placeholder, variables, handleSetVariable, suggestions, multiline, readOnly]);

useEffect(() => {
if (defaultValue !== previousDefaultValueRef.current) {
Expand Down Expand Up @@ -206,7 +217,9 @@ export const RQSingleLineEditor: React.FC<SingleLineEditorProps> = ({
<>
<div
ref={editorRef}
className={`${className ?? ""} editor-popup-container ant-input`}
className={`${className ?? ""} editor-popup-container ant-input${
multiline ? " single-line-editor--multiline" : ""
}${multiline && readOnly ? " single-line-editor--read-only" : ""}`}
onMouseLeave={handleMouseLeave}
>
<Conditional condition={!!hoveredVariable}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,38 @@
cursor: pointer;
}
}

/*
Postman-style multi-line mode (opt-in via the `multiline` prop).
Collapsed while blurred: exactly one line tall, the rest clipped. Expanded while focused: grows
in place up to a scrollable cap.
*/
.single-line-editor--multiline {
--rq-multiline-editor-line-height: 21px;
--rq-multiline-editor-max-height: 180px;

/* variable-popover.scss forces `line-height: inherit !important` on .cm-scroller, so set it here. */
line-height: var(--rq-multiline-editor-line-height);

.cm-scroller {
max-height: var(--rq-multiline-editor-line-height);
overflow: hidden;
}

.cm-editor.cm-focused .cm-scroller {
max-height: var(--rq-multiline-editor-max-height);
overflow-y: auto;
}

/* Read-only content can't take focus, so let it expand on hover — otherwise the tail is unreadable. */
&.single-line-editor--read-only:hover .cm-scroller {
max-height: var(--rq-multiline-editor-max-height);
overflow-y: auto;
}

.cm-newline-glyph {
color: var(--requestly-color-text-placeholder);
padding-left: 2px;
user-select: none;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { newlineGlyphPositions, singleLineConstraint } from "./singleLineEditorExtensions";

const stateWith = (doc: string) => EditorState.create({ doc, extensions: [singleLineConstraint] });

const applied = (state: EditorState, insert: string, at: number) =>
state.update({ changes: { from: at, to: at, insert } }).state.doc.toString();

describe("singleLineConstraint", () => {
it("blocks a newline typed into a single-line value", () => {
expect(applied(stateWith("hello"), "\n", 5)).toBe("hello");
});

it("keeps a pre-existing multi-line value editable instead of inert (RQ-4135)", () => {
expect(applied(stateWith("check\nthis"), "!", 10)).toBe("check\nthis!");
});

it("still refuses to grow a multi-line value", () => {
expect(applied(stateWith("check\nthis"), "\n", 10)).toBe("check\nthis");
});

it("allows edits that shrink the line count", () => {
const state = stateWith("check\nthis");
expect(state.update({ changes: { from: 5, to: 6 } }).state.doc.toString()).toBe("checkthis");
});
});

describe("newlineGlyphPositions", () => {
it("marks every line end except the last", () => {
expect(newlineGlyphPositions(EditorState.create({ doc: "a\nbb\nccc" }).doc)).toEqual([1, 4]);
});

it("marks nothing for a single-line value", () => {
expect(newlineGlyphPositions(EditorState.create({ doc: "a" }).doc)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Decoration, DecorationSet, EditorView, keymap, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view";
import { EditorState, Extension, Prec, Text } from "@codemirror/state";

/**
* Keeps a single-line editor single-line without freezing a value that already contains newlines:
* only transactions that *grow* the line count are dropped, so a pre-existing multi-line value stays
* navigable, editable and copyable (RQ-4135).
*
* ponytail: an external `defaultValue` switching to a multi-line string is still blocked here.
* Consumers that need that should opt into `multiline` instead.
*/
export const singleLineConstraint: Extension = EditorState.transactionFilter.of((tr) =>
tr.newDoc.lines > Math.max(1, tr.startState.doc.lines) ? [] : [tr]
);

/** Offsets that get a `↵` glyph — every line end except the last, which has no trailing newline. */
export const newlineGlyphPositions = (doc: Text): number[] => {
const positions: number[] = [];
for (let line = 1; line < doc.lines; line++) {
positions.push(doc.line(line).to);
}
return positions;
};

class NewlineGlyphWidget extends WidgetType {
toDOM() {
const glyph = document.createElement("span");
glyph.className = "cm-newline-glyph";
glyph.textContent = "↵";
return glyph;
}

ignoreEvent() {
return true;
}
}

const newlineGlyphDecoration = Decoration.widget({ widget: new NewlineGlyphWidget(), side: 1 });

const buildNewlineGlyphs = (view: EditorView): DecorationSet =>
Decoration.set(newlineGlyphPositions(view.state.doc).map((pos) => newlineGlyphDecoration.range(pos)));

/** Renders a `↵` marker at every line end so multi-line values read as multi-line, Postman style. */
const newlineGlyphsPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = buildNewlineGlyphs(view);
}

update(update: ViewUpdate) {
if (update.docChanged) {
this.decorations = buildNewlineGlyphs(update.view);
}
}
},
{ decorations: (plugin) => plugin.decorations }
);

/**
* Postman parity for multi-line values: newlines survive untouched, `↵` glyphs mark line ends,
* Shift+Enter inserts a newline and plain Enter commits (by blurring, which the editor's blur
* handler turns into a save).
*/
export const multilineExtensions = (onCommit: (value: string) => void): Extension => [
EditorView.lineWrapping,
newlineGlyphsPlugin,
Prec.high(
keymap.of([
{
key: "Shift-Enter",
run: (view) => {
view.dispatch(view.state.replaceSelection("\n"), { scrollIntoView: true, userEvent: "input" });
return true;
},
},
{
key: "Enter",
run: (view) => {
onCommit(view.state.doc.toString());
view.contentDOM.blur();
return true;
},
},
])
),
];
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ export interface SingleLineEditorProps {
onPaste?: (event: ClipboardEvent, text: string) => void;
variables?: ScopedVariables;
suggestions?: Array<{ value: string }>;
/**
* Opt-in Postman-style multi-line mode: newlines are preserved losslessly, `↵` glyphs mark line
* ends, Shift+Enter inserts a newline and Enter commits. The editor renders collapsed to a single
* line while blurred and expands in place while focused (see singleLineEditor.scss).
*/
multiline?: boolean;
readOnly?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { EnvironmentVariableType } from "backend/environment/types";
import Logger from "lib/logger";
import { MdOutlineWarningAmber } from "@react-icons/all-files/md/MdOutlineWarningAmber";
import { VariableRow } from "../../VariablesList";
import SingleLineEditor from "../../../SingleLineEditor";

const EditableContext = React.createContext<FormInstance<any> | null>(null);

Expand Down Expand Up @@ -117,11 +118,14 @@ export const EditableCell: React.FC<EditableCellProps> = ({

switch (record.type) {
case EnvironmentVariableType.String:
// String values may contain newlines, so they get the expand-on-focus multi-line editor.
// `defaultValue` is read off the record: the antd Form value stays in sync through onChange.
return (
<Input
ref={inputRef}
disabled={disabled}
onChange={(e) => handleChange(e.target.value)}
<SingleLineEditor
multiline
readOnly={disabled}
defaultValue={String(record[dataIndex] ?? "")}
onChange={(value: string) => handleChange(value)}
placeholder={getPlaceholderText(dataIndex)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@
}
}

/* Multi-line value editor: fixed one-line height when blurred, grows in place when focused. */
td.ant-table-cell .ant-input.single-line-editor--multiline {
height: auto;
min-height: 26px;
cursor: text;

&:focus-within {
border: 1px solid var(--requestly-color-primary-500);
}
}

td.ant-table-cell:nth-last-child(2) {
border-right: none !important;
}
Expand Down