Skip to content

Commit 9d3c8b2

Browse files
sid-brunoabansal21chirag-bruno
authored
feat: Allow ctrl/cmd + click to open URLs present in codemirror (#5930)
* feat: Allow ctrl/cmd + click to open URLs present in codemirror editors (#5160) * Allow ctrl/cmd + click to open URLs * fix for when user does cmd+tab, then comes back without it --------- Co-authored-by: Sid <[email protected]> * 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]> --------- Co-authored-by: Arun Bansal <[email protected]> Co-authored-by: Chirag Chandrashekhar <[email protected]>
2 parents 50442d9 + 39dfd8d commit 9d3c8b2

File tree

10 files changed

+827
-4
lines changed

10 files changed

+827
-4
lines changed

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/bruno-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"jsonc-parser": "^3.2.1",
4949
"jsonpath-plus": "^10.3.0",
5050
"know-your-http-well": "^0.5.0",
51+
"linkify-it": "^5.0.0",
5152
"lodash": "^4.17.21",
5253
"markdown-it": "^13.0.2",
5354
"markdown-it-replace-link": "^1.2.0",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +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 { setupLinkAware } from 'utils/codemirror/linkAware';
1718
import CodeMirrorSearch from 'components/CodeMirrorSearch';
1819

1920
const CodeMirror = require('codemirror');
@@ -204,6 +205,8 @@ export default class CodeEditor extends React.Component {
204205
editor,
205206
autoCompleteOptions
206207
);
208+
209+
setupLinkAware(editor);
207210
}
208211
}
209212

@@ -266,6 +269,7 @@ export default class CodeEditor extends React.Component {
266269

267270
componentWillUnmount() {
268271
if (this.editor) {
272+
this.editor?._destroyLinkAware?.();
269273
this.editor.off('change', this._onEdit);
270274
this.editor.off('scroll', this.onScroll);
271275
this.editor = null;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +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 { setupLinkAware } from 'utils/codemirror/linkAware';
89
import { IconEye, IconEyeOff } from '@tabler/icons';
910

1011
const CodeMirror = require('codemirror');
@@ -30,6 +31,8 @@ class MultiLineEditor extends Component {
3031
const variables = getAllVariables(this.props.collection, this.props.item);
3132

3233
this.editor = CodeMirror(this.editorRef.current, {
34+
lineWrapping: false,
35+
lineNumbers: false,
3336
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
3437
placeholder: this.props.placeholder,
3538
mode: 'brunovariables',
@@ -84,6 +87,8 @@ class MultiLineEditor extends Component {
8487
this.editor,
8588
autoCompleteOptions
8689
);
90+
91+
setupLinkAware(this.editor);
8792

8893
this.editor.setValue(String(this.props.value) || '');
8994
this.editor.on('change', this._onEdit);
@@ -168,6 +173,9 @@ class MultiLineEditor extends Component {
168173
if (this.brunoAutoCompleteCleanup) {
169174
this.brunoAutoCompleteCleanup();
170175
}
176+
if (this.editor?._destroyLinkAware) {
177+
this.editor._destroyLinkAware();
178+
}
171179
if (this.maskedEditor) {
172180
this.maskedEditor.destroy();
173181
this.maskedEditor = null;

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

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

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

2122
const CodeMirror = require('codemirror');
2223

@@ -138,6 +139,8 @@ export default class QueryEditor extends React.Component {
138139
editor.on('beforeChange', this._onBeforeChange);
139140
}
140141
this.addOverlay();
142+
143+
setupLinkAware(editor);
141144
}
142145

143146
componentDidUpdate(prevProps) {
@@ -170,6 +173,9 @@ export default class QueryEditor extends React.Component {
170173

171174
componentWillUnmount() {
172175
if (this.editor) {
176+
if (this.editor?._destroyLinkAware) {
177+
this.editor._destroyLinkAware();
178+
}
173179
this.editor.off('change', this._onEdit);
174180
this.editor.off('keyup', this._onKeyUp);
175181
this.editor.off('hasCompletion', this._onHasCompletion);

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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 { setupLinkAware } from 'utils/codemirror/linkAware';
910

1011
const CodeMirror = require('codemirror');
1112

@@ -40,7 +41,7 @@ class SingleLineEditor extends Component {
4041
this.props.onSave();
4142
}
4243
};
43-
const noopHandler = () => {};
44+
const noopHandler = () => { };
4445

4546
this.editor = CodeMirror(this.editorRef.current, {
4647
placeholder: this.props.placeholder ?? '',
@@ -94,7 +95,7 @@ class SingleLineEditor extends Component {
9495
this.editor,
9596
autoCompleteOptions
9697
);
97-
98+
9899
this.editor.setValue(String(this.props.value ?? ''));
99100
this.editor.on('change', this._onEdit);
100101
this.editor.on('paste', this._onPaste);
@@ -106,6 +107,7 @@ class SingleLineEditor extends Component {
106107
if (this.props.showNewlineArrow) {
107108
this._updateNewlineMarkers();
108109
}
110+
setupLinkAware(this.editor);
109111
}
110112

111113
/** Enable or disable masking the rendered content of the editor */
@@ -189,6 +191,9 @@ class SingleLineEditor extends Component {
189191

190192
componentWillUnmount() {
191193
if (this.editor) {
194+
if (this.editor?._destroyLinkAware) {
195+
this.editor._destroyLinkAware();
196+
}
192197
this.editor.off('change', this._onEdit);
193198
this.editor.off('paste', this._onPaste);
194199
this._clearNewlineMarkers();

packages/bruno-app/src/globalStyles.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,14 @@ const GlobalStyle = createGlobalStyle`
469469
background: #08f !important;
470470
color: #fff !important;
471471
}
472+
473+
.hovered-link.CodeMirror-link {
474+
text-decoration: underline !important;
475+
}
476+
.cmd-ctrl-pressed .hovered-link.CodeMirror-link[data-url] {
477+
cursor: pointer;
478+
color: ${(props) => props.theme.textLink} !important;
479+
}
472480
`;
473481

474482
export default GlobalStyle;
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)