diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index 7461b28f2b44f..4bcc56f0d0dfd 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -7,6 +7,7 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derived, globalTransaction, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -244,21 +245,43 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< let isDeleted = false; let isAdded = false; let flag = ''; + let status = 'Modified'; if (data.viewModel.modifiedUri && data.viewModel.originalUri && data.viewModel.modifiedUri.path !== data.viewModel.originalUri.path) { flag = 'R'; isRenamed = true; + status = 'Renamed'; } else if (!data.viewModel.modifiedUri) { flag = 'D'; isDeleted = true; + status = 'Deleted'; } else if (!data.viewModel.originalUri) { flag = 'A'; isAdded = true; + status = 'Added'; } this._elements.status.classList.toggle('renamed', isRenamed); this._elements.status.classList.toggle('deleted', isDeleted); this._elements.status.classList.toggle('added', isAdded); this._elements.status.innerText = flag; + // Set aria-label for the individual file container + const originalFileName = data.viewModel.originalUri?.path.split('/').pop(); + const modifiedFileName = data.viewModel.modifiedUri?.path.split('/').pop(); + + // Set aria-labels for the headers + let fileAriaLabel = ''; + if (isRenamed) { + fileAriaLabel = localize('renamedFileHeader', 'Renamed file from {0} to {1}', originalFileName, modifiedFileName); + } else if (isAdded) { + fileAriaLabel = localize('modifiedFileHeader', '{0} file {1}', status, modifiedFileName); + } else { + fileAriaLabel = localize('modifiedFileHeader', '{0} file {1}', status, originalFileName); + } + + this._elements.root.setAttribute('aria-label', fileAriaLabel); + this._elements.root.setAttribute('role', 'region'); + this._elements.root.setAttribute('tabindex', '0'); + this._resourceLabel2?.setUri(isRenamed ? data.viewModel.originalUri : undefined, { strikethrough: true }); this._dataStore.clear(); @@ -323,4 +346,8 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._elements.root.style.top = `-100000px`; this._elements.root.style.visibility = 'hidden'; // Some editor parts are still visible } + + public getContainerElement(): HTMLElement { + return this._elements.root; + } } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 9c49808167e57..bfb30bd5822fc 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -85,6 +85,14 @@ export class MultiDiffEditorWidget extends Disposable { public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined { return this._widgetImpl.get().findDocumentDiffItem(resource); } + + public goToNextFile(): void { + this._widgetImpl.get().goToNextFile(); + } + + public goToPreviousFile(): void { + this._widgetImpl.get().goToPreviousFile(); + } } export interface RevealOptions { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 288d0bd867800..5327756536886 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import { Dimension, getWindow, h, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { SmoothScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { compareBy, numberComparator } from '../../../../base/common/arrays.js'; @@ -88,7 +87,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { horizontal: ScrollbarVisibility.Auto, useShadows: false, }, this._scrollable)); - this._elements = h('div.monaco-component.multiDiffEditor', {}, [ + this._elements = h('div.monaco-component.multiDiffEditor', { + 'aria-label': localize('multiDiffEditor.ariaLabel', 'Multi File Diff Editor'), + 'role': 'region' + }, [ h('div', {}, [this._scrollableElement.getDomNode()]), h('div.placeholder@placeholder', {}, [h('div')]), ]); @@ -346,6 +348,75 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._scrollableElements.content.style.transform = `translateY(${-(scrollTop + contentScrollOffsetToScrollOffset)}px)`; } + + public goToNextFile(): void { + const viewModel = this._viewModel.get(); + if (!viewModel) { + return; + } + + const items = viewModel.items.get(); + const activeItem = viewModel.activeDiffItem.get(); + const currentIndex = activeItem ? items.indexOf(activeItem) : -1; + const nextIndex = Math.min(currentIndex + 1, items.length - 1); + + if (nextIndex !== currentIndex && nextIndex >= 0) { + const nextItem = items[nextIndex]; + viewModel.activeDiffItem.setCache(nextItem, undefined); + this.scrollToItem(nextIndex); + this.focusFileContainer(nextIndex); + } + } + + public goToPreviousFile(): void { + const viewModel = this._viewModel.get(); + if (!viewModel) { + return; + } + + const items = viewModel.items.get(); + const activeItem = viewModel.activeDiffItem.get(); + const currentIndex = activeItem ? items.indexOf(activeItem) : -1; + const prevIndex = Math.max(currentIndex - 1, 0); + + if (prevIndex !== currentIndex && prevIndex >= 0) { + const prevItem = items[prevIndex]; + viewModel.activeDiffItem.setCache(prevItem, undefined); + this.scrollToItem(prevIndex); + this.focusFileContainer(prevIndex); + } + } + + private scrollToItem(index: number): void { + const viewItems = this._viewItems.get(); + if (index < 0 || index >= viewItems.length) { + return; + } + + let scrollTop = 0; + for (let i = 0; i < index; i++) { + scrollTop += viewItems[i].contentHeight.get() + this._spaceBetweenPx; + } + + this._scrollableElement.setScrollPosition({ scrollTop }); + } + + private focusFileContainer(index: number): void { + const viewItems = this._viewItems.get(); + if (index < 0 || index >= viewItems.length) { + return; + } + + const viewItem = viewItems[index]; + const template = viewItem.template.get(); + if (template) { + // Focus the main file container div to trigger screen reader announcement + const containerElement = template.getContainerElement(); + if (containerElement) { + containerElement.focus(); + } + } + } } function highlightRange(targetEditor: ICodeEditor, range: IRange) { diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts index 86aeac17ca883..eddbc6d23fc77 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts @@ -131,3 +131,47 @@ export class ExpandAllAction extends Action2 { } } } + +export class GoToNextFileAction extends Action2 { + constructor() { + super({ + id: 'multiDiffEditor.goToNextFile', + title: localize2('goToNextFile', 'Go to Next File'), + icon: Codicon.goToFile, + precondition: ActiveEditorContext.isEqualTo(MultiDiffEditor.ID), + f1: true + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const editorService = accessor.get(IEditorService); + const activeEditorPane = editorService.activeEditorPane; + if (!(activeEditorPane instanceof MultiDiffEditor)) { + return; + } + + activeEditorPane.goToNextFile(); + } +} + +export class GoToPreviousFileAction extends Action2 { + constructor() { + super({ + id: 'multiDiffEditor.goToPreviousFile', + title: localize2('goToPreviousFile', 'Go to Previous File'), + icon: Codicon.goToFile, + precondition: ActiveEditorContext.isEqualTo(MultiDiffEditor.ID), + f1: true + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const editorService = accessor.get(IEditorService); + const activeEditorPane = editorService.activeEditorPane; + if (!(activeEditorPane instanceof MultiDiffEditor)) { + return; + } + + activeEditorPane.goToPreviousFile(); + } +} diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.ts index 815723039183e..368149cdc09f6 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.ts @@ -13,7 +13,7 @@ import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; import { MultiDiffEditor } from './multiDiffEditor.js'; import { MultiDiffEditorInput, MultiDiffEditorResolverContribution, MultiDiffEditorSerializer } from './multiDiffEditorInput.js'; -import { CollapseAllAction, ExpandAllAction, GoToFileAction } from './actions.js'; +import { CollapseAllAction, ExpandAllAction, GoToFileAction, GoToPreviousFileAction, GoToNextFileAction } from './actions.js'; import { IMultiDiffSourceResolverService, MultiDiffSourceResolverService } from './multiDiffSourceResolverService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { OpenScmGroupAction, ScmMultiDiffSourceResolverContribution } from './scmMultiDiffSourceResolver.js'; @@ -21,6 +21,8 @@ import { OpenScmGroupAction, ScmMultiDiffSourceResolverContribution } from './sc registerAction2(GoToFileAction); registerAction2(CollapseAllAction); registerAction2(ExpandAllAction); +registerAction2(GoToNextFileAction); +registerAction2(GoToPreviousFileAction); Registry.as(Extensions.Configuration) .registerConfiguration({ diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index 3a45ea35556dd..c1eb4069471a5 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -153,6 +153,14 @@ export class MultiDiffEditor extends AbstractEditorWithViewState): Promise { return this.editorProgressService.showWhile(promise); } + + public goToNextFile(): void { + this._multiDiffEditorWidget?.goToNextFile(); + } + + public goToPreviousFile(): void { + this._multiDiffEditorWidget?.goToPreviousFile(); + } }