Skip to content
Merged
25 changes: 18 additions & 7 deletions packages/insomnia/src/common/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export async function buildRenderContext({
}

if (subEnvironment) {
const ordered = orderedJSON.order(subEnvironment.data, subEnvironment.dataPropertyOrder ?? null, JSON_ORDER_SEPARATOR);
const ordered = orderedJSON.order(
subEnvironment.data,
subEnvironment.dataPropertyOrder ?? null,
JSON_ORDER_SEPARATOR,
);
envObjects.push(ordered);
}

Expand Down Expand Up @@ -428,19 +432,26 @@ export async function getRenderContext({
const inKey = NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME;

if (rootGlobalEnvironment) {
getKeySource(rootGlobalEnvironment.data || {}, inKey, 'rootGlobal');
getKeySource(rootGlobalEnvironment.data || {}, inKey, rootGlobalEnvironment.name || 'Base Environment (Project)');
}

if (subGlobalEnvironment) {
getKeySource(subGlobalEnvironment.data || {}, inKey, 'subGlobal');
getKeySource(
subGlobalEnvironment.data || {},
inKey,
`${subGlobalEnvironment.name || 'Environment'} (Project Sub-Environment)`,
);
}

// Get Keys from root environment
getKeySource((rootEnvironment || {}).data, inKey, 'root');
getKeySource((rootEnvironment || {}).data, inKey, rootEnvironment?.name || 'Base Environment (Collection)');

// Get Keys from sub environment
if (subEnvironment) {
getKeySource(subEnvironment.data || {}, inKey, subEnvironment.name || '');
if (subEnvironment && subEnvironment._id !== rootEnvironment?._id) {
getKeySource(
subEnvironment.data || {},
inKey,
`${subEnvironment.name || 'Environment'} (Collection Sub-Environment)`,
);
}

// Get Keys from ancestors (e.g. Folders)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ async function _highlightNunjucksTags(
const el = document.createElement('span');
el.className = `nunjucks-tag ${tok.type}`;
el.setAttribute('draggable', 'true');
// Behavior hook so hover logic can detect tags without coupling to the CSS class name.
// See handleEditorMouseMove in one-line-editor.tsx for usage.
el.dataset.nunjucksTag = 'true';
el.dataset.error = 'off';
el.dataset.template = tok.string;
el.replaceChildren(document.createElement('label'), document.createTextNode(tok.string));
Expand Down Expand Up @@ -313,8 +316,8 @@ async function _updateElementText(
const context = await renderContext();
const con = context.context.getKeysContext();
const contextForKey = con.keyContext[cleanedStr];
// Only prefix the title with context, if context is found
const valueAndContext = contextForKey ? `{${contextForKey}}: ${title}` : title;
// Only suffix the title with context, if context is found
const valueAndContext = contextForKey ? `${title} {${contextForKey}}` : title;

// Swap what's shown in the tooltip vs the innerHTML
innerHTML = showVariableSourceAndValue ? valueAndContext : cleanedStr;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { showModal } from '~/ui/components/modals';
import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal';
import { UpgradeModal } from '~/ui/components/modals/upgrade-modal';
import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts';
import { Tooltip } from '~/ui/components/tooltip';
import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks';
import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh';
import { usePlanData } from '~/ui/hooks/use-plan';
Expand Down Expand Up @@ -102,10 +103,33 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
const codeMirror = useRef<CodeMirror.EditorFromTextArea | null>(null);
// We need to track editor version in order to re-apply some effects when the editor is re-initialized.
const [editorVersion, setEditorVersion] = useState(0);
const [tooltipValue, setTooltipValue] = useState<string>(
type?.toLowerCase() === 'password' ? '' : defaultValue || '',
);
const { settings } = useRootLoaderData()!;
const { isOwner, isEnterprisePlan } = usePlanData();
const { handleRender, handleGetRenderContext } = useNunjucks();

// Update the tooltip value, including rendering the value of a nunjucks tag if necessary
const updateTooltipValue = useCallback(
async (rawValue: string) => {
if (type?.toLowerCase() === 'password') {
return;
}
if (!handleRender || !/{{|{%/.test(rawValue)) {
setTooltipValue(rawValue);
return;
}
try {
setTooltipValue(await handleRender(rawValue));
} catch {
// Rendering fails when any tag in the field is invalid. Fall back to showing the raw template string that's there.
setTooltipValue(rawValue);
}
},
[handleRender, type],
);

const getKeyMap = useCallback(() => {
if (!readOnly && settings.enableKeyMapForInlineTextEditors && settings.editorKeyMap) {
return settings.editorKeyMap;
Expand Down Expand Up @@ -253,6 +277,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>

// Actually set the value
codeMirror.current?.setValue(defaultValue || '');
updateTooltipValue(defaultValue || '');
// Clear history so we can't undo the initial set
codeMirror.current?.clearHistory();
// Restore undo/redo history saved before the previous unmount so undo
Expand Down Expand Up @@ -290,6 +315,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
eventListeners,
id,
historyKey,
updateTooltipValue,
]);

const persistState = useCallback(() => {
Expand Down Expand Up @@ -406,8 +432,9 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
cm.setCursor(cursor);
// value baseline changed externally, so the old history no longer applies
cm.clearHistory();
updateTooltipValue(defaultValue || '');
}
}, [defaultValue, historyKey]);
}, [defaultValue, historyKey, type, updateTooltipValue]);

useEffect(() => {
// Prevent these things if we're type === "password"
Expand All @@ -429,10 +456,11 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
if (onChange) {
onChange(doc.getValue() || '');
}
updateTooltipValue(doc.getValue() || '');
}, DEBOUNCE_MILLIS);
codeMirror.current?.on('changes', fn);
return () => codeMirror.current?.off('changes', fn);
}, [editorVersion, onChange]);
}, [editorVersion, onChange, type, updateTooltipValue]);

useEffect(() => {
const flushOnBlur = (doc: CodeMirror.Editor) => {
Expand Down Expand Up @@ -505,45 +533,78 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
[],
);

const isContentTruncated = () => {
const scrollInfo = codeMirror.current?.getScrollInfo();
if (!scrollInfo) {
return false;
}
// CodeMirror's own CSS adds a fixed 30px to the scroller's width to hide the native
// scrollbar (see the "magic margin" comment on .CodeMirror-scroll in codemirror.css).
// scrollInfo.width always includes this extra 30px, even when the text isn't truncated
// at all, so we must subtract it back out before comparing - otherwise every line would
// incorrectly look truncated.
const CODEMIRROR_SCROLLBAR_MARGIN_PX = 30;
return scrollInfo.width > scrollInfo.clientWidth + CODEMIRROR_SCROLLBAR_MARGIN_PX;
};

// Nunjucks tags render their own native (rendered value + source) tooltip on hover. Showing the
// whole-field custom tooltip on top of that would double up and only show the raw, unrendered
// template text - so suppress the custom tooltip while the pointer is over a tag. This is tracked
// per-pointer-position (rather than per-field) so a field mixing plain text and tags still shows
// the full-value tooltip when hovering the text portion.
const isPointerOverNunjucksTag = useRef(false);
const handleEditorMouseMove = (event: React.MouseEvent) => {
isPointerOverNunjucksTag.current = Boolean((event.target as HTMLElement)?.closest?.('[data-nunjucks-tag]'));
};

return (
<div
className={classnames('editor--single-line', {
'editor': true,
'editor--readonly': readOnly,
})}
data-editor-type={type || 'text'}
data-testid="OneLineEditor"
onContextMenu={async event => {
if (readOnly) {
return;
}
event.preventDefault();
const pluginTemplateTags = await plugins.getTemplateTags();
const target = event.target as HTMLElement;
// right click on Liquid template tag
if (target?.classList?.contains('nunjucks-tag')) {
const { clientX, clientY } = event;
const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror);
if (nunjucksTag) {
// show context menu for Liquid template tag
window.main.showNunjucksContextMenu({ key: id, nunjucksTag, pluginTemplateTags });
}
} else {
window.main.showNunjucksContextMenu({ key: id, pluginTemplateTags });
}
}}
<Tooltip
message={tooltipValue}
delay={1000}
className="h-full w-full"
followCursor
shouldShow={() => Boolean(tooltipValue) && !isPointerOverNunjucksTag.current && isContentTruncated()}
>
<div ref={editorContainerRef} className="editor__container input editor--single-line">
<textarea
id={id}
ref={textAreaRef}
style={{ display: 'none' }}
readOnly={readOnly}
autoComplete="off"
defaultValue=""
/>
<div
className={classnames('editor--single-line', {
'editor': true,
'editor--readonly': readOnly,
})}
data-editor-type={type || 'text'}
data-testid="OneLineEditor"
onMouseMove={handleEditorMouseMove}
onContextMenu={async event => {
if (readOnly) {
return;
}
event.preventDefault();
const pluginTemplateTags = await plugins.getTemplateTags();
const target = event.target as HTMLElement;
// right click on Liquid template tag
if (target?.classList?.contains('nunjucks-tag')) {
const { clientX, clientY } = event;
const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror);
if (nunjucksTag) {
// show context menu for Liquid template tag
window.main.showNunjucksContextMenu({ key: id, nunjucksTag, pluginTemplateTags });
}
} else {
window.main.showNunjucksContextMenu({ key: id, pluginTemplateTags });
}
}}
>
<div ref={editorContainerRef} className="editor__container input editor--single-line">
<textarea
id={id}
ref={textAreaRef}
style={{ display: 'none' }}
readOnly={readOnly}
autoComplete="off"
defaultValue=""
/>
</div>
</div>
</div>
</Tooltip>
);
},
);
Expand Down
78 changes: 73 additions & 5 deletions packages/insomnia/src/ui/components/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classnames from 'classnames';
import React, { type CSSProperties, type ReactNode } from 'react';
import React, { type CSSProperties, type ReactNode, useEffect } from 'react';
import { mergeProps, OverlayContainer, useOverlayPosition, useTooltip, useTooltipTrigger } from 'react-aria';
import { createPortal } from 'react-dom';
import { useTooltipTriggerState } from 'react-stately';
Expand All @@ -14,37 +14,102 @@ interface Props {
wide?: boolean;
style?: CSSProperties;
onClick?: () => void;
// Anchors the tooltip to the mouse position instead of the trigger element's bounding
// box. Useful when the trigger is much wider/taller than its actual visible content
// (e.g. a text field that fills a whole table cell) - anchoring to the trigger box would
// place the tooltip relative to the cell, not to whatever the user is actually hovering.
followCursor?: boolean;
// When provided, called on hover to decide whether the tooltip should open at all. Use this
// for fields whose content is clipped/truncated, so the tooltip only appears when there's
// more content than what's visible.
shouldShow?: () => boolean;
}

export const Tooltip = (props: Props) => {
const { children, message, className, wide, selectable, delay = 400, position, style } = props;
const { children, message, className, wide, selectable, delay = 400, position, style, followCursor, shouldShow } = props;
const triggerRef = React.useRef<HTMLDivElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const dwellTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cursorPosRef = React.useRef({ x: 0, y: 0 });

const state = useTooltipTriggerState({ delay });
const trigger = useTooltipTrigger(props, state, triggerRef);
// react-stately shares a single "warmup" timer across every tooltip on the page: once one
// tooltip has shown, any other tooltip opens almost instantly for the next 500ms instead of
// waiting out `delay`. Quickly sweeping the mouse across many trigger elements (e.g. table
// rows) then makes each one flash open and closed in turn. `trigger: 'focus'` tells
// useTooltipTrigger to ignore hover entirely (keyboard/focus accessibility is unaffected),
// and we open/close on hover ourselves below with a plain dwell timer, so showing a tooltip
// always waits the full `delay` no matter what any other tooltip just did.
const trigger = useTooltipTrigger({ ...props, trigger: 'focus' }, state, triggerRef);
Comment thread
fiosman marked this conversation as resolved.
const tooltip = useTooltip(trigger.tooltipProps, state);

const { overlayProps: positionProps } = useOverlayPosition({
targetRef: triggerRef,
overlayRef,
placement: position,
offset: 5,
isOpen: state.isOpen,
isOpen: state.isOpen && !followCursor,
});

const clearDwellTimeout = () => {
if (dwellTimeout.current) {
clearTimeout(dwellTimeout.current);
dwellTimeout.current = null;
}
};

const handleMouseEnter = (e: React.MouseEvent) => {
clearDwellTimeout();
if (followCursor) {
cursorPosRef.current = { x: e.clientX, y: e.clientY };
}
dwellTimeout.current = setTimeout(() => {
dwellTimeout.current = null;
// Re-check right before opening: over the dwell the pointer may have moved to a spot
// (e.g. onto a nunjucks tag with its own tooltip) where the tooltip shouldn't show.
if (shouldShow && !shouldShow()) {
return;
}
state.open(true);
}, delay);
};

const handleMouseMove = (e: React.MouseEvent) => {
if (followCursor) {
cursorPosRef.current = { x: e.clientX, y: e.clientY };
}
// The pointer can move to a spot where the tooltip should no longer show (e.g. from plain
// text onto a nunjucks tag with its own tooltip) after it's already open. Re-check and close
// so the two tooltips don't double up.
if (state.isOpen && shouldShow && !shouldShow()) {
state.close(true);
}
};

const handleMouseLeave = () => {
clearDwellTimeout();
state.close(true);
};
Comment thread
fiosman marked this conversation as resolved.

useEffect(() => clearDwellTimeout, []);
Comment thread
fiosman marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clever


const tooltipClasses = classnames(className, 'tooltip');
const bubbleClasses = classnames('tooltip__bubble theme--tooltip', {
'tooltip__bubble--visible': state.isOpen,
'tooltip__bubble--wide': wide,
selectable,
});

const cursorStyle: CSSProperties = followCursor
? { position: 'fixed', top: cursorPosRef.current.y + 16, left: cursorPosRef.current.x + 12, margin: 0 }
: {};

const overlayContent = message ? (
<div
ref={overlayRef}
onClick={e => e.stopPropagation()}
{...mergeProps(tooltip.tooltipProps, positionProps)}
{...mergeProps(tooltip.tooltipProps, followCursor ? {} : positionProps)}
style={followCursor ? cursorStyle : undefined}
className={bubbleClasses}
>
{message}
Expand All @@ -60,6 +125,9 @@ export const Tooltip = (props: Props) => {
className={tooltipClasses}
style={{ position: 'relative', ...style }}
{...trigger.triggerProps}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={props.onClick}
>
{children}
Expand Down
Loading