Skip to content

Commit fc7c0c4

Browse files
authored
better keyboard handling of attachments (#249917)
* keep focus in input * better keyboard attachments * lots of better handling
1 parent af9ba79 commit fc7c0c4

File tree

3 files changed

+65
-17
lines changed

3 files changed

+65
-17
lines changed

src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as dom from '../../../../../base/browser/dom.js';
7+
import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js';
78
import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js';
89
import { Codicon } from '../../../../../base/common/codicons.js';
10+
import { KeyCode } from '../../../../../base/common/keyCodes.js';
911
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
1012
import { Schemas } from '../../../../../base/common/network.js';
1113
import { basename, dirname } from '../../../../../base/common/resources.js';
@@ -90,6 +92,13 @@ export class ImplicitContextAttachmentWidget extends Disposable {
9092
this.convertToRegularAttachment();
9193
}));
9294

95+
this.renderDisposables.add(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, e => {
96+
const event = new StandardKeyboardEvent(e);
97+
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
98+
this.convertToRegularAttachment();
99+
}
100+
}));
101+
93102
// Context menu
94103
const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.domNode));
95104

src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,38 +109,35 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
109109
this._onDidDelete.fire(e.browserEvent);
110110
}
111111
}));
112-
if (this.options.shouldFocusClearButton) {
113-
clearButton.focus();
114-
}
115112
}
116113

117114
protected addResourceOpenHandlers(resource: URI, range: IRange | undefined): void {
118115
this.element.style.cursor = 'pointer';
119-
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, (e: MouseEvent) => {
116+
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async (e: MouseEvent) => {
120117
dom.EventHelper.stop(e, true);
121118
if (this.attachment.kind === 'directory') {
122-
this.openResource(resource, true);
119+
await this.openResource(resource, true);
123120
} else {
124-
this.openResource(resource, false, range);
121+
await this.openResource(resource, false, range);
125122
}
126123
}));
127124

128-
this._register(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
125+
this._register(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, async (e: KeyboardEvent) => {
129126
const event = new StandardKeyboardEvent(e);
130127
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
131128
dom.EventHelper.stop(e, true);
132129
if (this.attachment.kind === 'directory') {
133-
this.openResource(resource, true);
130+
await this.openResource(resource, true);
134131
} else {
135-
this.openResource(resource, false, range);
132+
await this.openResource(resource, false, range);
136133
}
137134
}
138135
}));
139136
}
140137

141-
protected openResource(resource: URI, isDirectory: true): void;
142-
protected openResource(resource: URI, isDirectory: false, range: IRange | undefined): void;
143-
protected openResource(resource: URI, isDirectory?: boolean, range?: IRange): void {
138+
protected async openResource(resource: URI, isDirectory: true): Promise<void>;
139+
protected async openResource(resource: URI, isDirectory: false, range: IRange | undefined): Promise<void>;
140+
protected async openResource(resource: URI, isDirectory?: boolean, range?: IRange): Promise<void> {
144141
if (isDirectory) {
145142
// Reveal Directory in explorer
146143
this.commandService.executeCommand(revealInSideBarCommand.id, resource);
@@ -151,9 +148,10 @@ abstract class AbstractChatAttachmentWidget extends Disposable {
151148
const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined;
152149
const options: OpenInternalOptions = {
153150
fromUserGesture: true,
154-
editorOptions: openTextEditorOptions,
151+
editorOptions: { ...openTextEditorOptions, preserveFocus: true },
155152
};
156-
this.openerService.open(resource, options);
153+
await this.openerService.open(resource, options);
154+
this.element.focus();
157155
}
158156
}
159157

@@ -257,9 +255,9 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
257255

258256
const ref = attachment.references?.[0]?.reference;
259257
resource = ref && URI.isUri(ref) ? ref : undefined;
260-
const clickHandler = () => {
258+
const clickHandler = async () => {
261259
if (resource) {
262-
this.openResource(resource, false, undefined);
260+
await this.openResource(resource, false, undefined);
263261
}
264262
};
265263
type AttachImageEvent = {
@@ -588,7 +586,7 @@ export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachme
588586
ariaLabel = this.getAriaLabel(attachment);
589587
}
590588

591-
const clickHandler = () => this.openResource(resource, false, undefined);
589+
const clickHandler = async () => await this.openResource(resource, false, undefined);
592590
const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'unknown';
593591
const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array();
594592
this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState));

src/vs/workbench/contrib/chat/browser/chatInputPart.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as dom from '../../../../base/browser/dom.js';
77
import { addDisposableListener } from '../../../../base/browser/dom.js';
88
import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js';
99
import { IHistoryNavigationWidget } from '../../../../base/browser/history.js';
10+
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
1011
import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
1112
import * as aria from '../../../../base/browser/ui/aria/aria.js';
1213
import { Button } from '../../../../base/browser/ui/button/button.js';
@@ -18,6 +19,7 @@ import { Codicon } from '../../../../base/common/codicons.js';
1819
import { logExecutionTime } from '../../../../base/common/decorators/logTime.js';
1920
import { Emitter, Event } from '../../../../base/common/event.js';
2021
import { HistoryNavigator2 } from '../../../../base/common/history.js';
22+
import { KeyCode } from '../../../../base/common/keyCodes.js';
2123
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
2224
import { ResourceSet } from '../../../../base/common/map.js';
2325
import { observableFromEvent } from '../../../../base/common/observable.js';
@@ -1227,6 +1229,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
12271229
dom.clearNode(container);
12281230
const hoverDelegate = store.add(createInstantHoverDelegate());
12291231

1232+
store.add(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.KEY_DOWN, (e: StandardKeyboardEvent) => {
1233+
this.handleAttachmentNavigation(e);
1234+
}));
1235+
12301236
const attachments = [...this.attachmentModel.attachments.entries()];
12311237
const hasAttachments = Boolean(attachments.length) || Boolean(this.implicitContext?.value) || !this.promptInstructionsAttachmentsPart.empty;
12321238
dom.setVisibility(Boolean(hasAttachments || (this.addFilesToolbar && !this.addFilesToolbar.isEmpty())), this.attachmentsContainer);
@@ -1303,6 +1309,41 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
13031309
this.renderAttachedContext();
13041310
}
13051311

1312+
private handleAttachmentNavigation(e: StandardKeyboardEvent): void {
1313+
if (!e.equals(KeyCode.LeftArrow) && !e.equals(KeyCode.RightArrow)) {
1314+
return;
1315+
}
1316+
1317+
const toolbar = this.addFilesToolbar?.getElement().querySelector('.action-label');
1318+
if (!toolbar) {
1319+
return;
1320+
}
1321+
1322+
const attachments = Array.from(this.attachedContextContainer.querySelectorAll('.chat-attached-context-attachment'));
1323+
if (!attachments.length) {
1324+
return;
1325+
}
1326+
1327+
attachments.unshift(toolbar);
1328+
1329+
const activeElement = dom.getWindow(this.attachmentsContainer).document.activeElement;
1330+
const currentIndex = attachments.findIndex(attachment => attachment === activeElement);
1331+
let newIndex = currentIndex;
1332+
1333+
if (e.equals(KeyCode.LeftArrow)) {
1334+
newIndex = currentIndex > 0 ? currentIndex - 1 : attachments.length - 1;
1335+
} else if (e.equals(KeyCode.RightArrow)) {
1336+
newIndex = currentIndex < attachments.length - 1 ? currentIndex + 1 : 0;
1337+
}
1338+
1339+
if (newIndex !== -1) {
1340+
const nextElement = attachments[newIndex] as HTMLElement;
1341+
nextElement.focus();
1342+
e.preventDefault();
1343+
e.stopPropagation();
1344+
}
1345+
}
1346+
13061347
async renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null) {
13071348
dom.setVisibility(Boolean(chatEditingSession), this.chatEditingSessionWidgetContainer);
13081349

0 commit comments

Comments
 (0)