Skip to content

Commit 39dfd8d

Browse files
sid-brunoabansal21chirag-bruno
committed
Feature/cmd click on links (#5927)
fix: clean up whitespace and formatting in linkAware functions fix rediff Feature/cmd click on links (#6132) * Allow ctrl/cmd + click to open URLs * fix for when user does cmd+tab, then comes back without it * refactored the community contribution to match Autocomplete's implementation * updated the code to resolve issues caused during merge conflict resolution with the use of makeLinkAware * fix: updated the code to use lodash's debounce and removed redundant undefined checks * fix: correct debouncing test expectation in linkAware.spec.js The test was incorrectly expecting 3 setTimeout calls when debouncing should only result in one active timeout. Updated the test to verify debouncing behavior correctly by checking that setTimeout is called with the correct delay, and that only one execution happens after the debounce delay. * fix: fixed merge issues in linkAware.js * fix: fixed CodeMirror assignment to this.editor * fix: formatting fixes * fix: formatting fix --------- Co-authored-by: abansal21 <[email protected]> Co-authored-by: Chirag Chandrashekhar <[email protected]>
1 parent 460832f commit 39dfd8d

File tree

7 files changed

+291
-298
lines changed

7 files changed

+291
-298
lines changed

packages/bruno-app/src/components/CodeEditor/index.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
1414
import { JSHINT } from 'jshint';
1515
import stripJsonComments from 'strip-json-comments';
1616
import { getAllVariables } from 'utils/collections';
17-
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
17+
import { setupLinkAware } from 'utils/codemirror/linkAware';
1818
import CodeMirrorSearch from 'components/CodeMirrorSearch';
1919

2020
const CodeMirror = require('codemirror');
@@ -48,7 +48,7 @@ export default class CodeEditor extends React.Component {
4848
componentDidMount() {
4949
const variables = getAllVariables(this.props.collection, this.props.item);
5050

51-
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
51+
const editor = (this.editor = CodeMirror(this._node, {
5252
value: this.props.value || '',
5353
lineNumbers: true,
5454
lineWrapping: this.props.enableLineWrapping ?? true,
@@ -205,6 +205,8 @@ export default class CodeEditor extends React.Component {
205205
editor,
206206
autoCompleteOptions
207207
);
208+
209+
setupLinkAware(editor);
208210
}
209211
}
210212

@@ -267,9 +269,7 @@ export default class CodeEditor extends React.Component {
267269

268270
componentWillUnmount() {
269271
if (this.editor) {
270-
if(this.editor._destroyLinkAware) {
271-
this.editor._destroyLinkAware();
272-
}
272+
this.editor?._destroyLinkAware?.();
273273
this.editor.off('change', this._onEdit);
274274
this.editor.off('scroll', this.onScroll);
275275
this.editor = null;

packages/bruno-app/src/components/MultiLineEditor/index.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
55
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
66
import { MaskedEditor } from 'utils/common/masked-editor';
77
import StyledWrapper from './StyledWrapper';
8-
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
8+
import { setupLinkAware } from 'utils/codemirror/linkAware';
99
import { IconEye, IconEyeOff } from '@tabler/icons';
1010

1111
const CodeMirror = require('codemirror');
@@ -30,7 +30,7 @@ class MultiLineEditor extends Component {
3030
/** @type {import("codemirror").Editor} */
3131
const variables = getAllVariables(this.props.collection, this.props.item);
3232

33-
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
33+
this.editor = CodeMirror(this.editorRef.current, {
3434
lineWrapping: false,
3535
lineNumbers: false,
3636
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
@@ -87,6 +87,8 @@ class MultiLineEditor extends Component {
8787
this.editor,
8888
autoCompleteOptions
8989
);
90+
91+
setupLinkAware(this.editor);
9092

9193
this.editor.setValue(String(this.props.value) || '');
9294
this.editor.on('change', this._onEdit);
@@ -171,11 +173,9 @@ class MultiLineEditor extends Component {
171173
if (this.brunoAutoCompleteCleanup) {
172174
this.brunoAutoCompleteCleanup();
173175
}
174-
175-
if(this.editor._destroyLinkAware) {
176+
if (this.editor?._destroyLinkAware) {
176177
this.editor._destroyLinkAware();
177178
}
178-
179179
if (this.maskedEditor) {
180180
this.maskedEditor.destroy();
181181
this.maskedEditor = null;

packages/bruno-app/src/components/RequestPane/QueryEditor/index.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import StyledWrapper from './StyledWrapper';
1717
import { IconWand } from '@tabler/icons';
1818

1919
import onHasCompletion from './onHasCompletion';
20-
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
20+
import { setupLinkAware } from 'utils/codemirror/linkAware';
2121

2222
const CodeMirror = require('codemirror');
2323

@@ -36,7 +36,7 @@ export default class QueryEditor extends React.Component {
3636
}
3737

3838
componentDidMount() {
39-
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
39+
const editor = (this.editor = CodeMirror(this._node, {
4040
value: this.props.value || '',
4141
lineNumbers: true,
4242
tabSize: 2,
@@ -139,6 +139,8 @@ export default class QueryEditor extends React.Component {
139139
editor.on('beforeChange', this._onBeforeChange);
140140
}
141141
this.addOverlay();
142+
143+
setupLinkAware(editor);
142144
}
143145

144146
componentDidUpdate(prevProps) {
@@ -171,7 +173,7 @@ export default class QueryEditor extends React.Component {
171173

172174
componentWillUnmount() {
173175
if (this.editor) {
174-
if(this.editor._destroyLinkAware) {
176+
if (this.editor?._destroyLinkAware) {
175177
this.editor._destroyLinkAware();
176178
}
177179
this.editor.off('change', this._onEdit);

packages/bruno-app/src/components/SingleLineEditor/index.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { MaskedEditor } from 'utils/common/masked-editor';
66
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
77
import StyledWrapper from './StyledWrapper';
88
import { IconEye, IconEyeOff } from '@tabler/icons';
9-
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
9+
import { setupLinkAware } from 'utils/codemirror/linkAware';
10+
11+
const CodeMirror = require('codemirror');
1012

1113
class SingleLineEditor extends Component {
1214
constructor(props) {
@@ -39,9 +41,9 @@ class SingleLineEditor extends Component {
3941
this.props.onSave();
4042
}
4143
};
42-
const noopHandler = () => {};
44+
const noopHandler = () => { };
4345

44-
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
46+
this.editor = CodeMirror(this.editorRef.current, {
4547
placeholder: this.props.placeholder ?? '',
4648
lineWrapping: false,
4749
lineNumbers: false,
@@ -93,7 +95,7 @@ class SingleLineEditor extends Component {
9395
this.editor,
9496
autoCompleteOptions
9597
);
96-
98+
9799
this.editor.setValue(String(this.props.value ?? ''));
98100
this.editor.on('change', this._onEdit);
99101
this.editor.on('paste', this._onPaste);
@@ -105,6 +107,7 @@ class SingleLineEditor extends Component {
105107
if (this.props.showNewlineArrow) {
106108
this._updateNewlineMarkers();
107109
}
110+
setupLinkAware(this.editor);
108111
}
109112

110113
/** Enable or disable masking the rendered content of the editor */
@@ -188,7 +191,7 @@ class SingleLineEditor extends Component {
188191

189192
componentWillUnmount() {
190193
if (this.editor) {
191-
if(this.editor._destroyLinkAware) {
194+
if (this.editor?._destroyLinkAware) {
192195
this.editor._destroyLinkAware();
193196
}
194197
this.editor.off('change', this._onEdit);
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import LinkifyIt from 'linkify-it';
2+
import { isMacOS } from 'utils/common/platform';
3+
import { debounce } from 'lodash';
4+
/**
5+
* Marks URLs in the CodeMirror editor with clickable link styling
6+
* @param {Object} editor - The CodeMirror editor instance
7+
* @param {Object} linkify - The LinkifyIt instance for URL detection
8+
* @param {string} linkClass - CSS class name for links
9+
* @param {string} linkHint - Tooltip text for links
10+
*/
11+
function markUrls(editor, linkify, linkClass, linkHint) {
12+
const doc = editor.getDoc();
13+
const text = doc.getValue();
14+
15+
// Clear existing link marks
16+
editor.getAllMarks().forEach((mark) => {
17+
if (mark.className === linkClass) mark.clear();
18+
});
19+
20+
// Find and mark new URLs
21+
const matches = linkify.match(text);
22+
matches?.forEach(({ index, lastIndex, url }) => {
23+
const from = editor.posFromIndex(index);
24+
const to = editor.posFromIndex(lastIndex);
25+
editor.markText(from, to, {
26+
className: linkClass,
27+
attributes: {
28+
'data-url': url,
29+
'title': linkHint
30+
}
31+
});
32+
});
33+
}
34+
35+
/**
36+
* Handles mouse enter events on links to show hover effects
37+
* @param {Event} event - The mouse enter event
38+
* @param {string} linkClass - CSS class name for links
39+
* @param {string} linkHoverClass - CSS class name for hovered links
40+
* @param {Function} updateCmdCtrlClass - Function to update Cmd/Ctrl state
41+
*/
42+
function handleMouseEnter(event, linkClass, linkHoverClass, updateCmdCtrlClass) {
43+
const el = event.target;
44+
if (!el.classList.contains(linkClass)) return;
45+
46+
updateCmdCtrlClass(event);
47+
48+
el.classList.add(linkHoverClass);
49+
50+
// Add hover effect to previous siblings that are also links
51+
let sibling = el.previousElementSibling;
52+
while (sibling && sibling.classList.contains(linkClass)) {
53+
sibling.classList.add(linkHoverClass);
54+
sibling = sibling.previousElementSibling;
55+
}
56+
57+
// Add hover effect to next siblings that are also links
58+
sibling = el.nextElementSibling;
59+
while (sibling && sibling.classList.contains(linkClass)) {
60+
sibling.classList.add(linkHoverClass);
61+
sibling = sibling.nextElementSibling;
62+
}
63+
}
64+
65+
/**
66+
* Handles mouse leave events on links to remove hover effects
67+
* @param {Event} event - The mouse leave event
68+
* @param {string} linkClass - CSS class name for links
69+
* @param {string} linkHoverClass - CSS class name for hovered links
70+
*/
71+
function handleMouseLeave(event, linkClass, linkHoverClass) {
72+
const el = event.target;
73+
el.classList.remove(linkHoverClass);
74+
75+
// Remove hover effect from previous siblings that are also links
76+
let sibling = el.previousElementSibling;
77+
while (sibling && sibling.classList.contains(linkClass)) {
78+
sibling.classList.remove(linkHoverClass);
79+
sibling = sibling.previousElementSibling;
80+
}
81+
82+
// Remove hover effect from next siblings that are also links
83+
sibling = el.nextElementSibling;
84+
while (sibling && sibling.classList.contains(linkClass)) {
85+
sibling.classList.remove(linkHoverClass);
86+
sibling = sibling.nextElementSibling;
87+
}
88+
}
89+
90+
/**
91+
* Updates the CSS class on the editor wrapper based on Cmd/Ctrl key state
92+
* @param {Event} event - The keyboard event
93+
* @param {HTMLElement} editorWrapper - The editor wrapper element
94+
* @param {string} cmdCtrlClass - CSS class name for Cmd/Ctrl pressed state
95+
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
96+
*/
97+
function updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed) {
98+
if (isCmdOrCtrlPressed(event)) {
99+
editorWrapper.classList.add(cmdCtrlClass);
100+
} else {
101+
editorWrapper.classList.remove(cmdCtrlClass);
102+
}
103+
}
104+
105+
/**
106+
* Handles click events on links to open them externally
107+
* @param {Event} event - The click event
108+
* @param {string} linkClass - CSS class name for links
109+
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
110+
*/
111+
function handleClick(event, linkClass, isCmdOrCtrlPressed) {
112+
if (!isCmdOrCtrlPressed(event)) return;
113+
114+
if (event.target.classList.contains(linkClass)) {
115+
event.preventDefault();
116+
event.stopPropagation();
117+
const url = event.target.getAttribute('data-url');
118+
if (url) {
119+
window?.ipcRenderer?.openExternal(url);
120+
}
121+
}
122+
}
123+
124+
/**
125+
* Sets up link awareness for a CodeMirror editor instance.
126+
* This enables automatic URL detection, styling, and click-to-open functionality.
127+
* @param {Object} editor - The CodeMirror editor instance
128+
* @param {Object} options - Configuration options (currently unused but reserved for future use)
129+
* @returns {void}
130+
*/
131+
function setupLinkAware(editor, options = {}) {
132+
if (!editor) {
133+
return;
134+
}
135+
136+
// CSS class names and configuration
137+
const cmdCtrlClass = 'cmd-ctrl-pressed';
138+
const linkClass = 'CodeMirror-link';
139+
const linkHoverClass = 'hovered-link';
140+
const linkHint = isMacOS() ? 'Hold Cmd and click to open link' : 'Hold Ctrl and click to open link';
141+
142+
// Helper function to check if Cmd/Ctrl is pressed
143+
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
144+
145+
// Initialize LinkifyIt for URL detection
146+
const linkify = new LinkifyIt();
147+
const editorWrapper = editor.getWrapperElement();
148+
149+
// Create bound versions of event handlers with proper parameters
150+
const boundMarkUrls = () => markUrls(editor, linkify, linkClass, linkHint);
151+
const boundUpdateCmdCtrlClass = (event) => updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed);
152+
const boundHandleClick = (event) => handleClick(event, linkClass, isCmdOrCtrlPressed);
153+
const boundHandleMouseEnter = (event) => handleMouseEnter(event, linkClass, linkHoverClass, boundUpdateCmdCtrlClass);
154+
const boundHandleMouseLeave = (event) => handleMouseLeave(event, linkClass, linkHoverClass);
155+
156+
// Create debounced version of markUrls
157+
const debouncedMarkUrls = debounce(() => {
158+
requestAnimationFrame(boundMarkUrls);
159+
}, 150);
160+
161+
// Initial URL marking
162+
boundMarkUrls();
163+
164+
// Set up event listeners
165+
editor.on('changes', debouncedMarkUrls);
166+
window.addEventListener('keydown', boundUpdateCmdCtrlClass);
167+
window.addEventListener('keyup', boundUpdateCmdCtrlClass);
168+
editorWrapper.addEventListener('click', boundHandleClick);
169+
editorWrapper.addEventListener('mouseover', boundHandleMouseEnter);
170+
editorWrapper.addEventListener('mouseout', boundHandleMouseLeave);
171+
172+
// Cleanup function to remove all event listeners
173+
editor._destroyLinkAware = () => {
174+
editor.off('changes', debouncedMarkUrls);
175+
window.removeEventListener('keydown', boundUpdateCmdCtrlClass);
176+
window.removeEventListener('keyup', boundUpdateCmdCtrlClass);
177+
editorWrapper.removeEventListener('click', boundHandleClick);
178+
editorWrapper.removeEventListener('mouseover', boundHandleMouseEnter);
179+
editorWrapper.removeEventListener('mouseout', boundHandleMouseLeave);
180+
};
181+
}
182+
183+
export { setupLinkAware };

0 commit comments

Comments
 (0)