// ***************************************************************************** // Copyright (C) 2019 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; import { AbstractViewContribution, FrontendApplicationContribution, LabelProvider, StatusBar, StatusBarAlignment, StatusBarEntry, KeybindingRegistry, ViewContainerTitleOptions, codicon, StylingParticipant, ColorTheme, CssStyleCollector } from '@theia/core/lib/browser'; import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarAction } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { CommandRegistry, Command, Disposable, DisposableCollection, CommandService, MenuModelRegistry } from '@theia/core/lib/common'; import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; import { ScmService } from './scm-service'; import { ScmContextKeyService } from './scm-context-key-service'; import { ScmWidget } from '../browser/scm-widget'; import URI from '@theia/core/lib/common/uri'; import { ScmQuickOpenService } from './scm-quick-open-service'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; import { Color } from '@theia/core/lib/common/color'; import { ScmColors } from './scm-colors'; import { ScmCommand } from './scm-provider'; import { ScmDecorationsService } from '../browser/decorations/scm-decorations-service'; import { nls } from '@theia/core/lib/common/nls'; import { isHighContrast } from '@theia/core/lib/common/theme'; import { EditorMainMenu, EditorWidget } from '@theia/editor/lib/browser'; import { DirtyDiffNavigator } from './dirty-diff/dirty-diff-navigator'; import { MonacoDiffEditor } from '@theia/monaco/lib/browser/monaco-diff-editor'; export const SCM_WIDGET_FACTORY_ID = ScmWidget.ID; export const SCM_VIEW_CONTAINER_ID = 'scm-view-container'; export const SCM_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = { label: nls.localizeByDefault('Source Control'), iconClass: codicon('source-control'), closeable: true }; export namespace ScmMenus { export const CHANGES_GROUP = [...EditorMainMenu.GO, '6_changes_group']; } export namespace SCM_COMMANDS { export const CHANGE_REPOSITORY = { id: 'scm.change.repository', category: nls.localizeByDefault('Source Control'), originalCategory: 'Source Control', label: nls.localize('theia/scm/changeRepository', 'Change Repository...'), originalLabel: 'Change Repository...' }; export const ACCEPT_INPUT = { id: 'scm.acceptInput' }; export const TREE_VIEW_MODE = { id: 'scm.viewmode.tree', tooltip: nls.localizeByDefault('View as Tree'), iconClass: codicon('list-tree'), originalLabel: 'View as Tree', label: nls.localizeByDefault('View as Tree') }; export const LIST_VIEW_MODE = { id: 'scm.viewmode.list', tooltip: nls.localizeByDefault('View as List'), iconClass: codicon('list-flat'), originalLabel: 'View as List', label: nls.localizeByDefault('View as List') }; export const COLLAPSE_ALL = { id: 'scm.collapseAll', category: nls.localizeByDefault('Source Control'), originalCategory: 'Source Control', tooltip: nls.localizeByDefault('Collapse All'), iconClass: codicon('collapse-all'), label: nls.localizeByDefault('Collapse All'), originalLabel: 'Collapse All' }; export const GOTO_NEXT_CHANGE = Command.toDefaultLocalizedCommand({ id: 'workbench.action.editor.nextChange', category: 'Source Control', label: 'Go to Next Change', iconClass: codicon('arrow-down') }); export const GOTO_PREVIOUS_CHANGE = Command.toDefaultLocalizedCommand({ id: 'workbench.action.editor.previousChange', category: 'Source Control', label: 'Go to Previous Change', iconClass: codicon('arrow-up') }); export const SHOW_NEXT_CHANGE = Command.toDefaultLocalizedCommand({ id: 'editor.action.dirtydiff.next', category: 'Source Control', label: 'Show Next Change' }); export const SHOW_PREVIOUS_CHANGE = Command.toDefaultLocalizedCommand({ id: 'editor.action.dirtydiff.previous', category: 'Source Control', label: 'Show Previous Change' }); export const CLOSE_CHANGE_PEEK_VIEW = { id: 'editor.action.dirtydiff.close', category: nls.localizeByDefault('Source Control'), originalCategory: 'Source Control', label: nls.localize('theia/scm/dirtyDiff/close', 'Close Change Peek View'), originalLabel: 'Close Change Peek View' }; } export { ScmColors }; @injectable() export class ScmContribution extends AbstractViewContribution implements FrontendApplicationContribution, TabBarToolbarContribution, ColorContribution, StylingParticipant { @inject(StatusBar) protected readonly statusBar: StatusBar; @inject(ScmService) protected readonly scmService: ScmService; @inject(ScmQuickOpenService) protected readonly scmQuickOpenService: ScmQuickOpenService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(CommandService) protected readonly commands: CommandService; @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; @inject(ScmDecorationsService) protected readonly scmDecorationsService: ScmDecorationsService; @inject(DirtyDiffNavigator) protected readonly dirtyDiffNavigator: DirtyDiffNavigator; @inject(ScmContextKeyService) protected readonly scmContextKeys: ScmContextKeyService; protected scmFocus: ContextKey; constructor() { super({ viewContainerId: SCM_VIEW_CONTAINER_ID, widgetId: SCM_WIDGET_FACTORY_ID, widgetName: SCM_VIEW_CONTAINER_TITLE_OPTIONS.label, defaultWidgetOptions: { area: 'left', rank: 300 }, toggleCommandId: 'scmView:toggle', toggleKeybinding: 'ctrlcmd+shift+g' }); } @postConstruct() protected init(): void { this.scmFocus = this.contextKeys.createKey('scmFocus', false); } async initializeLayout(): Promise { await this.openView(); } onStart(): void { this.updateStatusBar(); this.updateScmProviderCount(); this.scmService.onDidAddRepository(() => { this.updateStatusBar(); this.updateScmProviderCount(); }); this.scmService.onDidRemoveRepository(() => { this.updateStatusBar(); this.updateScmProviderCount(); }); this.scmService.onDidChangeSelectedRepository(() => this.updateStatusBar()); this.scmService.onDidChangeStatusBarCommands(() => this.updateStatusBar()); this.labelProvider.onDidChange(() => this.updateStatusBar()); this.updateContextKeys(); this.shell.onDidChangeCurrentWidget(() => this.updateContextKeys()); this.scmDecorationsService.onDirtyDiffUpdate(update => this.dirtyDiffNavigator.handleDirtyDiffUpdate(update)); } protected updateContextKeys(): void { this.scmFocus.set(this.shell.currentWidget instanceof ScmWidget); } protected updateScmProviderCount(): void { this.scmContextKeys.scmProviderCount.set(this.scmService.repositories.length); } override registerCommands(commandRegistry: CommandRegistry): void { super.registerCommands(commandRegistry); commandRegistry.registerCommand(SCM_COMMANDS.CHANGE_REPOSITORY, { execute: () => this.scmQuickOpenService.changeRepository(), isEnabled: () => this.scmService.repositories.length > 1 }); commandRegistry.registerCommand(SCM_COMMANDS.ACCEPT_INPUT, { execute: () => this.acceptInput(), isEnabled: () => !!this.scmFocus.get() && !!this.acceptInputCommand() }); // Note that commands for dirty diff navigation need to be always available. // This is consistent with behavior in VS Code, and also with other similar commands (such as `Next Problem/Previous Problem`) in Theia. // See https://github.com/eclipse-theia/theia/pull/13104#discussion_r1497316614 for a detailed discussion. commandRegistry.registerCommand(SCM_COMMANDS.GOTO_NEXT_CHANGE, { execute: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { widget.editor.diffNavigator.next(); widget.activate(); } else { this.dirtyDiffNavigator.gotoNextChange(); } }, isEnabled: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { return widget.editor.diffNavigator.hasNext(); } return true; } }); commandRegistry.registerCommand(SCM_COMMANDS.GOTO_PREVIOUS_CHANGE, { execute: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { widget.editor.diffNavigator.previous(); widget.activate(); } else { this.dirtyDiffNavigator.gotoPreviousChange(); } }, isEnabled: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { return widget.editor.diffNavigator.hasPrevious(); } return true; } }); commandRegistry.registerCommand(SCM_COMMANDS.SHOW_NEXT_CHANGE, { execute: () => this.dirtyDiffNavigator.showNextChange() }); commandRegistry.registerCommand(SCM_COMMANDS.SHOW_PREVIOUS_CHANGE, { execute: () => this.dirtyDiffNavigator.showPreviousChange() }); commandRegistry.registerCommand(SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW, { execute: () => this.dirtyDiffNavigator.closeChangePeekView() }); } override registerMenus(menus: MenuModelRegistry): void { super.registerMenus(menus); menus.registerMenuAction(ScmMenus.CHANGES_GROUP, { commandId: SCM_COMMANDS.SHOW_NEXT_CHANGE.id, label: nls.localizeByDefault('Next Change'), order: '1' }); menus.registerMenuAction(ScmMenus.CHANGES_GROUP, { commandId: SCM_COMMANDS.SHOW_PREVIOUS_CHANGE.id, label: nls.localizeByDefault('Previous Change'), order: '2' }); } registerToolbarItems(registry: TabBarToolbarRegistry): void { const viewModeEmitter = new Emitter(); const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => { const id = command.id; const item: TabBarToolbarAction = { id, command: id, tooltip: command.label, onDidChange: viewModeEmitter.event }; this.commandRegistry.registerCommand({ id, iconClass: command && command.iconClass }, { execute: widget => { if (widget instanceof ScmWidget) { widget.viewMode = mode; viewModeEmitter.fire(); } }, isVisible: widget => { if (widget instanceof ScmWidget) { return !!this.scmService.selectedRepository && widget.viewMode !== mode; } return false; }, }); registry.registerItem(item); }; registerToggleViewItem(SCM_COMMANDS.TREE_VIEW_MODE, 'tree'); registerToggleViewItem(SCM_COMMANDS.LIST_VIEW_MODE, 'list'); this.commandRegistry.registerCommand(SCM_COMMANDS.COLLAPSE_ALL, { execute: widget => { if (widget instanceof ScmWidget && widget.viewMode === 'tree') { widget.collapseScmTree(); } }, isVisible: widget => { if (widget instanceof ScmWidget) { return !!this.scmService.selectedRepository && widget.viewMode === 'tree'; } return false; } }); registry.registerItem({ id: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id, command: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id, isVisible: widget => widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor, }); registry.registerItem({ id: SCM_COMMANDS.GOTO_NEXT_CHANGE.id, command: SCM_COMMANDS.GOTO_NEXT_CHANGE.id, isVisible: widget => widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor, }); registry.registerItem({ ...SCM_COMMANDS.COLLAPSE_ALL, command: SCM_COMMANDS.COLLAPSE_ALL.id }); } override registerKeybindings(keybindings: KeybindingRegistry): void { super.registerKeybindings(keybindings); keybindings.registerKeybinding({ command: SCM_COMMANDS.ACCEPT_INPUT.id, keybinding: 'ctrlcmd+enter', when: 'scmFocus' }); keybindings.registerKeybinding({ command: SCM_COMMANDS.GOTO_NEXT_CHANGE.id, keybinding: 'alt+f5', when: 'editorTextFocus' }); keybindings.registerKeybinding({ command: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id, keybinding: 'shift+alt+f5', when: 'editorTextFocus' }); keybindings.registerKeybinding({ command: SCM_COMMANDS.SHOW_NEXT_CHANGE.id, keybinding: 'alt+f3', when: 'editorTextFocus' }); keybindings.registerKeybinding({ command: SCM_COMMANDS.SHOW_PREVIOUS_CHANGE.id, keybinding: 'shift+alt+f3', when: 'editorTextFocus' }); keybindings.registerKeybinding({ command: SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW.id, keybinding: 'esc', when: 'dirtyDiffVisible' }); } protected async acceptInput(): Promise { const command = this.acceptInputCommand(); if (command && command.command) { await this.commands.executeCommand(command.command, ...command.arguments ? command.arguments : []); } } protected acceptInputCommand(): ScmCommand | undefined { const repository = this.scmService.selectedRepository; if (!repository) { return undefined; } return repository.provider.acceptInputCommand; } protected readonly statusBarDisposable = new DisposableCollection(); protected updateStatusBar(): void { this.statusBarDisposable.dispose(); const repository = this.scmService.selectedRepository; if (!repository) { return; } const name = this.labelProvider.getName(new URI(repository.provider.rootUri)); if (this.scmService.repositories.length > 1) { this.setStatusBarEntry(SCM_COMMANDS.CHANGE_REPOSITORY.id, { text: `$(database) ${name}`, tooltip: name.toString(), command: SCM_COMMANDS.CHANGE_REPOSITORY.id, alignment: StatusBarAlignment.LEFT, priority: 100 }); } const label = repository.provider.rootUri ? `${name} (${repository.provider.label})` : repository.provider.label; this.scmService.statusBarCommands.forEach((value, index) => this.setStatusBarEntry(`scm.status.${index}`, { text: value.title, tooltip: label + (value.tooltip ? ` - ${value.tooltip}` : ''), command: value.command, arguments: value.arguments, alignment: StatusBarAlignment.LEFT, priority: 100 })); } protected setStatusBarEntry(id: string, entry: StatusBarEntry): void { this.statusBar.setElement(id, entry); this.statusBarDisposable.push(Disposable.create(() => this.statusBar.removeElement(id))); } /** * It should be aligned with https://github.com/microsoft/vscode/blob/0dfa355b3ad185a6289ba28a99c141ab9e72d2be/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L808 */ registerColors(colors: ColorRegistry): void { colors.register( { id: ScmColors.editorGutterModifiedBackground, defaults: { dark: '#1B81A8', light: '#2090D3', hcDark: '#1B81A8', hcLight: '#2090D3' }, description: 'Editor gutter background color for lines that are modified.' }, { id: ScmColors.editorGutterAddedBackground, defaults: { dark: '#487E02', light: '#48985D', hcDark: '#487E02', hcLight: '#48985D' }, description: 'Editor gutter background color for lines that are added.' }, { id: ScmColors.editorGutterDeletedBackground, defaults: { dark: 'editorError.foreground', light: 'editorError.foreground', hcDark: 'editorError.foreground', hcLight: 'editorError.foreground' }, description: 'Editor gutter background color for lines that are deleted.' }, { id: 'minimapGutter.modifiedBackground', defaults: { dark: 'editorGutter.modifiedBackground', light: 'editorGutter.modifiedBackground', hcDark: 'editorGutter.modifiedBackground', hcLight: 'editorGutter.modifiedBackground' }, description: 'Minimap gutter background color for lines that are modified.' }, { id: 'minimapGutter.addedBackground', defaults: { dark: 'editorGutter.addedBackground', light: 'editorGutter.addedBackground', hcDark: 'editorGutter.modifiedBackground', hcLight: 'editorGutter.modifiedBackground' }, description: 'Minimap gutter background color for lines that are added.' }, { id: 'minimapGutter.deletedBackground', defaults: { dark: 'editorGutter.deletedBackground', light: 'editorGutter.deletedBackground', hcDark: 'editorGutter.deletedBackground', hcLight: 'editorGutter.deletedBackground' }, description: 'Minimap gutter background color for lines that are deleted.' }, { id: 'editorOverviewRuler.modifiedForeground', defaults: { dark: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6), light: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6), hcDark: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6), hcLight: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6) }, description: 'Overview ruler marker color for modified content.' }, { id: 'editorOverviewRuler.addedForeground', defaults: { dark: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6), light: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6), hcDark: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6), hcLight: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6) }, description: 'Overview ruler marker color for added content.' }, { id: 'editorOverviewRuler.deletedForeground', defaults: { dark: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6), light: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6), hcDark: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6), hcLight: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6) }, description: 'Overview ruler marker color for deleted content.' } ); } registerThemeStyle(theme: ColorTheme, collector: CssStyleCollector): void { const contrastBorder = theme.getColor('contrastBorder'); if (contrastBorder && isHighContrast(theme.type)) { collector.addRule(` .theia-scm-input-message-container textarea { outline: var(--theia-border-width) solid ${contrastBorder}; outline-offset: -1px; } `); } } }