Skip to content

Commit 3751af2

Browse files
authored
Implements inline edit indicator hover (microsoft#236738)
1 parent 9ee30e5 commit 3751af2

File tree

7 files changed

+395
-100
lines changed

7 files changed

+395
-100
lines changed

src/vs/editor/contrib/inlineCompletions/browser/view/inlineCompletionsView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class InlineCompletionsView extends Disposable {
4848
constructor(
4949
private readonly _editor: ICodeEditor,
5050
private readonly _model: IObservable<InlineCompletionsModel | undefined>,
51-
@IInstantiationService private readonly _instantiationService: IInstantiationService
51+
@IInstantiationService private readonly _instantiationService: IInstantiationService,
5252
) {
5353
super();
5454

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js';
7+
import { KeybindingLabel, unthemedKeybindingLabelOptions } from '../../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
8+
import { Codicon } from '../../../../../../base/common/codicons.js';
9+
import { ResolvedKeybinding } from '../../../../../../base/common/keybindings.js';
10+
import { IObservable, autorun, constObservable, derived, derivedWithStore, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js';
11+
import { OS } from '../../../../../../base/common/platform.js';
12+
import { ThemeIcon } from '../../../../../../base/common/themables.js';
13+
import { localize } from '../../../../../../nls.js';
14+
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
15+
import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
16+
import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js';
17+
import { Command } from '../../../../../common/languages.js';
18+
import { AcceptInlineCompletion, HideInlineCompletion, JumpToNextInlineEdit } from '../../controller/commands.js';
19+
import { ChildNode, FirstFnArg, LiveElement, n } from './utils.js';
20+
21+
export class GutterIndicatorMenuContent {
22+
constructor(
23+
private readonly _selectionOverride: IObservable<'jump' | 'accept' | undefined>,
24+
private readonly _close: (focusEditor: boolean) => void,
25+
private readonly _extensionCommands: IObservable<readonly Command[] | undefined>,
26+
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
27+
@IKeybindingService private readonly _keybindingService: IKeybindingService,
28+
@ICommandService private readonly _commandService: ICommandService,
29+
) {
30+
}
31+
32+
public toDisposableLiveElement(): LiveElement {
33+
return this._createHoverContent().toDisposableLiveElement();
34+
}
35+
36+
private _createHoverContent() {
37+
const activeElement = observableValue<string | undefined>('active', undefined);
38+
const activeElementOrDefault = derived(reader => this._selectionOverride.read(reader) ?? activeElement.read(reader));
39+
40+
const createOptionArgs = (options: { id: string; title: string; icon: ThemeIcon; commandId: string; commandArgs?: unknown[] }): FirstFnArg<typeof option> => {
41+
return {
42+
title: options.title,
43+
icon: options.icon,
44+
keybinding: this._getKeybinding(options.commandArgs ? undefined : options.commandId),
45+
isActive: activeElementOrDefault.map(v => v === options.id),
46+
onHoverChange: v => activeElement.set(v ? options.id : undefined, undefined),
47+
onAction: () => {
48+
this._close(true);
49+
return this._commandService.executeCommand(options.commandId, ...(options.commandArgs ?? []));
50+
},
51+
};
52+
};
53+
54+
// TODO make this menu contributable!
55+
return hoverContent([
56+
// TODO: make header dynamic, get from extension
57+
header(localize('inlineEdit', "Inline Edit")),
58+
option(createOptionArgs({ id: 'jump', title: localize('jump', "Jump"), icon: Codicon.arrowRight, commandId: new JumpToNextInlineEdit().id })),
59+
option(createOptionArgs({ id: 'accept', title: localize('accept', "Accept"), icon: Codicon.check, commandId: new AcceptInlineCompletion().id })),
60+
option(createOptionArgs({ id: 'reject', title: localize('reject', "Reject"), icon: Codicon.close, commandId: new HideInlineCompletion().id })),
61+
separator(),
62+
this._extensionCommands?.map(c => c && c.length > 0 ? [
63+
...c.map(c => option(createOptionArgs({ id: c.id, title: c.title, icon: Codicon.symbolEvent, commandId: c.id, commandArgs: c.arguments }))),
64+
separator()
65+
] : []),
66+
option(createOptionArgs({ id: 'settings', title: localize('settings', "Settings"), icon: Codicon.gear, commandId: 'workbench.action.openSettings', commandArgs: ['inlineSuggest.edits'] })),
67+
]);
68+
}
69+
70+
private _getKeybinding(commandId: string | undefined) {
71+
if (!commandId) {
72+
return constObservable(undefined);
73+
}
74+
return observableFromEvent(this._contextKeyService.onDidChangeContext, () => this._keybindingService.lookupKeybinding(commandId, this._contextKeyService, true));
75+
}
76+
}
77+
78+
function hoverContent(content: ChildNode) {
79+
return n.div({
80+
class: 'content',
81+
style: {
82+
margin: 4,
83+
minWidth: 150,
84+
}
85+
}, content);
86+
}
87+
88+
function header(title: string) {
89+
return n.div({
90+
class: 'header',
91+
style: {
92+
color: 'var(--vscode-descriptionForeground)',
93+
fontSize: '12px',
94+
fontWeight: '600',
95+
padding: '0 10px',
96+
lineHeight: 26,
97+
}
98+
}, [title]);
99+
}
100+
101+
function option(props: {
102+
title: string;
103+
icon: ThemeIcon;
104+
keybinding: IObservable<ResolvedKeybinding | undefined>;
105+
isActive?: IObservable<boolean>;
106+
onHoverChange?: (isHovered: boolean) => void;
107+
onAction?: () => void;
108+
}) {
109+
return derivedWithStore((_reader, store) => n.div({
110+
class: ['monaco-menu-option', props.isActive?.map(v => v && 'active')],
111+
onmouseenter: () => props.onHoverChange?.(true),
112+
onmouseleave: () => props.onHoverChange?.(false),
113+
onclick: props.onAction,
114+
onkeydown: e => {
115+
if (e.key === 'Enter') {
116+
props.onAction?.();
117+
}
118+
},
119+
tabIndex: 0,
120+
}, [
121+
n.elem('span', {
122+
style: {
123+
fontSize: 16,
124+
display: 'flex',
125+
}
126+
}, [renderIcon(props.icon)]),
127+
n.elem('span', {}, [props.title]),
128+
n.div({
129+
style: { marginLeft: 'auto', opacity: '0.6' },
130+
ref: elem => {
131+
const keybindingLabel = store.add(new KeybindingLabel(elem, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions }));
132+
store.add(autorun(reader => {
133+
keybindingLabel.set(props.keybinding.read(reader));
134+
}));
135+
}
136+
})
137+
]));
138+
}
139+
140+
function separator() {
141+
return n.div({
142+
class: 'menu-separator',
143+
style: {
144+
color: 'var(--vscode-editorActionList-foreground)',
145+
padding: '2px 0',
146+
}
147+
}, n.div({
148+
style: {
149+
borderBottom: '1px solid var(--vscode-editorHoverWidget-border)',
150+
}
151+
}));
152+
}

src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/gutterIndicatorView.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@
66
import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js';
77
import { Codicon } from '../../../../../../base/common/codicons.js';
88
import { Disposable } from '../../../../../../base/common/lifecycle.js';
9-
import { IObservable, constObservable, derived, observableFromEvent } from '../../../../../../base/common/observable.js';
9+
import { IObservable, autorun, constObservable, derived, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js';
10+
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
11+
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
1012
import { buttonBackground, buttonForeground, buttonSecondaryBackground, buttonSecondaryForeground } from '../../../../../../platform/theme/common/colorRegistry.js';
1113
import { registerColor, transparent } from '../../../../../../platform/theme/common/colorUtils.js';
1214
import { ObservableCodeEditor } from '../../../../../browser/observableCodeEditor.js';
1315
import { Rect } from '../../../../../browser/rect.js';
16+
import { HoverService } from '../../../../../browser/services/hoverService/hoverService.js';
17+
import { HoverWidget } from '../../../../../browser/services/hoverService/hoverWidget.js';
1418
import { EditorOption } from '../../../../../common/config/editorOptions.js';
1519
import { LineRange } from '../../../../../common/core/lineRange.js';
1620
import { OffsetRange } from '../../../../../common/core/offsetRange.js';
1721
import { StickyScrollController } from '../../../../stickyScroll/browser/stickyScrollController.js';
1822
import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js';
23+
import { GutterIndicatorMenuContent } from './gutterIndicatorMenu.js';
1924
import { mapOutFalsy, n, rectToProps } from './utils.js';
2025
import { localize } from '../../../../../../nls.js';
2126
export const inlineEditIndicatorPrimaryForeground = registerColor(
@@ -62,12 +67,14 @@ export const inlineEditIndicatorBackground = registerColor(
6267
localize('inlineEdit.gutterIndicator.background', 'Background color for the inline edit gutter indicator.')
6368
);
6469

65-
6670
export class InlineEditsGutterIndicator extends Disposable {
6771
constructor(
6872
private readonly _editorObs: ObservableCodeEditor,
6973
private readonly _originalRange: IObservable<LineRange | undefined>,
7074
private readonly _model: IObservable<InlineCompletionsModel | undefined>,
75+
private readonly _shouldShowHover: IObservable<boolean>,
76+
@IHoverService private readonly _hoverService: HoverService,
77+
@IInstantiationService private readonly _instantiationService: IInstantiationService,
7178
) {
7279
super();
7380

@@ -77,6 +84,14 @@ export class InlineEditsGutterIndicator extends Disposable {
7784
allowEditorOverflow: false,
7885
minContentWidthInPx: constObservable(0),
7986
}));
87+
88+
this._register(autorun(reader => {
89+
if (this._shouldShowHover.read(reader)) {
90+
this._showHover();
91+
} else {
92+
this._hoverService.hideHover();
93+
}
94+
}));
8095
}
8196

8297
private readonly _originalRangeObs = mapOutFalsy(this._originalRange);
@@ -132,44 +147,88 @@ export class InlineEditsGutterIndicator extends Disposable {
132147

133148
private readonly _tabAction = derived(this, reader => {
134149
const m = this._model.read(reader);
135-
if (m && m.tabShouldJumpToInlineEdit.read(reader)) { return 'jump' as const; }
136-
if (m && m.tabShouldAcceptInlineEdit.read(reader)) { return 'accept' as const; }
150+
if (this._editorObs.isFocused.read(reader)) {
151+
if (m && m.tabShouldJumpToInlineEdit.read(reader)) { return 'jump' as const; }
152+
if (m && m.tabShouldAcceptInlineEdit.read(reader)) { return 'accept' as const; }
153+
}
137154
return 'inactive' as const;
138155
});
139156

140157
private readonly _onClickAction = derived(this, reader => {
141158
if (this._layout.map(d => d && d.docked).read(reader)) {
142159
return {
143-
label: 'Click to accept inline edit',
160+
selectionOverride: 'accept' as const,
144161
action: () => { this._model.get()?.accept(); }
145162
};
146163
} else {
147164
return {
148-
label: 'Click to jump to inline edit',
165+
selectionOverride: 'jump' as const,
149166
action: () => { this._model.get()?.jump(); }
150167
};
151168
}
152169
});
153170

171+
private readonly _iconRef = n.ref<HTMLDivElement>();
172+
private _hoverVisible: boolean = false;
173+
private readonly _isHoveredOverIcon = observableValue(this, false);
174+
private readonly _hoverSelectionOverride = derived(this, reader => this._isHoveredOverIcon.read(reader) ? this._onClickAction.read(reader).selectionOverride : undefined);
175+
176+
private _showHover(): void {
177+
if (this._hoverVisible) {
178+
return;
179+
}
180+
181+
const content = this._instantiationService.createInstance(
182+
GutterIndicatorMenuContent,
183+
this._hoverSelectionOverride,
184+
(focusEditor) => {
185+
h?.dispose();
186+
if (focusEditor) {
187+
this._editorObs.editor.focus();
188+
}
189+
},
190+
this._model.map((m, r) => m?.state.read(r)?.inlineCompletion?.inlineCompletion.source.inlineCompletions.commands),
191+
).toDisposableLiveElement();
192+
const h = this._hoverService.showHover({
193+
target: this._iconRef.element,
194+
content: content.element,
195+
}) as HoverWidget | undefined;
196+
if (h) {
197+
this._hoverVisible = true;
198+
h.onDispose(() => {
199+
content.dispose();
200+
this._hoverVisible = false;
201+
});
202+
} else {
203+
content.dispose();
204+
}
205+
}
206+
154207
private readonly _indicator = n.div({
155208
class: 'inline-edits-view-gutter-indicator',
156209
onclick: () => this._onClickAction.get().action(),
157-
title: this._onClickAction.map(a => a.label),
158210
style: {
159211
position: 'absolute',
160212
overflow: 'visible',
161213
},
162-
}, mapOutFalsy(this._layout).map(l => !l ? [] : [
214+
}, mapOutFalsy(this._layout).map(layout => !layout ? [] : [
163215
n.div({
164216
style: {
165217
position: 'absolute',
166218
background: 'var(--vscode-inlineEdit-gutterIndicator-background)',
167219
borderRadius: '4px',
168-
...rectToProps(reader => l.read(reader).rect),
220+
...rectToProps(reader => layout.read(reader).rect),
169221
}
170222
}),
171223
n.div({
172224
class: 'icon',
225+
ref: this._iconRef,
226+
onmouseenter: () => {
227+
// TODO show hover when hovering ghost text etc.
228+
this._isHoveredOverIcon.set(true, undefined);
229+
this._showHover();
230+
},
231+
onmouseleave: () => { this._isHoveredOverIcon.set(false, undefined); },
173232
style: {
174233
cursor: 'pointer',
175234
zIndex: '1000',
@@ -192,12 +251,12 @@ export class InlineEditsGutterIndicator extends Disposable {
192251
display: 'flex',
193252
justifyContent: 'center',
194253
transition: 'background-color 0.2s ease-in-out',
195-
...rectToProps(reader => l.read(reader).iconRect),
254+
...rectToProps(reader => layout.read(reader).iconRect),
196255
}
197256
}, [
198257
n.div({
199258
style: {
200-
rotate: l.map(l => {
259+
rotate: layout.map(l => {
201260
switch (l.arrowDirection) {
202261
case 'right': return '0deg';
203262
case 'bottom': return '90deg';
@@ -207,7 +266,7 @@ export class InlineEditsGutterIndicator extends Disposable {
207266
transition: 'rotate 0.2s ease-in-out',
208267
}
209268
}, [
210-
renderIcon(Codicon.arrowRight),
269+
renderIcon(Codicon.arrowRight)
211270
])
212271
]),
213272
])).keepUpdated(this._store);

src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/sideBySideDiff.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,15 @@ export class InlineEditsSideBySideDiff extends Disposable {
180180
private readonly _editorContainerTopLeft = observableValue<IObservable<Point | undefined> | undefined>(this, undefined);
181181

182182
private readonly _editorContainer = n.div({
183-
class: 'editorContainer',
183+
class: ['editorContainer', this._editorObs.getOption(EditorOption.inlineSuggest).map(v => !v.edits.experimental.useGutterIndicator && 'showHover')],
184184
style: { position: 'absolute' },
185185
}, [
186186
n.div({ class: 'preview', style: {}, ref: this.previewRef }),
187187
n.div({ class: 'toolbar', style: {}, ref: this.toolbarRef }),
188188
]).keepUpdated(this._store);
189189

190+
public readonly isHovered = this._editorContainer.getIsHovered(this._store);
191+
190192
protected readonly _toolbar = this._register(this._instantiationService.createInstance(CustomizedMenuWorkbenchToolBar, this.toolbarRef.element, MenuId.InlineEditsActions, {
191193
menuOptions: { renderShortTitle: true },
192194
toolbarOptions: {

0 commit comments

Comments
 (0)