Skip to content

Commit

Permalink
Fixes #152088 - Adds various editor gutter tooltips (#200335)
Browse files Browse the repository at this point in the history
* Allows line decorations to set a tooltip (for #152088).
Adopts the tooltip for folding and dirty diff decorator.

* Shows a tooltip to add breakpoints (for #152088)
  • Loading branch information
hediet authored Dec 18, 2023
1 parent 279872b commit a93f0f5
Show file tree
Hide file tree
Showing 11 changed files with 87 additions and 25 deletions.
19 changes: 19 additions & 0 deletions src/vs/base/common/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ export function format2(template: string, values: Record<string, unknown>): stri
return template.replace(_format2Regexp, (match, group) => (values[group] ?? match) as string);
}

/**
* Encodes the given value so that it can be used as literal value in html attributes.
*
* In other words, computes `$val`, such that `attr` in `<div attr="$val" />` has the runtime value `value`.
* This prevents XSS injection.
*/
export function htmlAttributeEncodeValue(value: string): string {
return value.replace(/[<>"'&]/g, ch => {
switch (ch) {
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&apos;';
case '&': return '&amp;';
}
return ch;
});
}

/**
* Converts HTML characters inside the string to use entities instead. Makes the string safe from
* being used e.g. in HTMLElement.innerHTML.
Expand Down
10 changes: 10 additions & 0 deletions src/vs/base/test/common/strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,13 @@ suite('Strings', () => {

ensureNoDisposablesAreLeakedInTestSuite();
});

test('htmlAttributeEncodeValue', () => {
assert.strictEqual(strings.htmlAttributeEncodeValue(''), '');
assert.strictEqual(strings.htmlAttributeEncodeValue('abc'), 'abc');
assert.strictEqual(strings.htmlAttributeEncodeValue('<script>alert("Hello")</script>'), '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;');
assert.strictEqual(strings.htmlAttributeEncodeValue('Hello & World'), 'Hello &amp; World');
assert.strictEqual(strings.htmlAttributeEncodeValue('"Hello"'), '&quot;Hello&quot;');
assert.strictEqual(strings.htmlAttributeEncodeValue('\'Hello\''), '&apos;Hello&apos;');
assert.strictEqual(strings.htmlAttributeEncodeValue('<>&\'"'), '&lt;&gt;&amp;&apos;&quot;');
});
19 changes: 10 additions & 9 deletions src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@ import { ViewContext } from 'vs/editor/common/viewModel/viewContext';
* This can end up producing multiple `LineDecorationToRender`.
*/
export class DecorationToRender {
_decorationToRenderBrand: void = undefined;
public readonly _decorationToRenderBrand: void = undefined;

public startLineNumber: number;
public endLineNumber: number;
public className: string;
public readonly zIndex: number;

constructor(startLineNumber: number, endLineNumber: number, className: string, zIndex: number | undefined) {
this.startLineNumber = +startLineNumber;
this.endLineNumber = +endLineNumber;
this.className = String(className);
constructor(
public readonly startLineNumber: number,
public readonly endLineNumber: number,
public readonly className: string,
public readonly tooltip: string | null,
zIndex: number | undefined,
) {
this.zIndex = zIndex ?? 0;
}
}
Expand All @@ -42,6 +42,7 @@ export class LineDecorationToRender {
constructor(
public readonly className: string,
public readonly zIndex: number,
public readonly tooltip: string | null,
) { }
}

Expand Down Expand Up @@ -108,7 +109,7 @@ export abstract class DedupOverlay extends DynamicViewOverlay {
}

for (let i = startLineIndex; i <= prevEndLineIndex; i++) {
output[i].add(new LineDecorationToRender(className, zIndex));
output[i].add(new LineDecorationToRender(className, zIndex, d.tooltip));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ export class LinesDecorationsOverlay extends DedupOverlay {
const linesDecorationsClassName = d.options.linesDecorationsClassName;
const zIndex = d.options.zIndex;
if (linesDecorationsClassName) {
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.endLineNumber, linesDecorationsClassName, zIndex);
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.endLineNumber, linesDecorationsClassName, d.options.linesDecorationsTooltip ?? null, zIndex);
}
const firstLineDecorationClassName = d.options.firstLineDecorationClassName;
if (firstLineDecorationClassName) {
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.startLineNumber, firstLineDecorationClassName, zIndex);
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.startLineNumber, firstLineDecorationClassName, d.options.linesDecorationsTooltip ?? null, zIndex);
}
}
return r;
Expand All @@ -103,7 +103,12 @@ export class LinesDecorationsOverlay extends DedupOverlay {
const decorations = toRender[lineIndex].getDecorations();
let lineOutput = '';
for (const decoration of decorations) {
lineOutput += '<div class="cldr ' + decoration.className + common;
let addition = '<div class="cldr ' + decoration.className;
if (decoration.tooltip !== null) {
addition += '" title="' + decoration.tooltip; // The tooltip is already escaped.
}
addition += common;
lineOutput += addition;
}
output[lineIndex] = lineOutput;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class MarginViewLineDecorationsOverlay extends DedupOverlay {
const marginClassName = d.options.marginClassName;
const zIndex = d.options.zIndex;
if (marginClassName) {
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.endLineNumber, marginClassName, zIndex);
r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.endLineNumber, marginClassName, null, zIndex);
}
}
return r;
Expand Down
4 changes: 4 additions & 0 deletions src/vs/editor/common/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ export interface IModelDecorationOptions {
* If set, the decoration will be rendered in the lines decorations with this CSS class name.
*/
linesDecorationsClassName?: string | null;
/**
* Controls the tooltip text of the line decoration.
*/
linesDecorationsTooltip?: string | null;
/**
* If set, the decoration will be rendered in the lines decorations with this CSS class name, but only for the first line in case of line wrapping.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/vs/editor/common/model/textModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2297,6 +2297,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions {
readonly glyphMargin?: model.IModelDecorationGlyphMarginOptions | null | undefined;
readonly glyphMarginClassName: string | null;
readonly linesDecorationsClassName: string | null;
readonly linesDecorationsTooltip: string | null;
readonly firstLineDecorationClassName: string | null;
readonly marginClassName: string | null;
readonly inlineClassName: string | null;
Expand Down Expand Up @@ -2328,6 +2329,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions {
this.glyphMargin = options.glyphMarginClassName ? new ModelDecorationGlyphMarginOptions(options.glyphMargin) : null;
this.glyphMarginClassName = options.glyphMarginClassName ? cleanClassName(options.glyphMarginClassName) : null;
this.linesDecorationsClassName = options.linesDecorationsClassName ? cleanClassName(options.linesDecorationsClassName) : null;
this.linesDecorationsTooltip = options.linesDecorationsTooltip ? strings.htmlAttributeEncodeValue(options.linesDecorationsTooltip) : null;
this.firstLineDecorationClassName = options.firstLineDecorationClassName ? cleanClassName(options.firstLineDecorationClassName) : null;
this.marginClassName = options.marginClassName ? cleanClassName(options.marginClassName) : null;
this.inlineClassName = options.inlineClassName ? cleanClassName(options.inlineClassName) : null;
Expand Down
25 changes: 19 additions & 6 deletions src/vs/editor/contrib/folding/browser/foldingDecorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ export const foldingManualExpandedIcon = registerIcon('folding-manual-expanded',

const foldedBackgroundMinimap = { color: themeColorFromId(foldBackground), position: MinimapPosition.Inline };

const collapsed = localize('linesCollapsed', "Click to expand the range.");
const expanded = localize('linesExpanded', "Click to collapse the range.");

export class FoldingDecorationProvider implements IDecorationProvider {

private static readonly COLLAPSED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-collapsed-visual-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
isWholeLine: true,
linesDecorationsTooltip: collapsed,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingCollapsedIcon),
});

Expand All @@ -41,6 +45,7 @@ export class FoldingDecorationProvider implements IDecorationProvider {
className: 'folded-background',
minimap: foldedBackgroundMinimap,
isWholeLine: true,
linesDecorationsTooltip: collapsed,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingCollapsedIcon)
});

Expand All @@ -49,6 +54,7 @@ export class FoldingDecorationProvider implements IDecorationProvider {
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
isWholeLine: true,
linesDecorationsTooltip: collapsed,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualCollapsedIcon)
});

Expand All @@ -59,14 +65,16 @@ export class FoldingDecorationProvider implements IDecorationProvider {
className: 'folded-background',
minimap: foldedBackgroundMinimap,
isWholeLine: true,
linesDecorationsTooltip: collapsed,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualCollapsedIcon)
});

private static readonly NO_CONTROLS_COLLAPSED_RANGE_DECORATION = ModelDecorationOptions.register({
description: 'folding-no-controls-range-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
afterContentClassName: 'inline-folded',
isWholeLine: true
isWholeLine: true,
linesDecorationsTooltip: collapsed,
});

private static readonly NO_CONTROLS_COLLAPSED_HIGHLIGHTED_RANGE_DECORATION = ModelDecorationOptions.register({
Expand All @@ -75,35 +83,40 @@ export class FoldingDecorationProvider implements IDecorationProvider {
afterContentClassName: 'inline-folded',
className: 'folded-background',
minimap: foldedBackgroundMinimap,
isWholeLine: true
isWholeLine: true,
linesDecorationsTooltip: collapsed,
});

private static readonly EXPANDED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-expanded-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingExpandedIcon)
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingExpandedIcon),
linesDecorationsTooltip: expanded,
});

private static readonly EXPANDED_AUTO_HIDE_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-expanded-auto-hide-visual-decoration',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingExpandedIcon)
firstLineDecorationClassName: ThemeIcon.asClassName(foldingExpandedIcon),
linesDecorationsTooltip: expanded,
});

private static readonly MANUALLY_EXPANDED_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-expanded-visual-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingManualExpandedIcon)
firstLineDecorationClassName: 'alwaysShowFoldIcons ' + ThemeIcon.asClassName(foldingManualExpandedIcon),
linesDecorationsTooltip: expanded,
});

private static readonly MANUALLY_EXPANDED_AUTO_HIDE_VISUAL_DECORATION = ModelDecorationOptions.register({
description: 'folding-manually-expanded-auto-hide-visual-decoration',
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
isWholeLine: true,
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualExpandedIcon)
firstLineDecorationClassName: ThemeIcon.asClassName(foldingManualExpandedIcon),
linesDecorationsTooltip: expanded,
});

private static readonly NO_CONTROLS_EXPANDED_RANGE_DECORATION = ModelDecorationOptions.register({
Expand Down
4 changes: 4 additions & 0 deletions src/vs/monaco.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,10 @@ declare namespace monaco.editor {
* If set, the decoration will be rendered in the lines decorations with this CSS class name.
*/
linesDecorationsClassName?: string | null;
/**
* Controls the tooltip text of the line decoration.
*/
linesDecorationsTooltip?: string | null;
/**
* If set, the decoration will be rendered in the lines decorations with this CSS class name, but only for the first line in case of line wrapping.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const breakpointHelperDecoration: IModelDecorationOptions = {
description: 'breakpoint-helper-decoration',
glyphMarginClassName: ThemeIcon.asClassName(icons.debugBreakpointHint),
glyphMargin: { position: GlyphMarginLane.Right },
glyphMarginHoverMessage: new MarkdownString().appendText(nls.localize('breakpointHelper', "Click to add a breakpoint.")),
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
};

Expand Down
15 changes: 9 additions & 6 deletions src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,14 +1047,15 @@ const overviewRulerDeletedForeground = registerColor('editorOverviewRuler.delete

class DirtyDiffDecorator extends Disposable {

static createDecoration(className: string, options: { gutter: boolean; overview: { active: boolean; color: string }; minimap: { active: boolean; color: string }; isWholeLine: boolean }): ModelDecorationOptions {
static createDecoration(className: string, tooltip: string | null, options: { gutter: boolean; overview: { active: boolean; color: string }; minimap: { active: boolean; color: string }; isWholeLine: boolean }): ModelDecorationOptions {
const decorationOptions: IModelDecorationOptions = {
description: 'dirty-diff-decoration',
isWholeLine: options.isWholeLine,
};

if (options.gutter) {
decorationOptions.linesDecorationsClassName = `dirty-diff-glyph ${className}`;
decorationOptions.linesDecorationsTooltip = tooltip;
}

if (options.overview.active) {
Expand Down Expand Up @@ -1096,31 +1097,33 @@ class DirtyDiffDecorator extends Disposable {
const overview = decorations === 'all' || decorations === 'overview';
const minimap = decorations === 'all' || decorations === 'minimap';

this.addedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-added', {
const diffAdded = nls.localize('diffAdded', 'Added lines');
this.addedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-added', diffAdded, {
gutter,
overview: { active: overview, color: overviewRulerAddedForeground },
minimap: { active: minimap, color: minimapGutterAddedBackground },
isWholeLine: true
});
this.addedPatternOptions = DirtyDiffDecorator.createDecoration('dirty-diff-added-pattern', {
this.addedPatternOptions = DirtyDiffDecorator.createDecoration('dirty-diff-added-pattern', diffAdded, {
gutter,
overview: { active: overview, color: overviewRulerAddedForeground },
minimap: { active: minimap, color: minimapGutterAddedBackground },
isWholeLine: true
});
this.modifiedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-modified', {
const diffModified = nls.localize('diffModified', 'Changed lines');
this.modifiedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-modified', diffModified, {
gutter,
overview: { active: overview, color: overviewRulerModifiedForeground },
minimap: { active: minimap, color: minimapGutterModifiedBackground },
isWholeLine: true
});
this.modifiedPatternOptions = DirtyDiffDecorator.createDecoration('dirty-diff-modified-pattern', {
this.modifiedPatternOptions = DirtyDiffDecorator.createDecoration('dirty-diff-modified-pattern', diffModified, {
gutter,
overview: { active: overview, color: overviewRulerModifiedForeground },
minimap: { active: minimap, color: minimapGutterModifiedBackground },
isWholeLine: true
});
this.deletedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-deleted', {
this.deletedOptions = DirtyDiffDecorator.createDecoration('dirty-diff-deleted', nls.localize('diffDeleted', 'Removed lines'), {
gutter,
overview: { active: overview, color: overviewRulerDeletedForeground },
minimap: { active: minimap, color: minimapGutterDeletedBackground },
Expand Down

0 comments on commit a93f0f5

Please sign in to comment.