Commit 7ed9d3a8 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '21762-link-to-a-line' into 'master'

Linking to a single line number in Web IDE

See merge request gitlab-org/gitlab!56159
parents d897b90f d4b91df4
......@@ -43,7 +43,7 @@ export default class EditBlob {
blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
this.editor.use(new FileTemplateExtension());
this.editor.use(new FileTemplateExtension({ instance: this.editor }));
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
......
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '../constants';
import { Range } from 'monaco-editor';
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
const hashRegexp = new RegExp('#?L', 'g');
const createAnchor = (href) => {
const fragment = new DocumentFragment();
const el = document.createElement('a');
el.classList.add('link-anchor');
el.href = href;
fragment.appendChild(el);
el.addEventListener('contextmenu', (e) => {
e.stopPropagation();
});
return fragment;
};
export class EditorLiteExtension {
constructor({ instance, ...options } = {}) {
if (instance) {
Object.assign(instance, options);
EditorLiteExtension.highlightLines(instance);
if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
EditorLiteExtension.setupLineLinking(instance);
}
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
}
static highlightLines(instance) {
const { hash } = window.location;
if (!hash) {
return;
}
const [start, end] = hash.replace(hashRegexp, '').split('-');
let startLine = start ? parseInt(start, 10) : null;
let endLine = end ? parseInt(end, 10) : startLine;
if (endLine < startLine) {
[startLine, endLine] = [endLine, startLine];
}
if (startLine) {
window.requestAnimationFrame(() => {
instance.revealLineInCenter(startLine);
Object.assign(instance, {
lineDecorations: instance.deltaDecorations(
[],
[
{
range: new Range(startLine, 1, endLine, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
],
),
});
});
}
}
static onMouseMoveHandler(e) {
const target = e.target.element;
if (target.classList.contains('line-numbers')) {
const lineNum = e.target.position.lineNumber;
const hrefAttr = `#L${lineNum}`;
let el = target.querySelector('a');
if (!el) {
el = createAnchor(hrefAttr);
target.appendChild(el);
}
}
}
static setupLineLinking(instance) {
instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler);
instance.onMouseDown((e) => {
const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
if (!isCorrectAnchor) {
return;
}
if (instance.lineDecorations) {
instance.deltaDecorations(instance.lineDecorations, []);
}
});
}
}
......@@ -7,6 +7,7 @@ export const defaultEditorOptions = {
enabled: false,
},
wordWrap: 'on',
glyphMargin: true,
};
export const defaultDiffOptions = {
......@@ -21,6 +22,7 @@ export const defaultDiffEditorOptions = {
readOnly: false,
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
glyphMargin: true,
};
export const defaultModelOptions = {
......
......@@ -27,7 +27,7 @@ export default {
registerCiSchema() {
const editorInstance = this.$refs.editor.getEditor();
editorInstance.use(new CiSchemaExtension());
editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
editorInstance.registerCiSchema({
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
......
......@@ -24,3 +24,53 @@
[id^='editor-lite-'] {
height: 500px;
}
.monaco-editor.gl-editor-lite {
.margin-view-overlays {
.line-numbers {
@include gl-display-flex;
@include gl-justify-content-end;
@include gl-relative;
&::before {
@include gl-visibility-hidden;
@include gl-align-self-center;
@include gl-bg-gray-400;
@include gl-mr-2;
@include gl-w-4;
@include gl-h-4;
mask-image: asset_url('icons-stacked.svg#link');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
content: '';
}
&:hover {
@include gl-text-decoration-underline;
cursor: pointer !important;
}
&:hover::before {
@include gl-visibility-visible;
}
&:focus::before {
@include gl-visibility-visible;
outline: auto;
}
.link-anchor {
@include gl-display-block;
@include gl-absolute;
@include gl-w-full;
@include gl-h-full;
}
}
}
}
.active-line-text {
@include gl-bg-orange-600;
@include gl-opacity-3;
}
---
title: Linking to a single line number in Web IDE
merge_request: 56159
author:
type: added
......@@ -56,6 +56,24 @@ NOTE:
The **Set up CI/CD** button does not appear on an empty repository. For the button
to display, add a file to your repository.
## Highlight lines
Web Editor enables you to highlight a single line by adding specially formatted
hash information to the URL's file path segment. For example, the file path segment
`MY_FILE.js#L3` instructs the Web Editor to highlight line 3.
The Web Editor also enables you to highlight multiple lines using a similar pattern. In
this case, the file path segment `MY_FILE.js#L3-10` instructs the Web Editor to
highlight lines 3 to 10 of the file.
You don't need to construct these lines manually. Instead, you can:
1. Hover over the number of a line you want to be highlighted when sharing.
1. Right-click the number with your mouse.
1. Click **Copy Link Address** in the context menu.
![Link to a line](img/web_editor_line_link_v13_10.png)
## Upload a file
The ability to create a file is great when the content is text. However, this
......
......@@ -66,9 +66,6 @@ Monaco uses the [Monarch](https://microsoft.github.io/monaco-editor/monarch.html
If you are missing Syntax Highlighting support for any language, we prepared a short guide on how to [add support for a missing language Syntax Highlighting.](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/ide/lib/languages/README.md)
NOTE:
Single file editing is based on the [Ace Editor](https://ace.c9.io).
### Themes
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2389) in GitLab 13.0.
......@@ -82,6 +79,12 @@ You can pick a theme from your [profile preferences](../../profile/preferences.m
|-------------------------------------------------------------|-----------------------------------------|
| ![Solarized Dark Theme](img/solarized_dark_theme_v13_1.png) | ![Dark Theme](img/dark_theme_v13_0.png) |
## Highlight lines
WebIDE is built with the [Web Editor](../repository/web_editor.md). This enables WebIDE to share the
same core features for highlighting and linking to particular lines in the edited files
[described for the Web Editor](../repository/web_editor.md#highlight-lines).
## Schema based validation
> - Support for validation based on predefined schemas [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218472) in GitLab 13.2.
......
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '~/editor/constants';
import { Range } from 'monaco-editor';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import {
ERROR_INSTANCE_REQUIRED_FOR_EXTENSION,
EDITOR_TYPE_CODE,
EDITOR_TYPE_DIFF,
} from '~/editor/constants';
import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
describe('The basis for an Editor Lite extension', () => {
const defaultLine = 3;
let ext;
let event;
const defaultOptions = { foo: 'bar' };
const findLine = (num) => {
return document.querySelector(`.line-numbers:nth-child(${num})`);
};
const generateLines = () => {
let res = '';
for (let line = 1, lines = 5; line <= lines; line += 1) {
res += `<div class="line-numbers">${line}</div>`;
}
return res;
};
const generateEventMock = ({ line = defaultLine, el = null } = {}) => {
return {
target: {
element: el || findLine(line),
position: {
lineNumber: line,
},
},
};
};
beforeEach(() => {
setFixtures(generateLines());
event = generateEventMock();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it.each`
description | instance | options
${'accepts configuration options and instance'} | ${{}} | ${defaultOptions}
${'leaves instance intact if no options are passed'} | ${{}} | ${undefined}
${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined}
${'throws if only options are passed'} | ${undefined} | ${defaultOptions}
`('$description', ({ instance, options } = {}) => {
const originalInstance = { ...instance };
it.each`
description | instance | options
${'accepts configuration options and instance'} | ${{}} | ${defaultOptions}
${'leaves instance intact if no options are passed'} | ${{}} | ${undefined}
${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined}
${'throws if only options are passed'} | ${undefined} | ${defaultOptions}
`('$description', ({ instance, options } = {}) => {
const originalInstance = { ...instance };
if (instance) {
if (options) {
Object.entries(options).forEach((prop) => {
expect(instance[prop]).toBeUndefined();
});
// Both instance and options are passed
ext = new EditorLiteExtension({ instance, ...options });
Object.entries(options).forEach(([prop, value]) => {
expect(ext[prop]).toBeUndefined();
expect(instance[prop]).toBe(value);
});
if (instance) {
if (options) {
Object.entries(options).forEach((prop) => {
expect(instance[prop]).toBeUndefined();
});
// Both instance and options are passed
ext = new EditorLiteExtension({ instance, ...options });
Object.entries(options).forEach(([prop, value]) => {
expect(ext[prop]).toBeUndefined();
expect(instance[prop]).toBe(value);
});
} else {
ext = new EditorLiteExtension({ instance });
expect(instance).toEqual(originalInstance);
}
} else if (options) {
// Options are passed without instance
expect(() => {
ext = new EditorLiteExtension({ ...options });
}).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
} else {
ext = new EditorLiteExtension({ instance });
expect(instance).toEqual(originalInstance);
// Neither options nor instance are passed
expect(() => {
ext = new EditorLiteExtension();
}).not.toThrow();
}
} else if (options) {
// Options are passed without instance
expect(() => {
ext = new EditorLiteExtension({ ...options });
}).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
} else {
// Neither options nor instance are passed
expect(() => {
ext = new EditorLiteExtension();
}).not.toThrow();
}
});
it('initializes the line highlighting', () => {
const spy = jest.spyOn(EditorLiteExtension, 'highlightLines');
ext = new EditorLiteExtension({ instance: {} });
expect(spy).toHaveBeenCalled();
});
it('sets up the line linking for code instance', () => {
const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking');
const instance = {
getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE),
onMouseMove: jest.fn(),
onMouseDown: jest.fn(),
};
ext = new EditorLiteExtension({ instance });
expect(spy).toHaveBeenCalledWith(instance);
});
it('does not set up the line linking for diff instance', () => {
const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking');
const instance = {
getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF),
};
ext = new EditorLiteExtension({ instance });
expect(spy).not.toHaveBeenCalled();
});
});
describe('highlightLines', () => {
const revealSpy = jest.fn();
const decorationsSpy = jest.fn();
const instance = {
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
};
const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' };
useFakeRequestAnimationFrame();
beforeEach(() => {
delete window.location;
window.location = new URL(`https://localhost`);
});
afterEach(() => {
window.location.hash = '';
});
it.each`
desc | hash | shouldReveal | expectedRange
${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]}
${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]}
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]}
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]}
${'does not highlight if there is no hash'} | ${''} | ${false} | ${null}
${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null}
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null}
`('$desc', ({ hash, shouldReveal, expectedRange } = {}) => {
window.location.hash = hash;
EditorLiteExtension.highlightLines(instance);
if (!shouldReveal) {
expect(revealSpy).not.toHaveBeenCalled();
expect(decorationsSpy).not.toHaveBeenCalled();
} else {
expect(revealSpy).toHaveBeenCalledWith(expectedRange[0]);
expect(decorationsSpy).toHaveBeenCalledWith(
[],
[
{
range: new Range(...expectedRange),
options: defaultDecorationOptions,
},
],
);
}
});
it('stores the line decorations on the instance', () => {
decorationsSpy.mockReturnValue('foo');
window.location.hash = '#L10';
expect(instance.lineDecorations).toBeUndefined();
EditorLiteExtension.highlightLines(instance);
expect(instance.lineDecorations).toBe('foo');
});
});
describe('setupLineLinking', () => {
const instance = {
onMouseMove: jest.fn(),
onMouseDown: jest.fn(),
deltaDecorations: jest.fn(),
lineDecorations: 'foo',
};
beforeEach(() => {
EditorLiteExtension.onMouseMoveHandler(event); // generate the anchor
});
it.each`
desc | spy
${'onMouseMove'} | ${instance.onMouseMove}
${'onMouseDown'} | ${instance.onMouseDown}
`('sets up the $desc listener', ({ spy } = {}) => {
EditorLiteExtension.setupLineLinking(instance);
expect(spy).toHaveBeenCalled();
});
it.each`
desc | eventTrigger | shouldRemove
${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false}
${'removes existing line decorations when clicking a line number'} | ${'.link-anchor'} | ${true}
`('$desc', ({ eventTrigger, shouldRemove } = {}) => {
event = generateEventMock({ el: eventTrigger ? document.querySelector(eventTrigger) : null });
instance.onMouseDown.mockImplementation((fn) => {
fn(event);
});
EditorLiteExtension.setupLineLinking(instance);
if (shouldRemove) {
expect(instance.deltaDecorations).toHaveBeenCalledWith(instance.lineDecorations, []);
} else {
expect(instance.deltaDecorations).not.toHaveBeenCalled();
}
});
});
describe('onMouseMoveHandler', () => {
it('stops propagation for contextmenu event on the generated anchor', () => {
EditorLiteExtension.onMouseMoveHandler(event);
const anchor = findLine(defaultLine).querySelector('a');
const contextMenuEvent = new Event('contextmenu');
jest.spyOn(contextMenuEvent, 'stopPropagation');
anchor.dispatchEvent(contextMenuEvent);
expect(contextMenuEvent.stopPropagation).toHaveBeenCalled();
});
it('creates an anchor if it does not exist yet', () => {
expect(findLine(defaultLine).querySelector('a')).toBe(null);
EditorLiteExtension.onMouseMoveHandler(event);
expect(findLine(defaultLine).querySelector('a')).not.toBe(null);
});
it('does not create a new anchor if it exists', () => {
EditorLiteExtension.onMouseMoveHandler(event);
expect(findLine(defaultLine).querySelector('a')).not.toBe(null);
EditorLiteExtension.createAnchor = jest.fn();
EditorLiteExtension.onMouseMoveHandler(event);
expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled();
expect(findLine(defaultLine).querySelectorAll('a')).toHaveLength(1);
});
it('does not create a link if the event is triggered on a wrong node', () => {
setFixtures('<div class="wrong-class">3</div>');
EditorLiteExtension.createAnchor = jest.fn();
const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') });
EditorLiteExtension.onMouseMoveHandler(wrongEvent);
expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled();
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment