diff --git a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/SingleLineEditor.tsx b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/SingleLineEditor.tsx index c25c73db00..660f6c434b 100644 --- a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/SingleLineEditor.tsx +++ b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/SingleLineEditor.tsx @@ -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 = ({ className, @@ -26,6 +27,8 @@ export const RQSingleLineEditor: React.FC = ({ onPaste, variables, suggestions, + multiline = false, + readOnly = false, }) => { const editorRef = useRef(null); const editorViewRef = useRef(null); @@ -46,6 +49,7 @@ export const RQSingleLineEditor: React.FC = ({ const onBlurRef = useRef(onBlur); const onChangeRef = useRef(onChange); const onPasteRef = useRef(onPaste); + const onPressEnterRef = useRef(onPressEnter); const previousDefaultValueRef = useRef(defaultValue); const isPopoverPinnedRef = useRef(false); @@ -55,7 +59,8 @@ export const RQSingleLineEditor: React.FC = ({ onBlurRef.current = onBlur; onChangeRef.current = onChange; onPasteRef.current = onPaste; - }, [onBlur, onChange, onPaste]); + onPressEnterRef.current = onPressEnter; + }, [onBlur, onChange, onPaste, onPressEnter]); const [hoveredVariable, setHoveredVariable] = useState(null); const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }); @@ -102,9 +107,13 @@ export const RQSingleLineEditor: React.FC = ({ 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()); @@ -118,14 +127,15 @@ export const RQSingleLineEditor: React.FC = ({ }, // 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) => { @@ -136,7 +146,8 @@ export const RQSingleLineEditor: React.FC = ({ // (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( @@ -171,7 +182,7 @@ export const RQSingleLineEditor: React.FC = ({ //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) { @@ -206,7 +217,9 @@ export const RQSingleLineEditor: React.FC = ({ <>
diff --git a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditor.scss b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditor.scss index 6734a56350..592317d904 100644 --- a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditor.scss +++ b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditor.scss @@ -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; + } +} diff --git a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.test.ts b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.test.ts new file mode 100644 index 0000000000..7ce840e788 --- /dev/null +++ b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.test.ts @@ -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([]); + }); +}); diff --git a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.ts b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.ts new file mode 100644 index 0000000000..8719d4b6a9 --- /dev/null +++ b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/singleLineEditorExtensions.ts @@ -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; + }, + }, + ]) + ), +]; diff --git a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/types.ts b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/types.ts index 31e082e613..4ffc4dc0c9 100644 --- a/app/src/features/apiClient/screens/environment/components/SingleLineEditor/types.ts +++ b/app/src/features/apiClient/screens/environment/components/SingleLineEditor/types.ts @@ -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; } diff --git a/app/src/features/apiClient/screens/environment/components/VariablesList/components/customTableRow/CustomTableRow.tsx b/app/src/features/apiClient/screens/environment/components/VariablesList/components/customTableRow/CustomTableRow.tsx index ce6b3ef1f7..e52e7dd290 100644 --- a/app/src/features/apiClient/screens/environment/components/VariablesList/components/customTableRow/CustomTableRow.tsx +++ b/app/src/features/apiClient/screens/environment/components/VariablesList/components/customTableRow/CustomTableRow.tsx @@ -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 | null>(null); @@ -117,11 +118,14 @@ export const EditableCell: React.FC = ({ 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 ( - handleChange(e.target.value)} + handleChange(value)} placeholder={getPlaceholderText(dataIndex)} /> ); diff --git a/app/src/features/apiClient/screens/environment/components/VariablesList/variablesList.scss b/app/src/features/apiClient/screens/environment/components/VariablesList/variablesList.scss index eb126d2955..642c3c9cba 100644 --- a/app/src/features/apiClient/screens/environment/components/VariablesList/variablesList.scss +++ b/app/src/features/apiClient/screens/environment/components/VariablesList/variablesList.scss @@ -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; }