deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
import { CommandContribution, CommandRegistry, Command, MenuContribution, MenuModelRegistry, DisposableCollection } from '@theia/core/lib/common';
import { BlameDecorator } from './blame-decorator';
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
import { BlameManager } from './blame-manager';
import URI from '@theia/core/lib/common/uri';
import { EDITOR_CONTEXT_MENU_SCM } from '@theia/scm-extra/lib/browser/scm-extra-contribution';
import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import debounce = require('@theia/core/shared/lodash.debounce');
export namespace BlameCommands {
export const TOGGLE_GIT_ANNOTATIONS = Command.toLocalizedCommand({
id: 'git.editor.toggle.annotations',
category: 'Git',
label: 'Toggle Blame Annotations'
}, 'theia/git/toggleBlameAnnotations', 'vscode.git/package/displayName');
export const CLEAR_GIT_ANNOTATIONS: Command = {
id: 'git.editor.clear.annotations'
};
}
@injectable()
export class BlameContribution implements CommandContribution, KeybindingContribution, MenuContribution {
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(BlameDecorator)
protected readonly decorator: BlameDecorator;
@inject(BlameManager)
protected readonly blameManager: BlameManager;
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
protected _visibleBlameAnnotations: ContextKey<boolean>;
@postConstruct()
protected init(): void {
this._visibleBlameAnnotations = this.contextKeyService.createKey<boolean>('showsBlameAnnotations', this.visibleBlameAnnotations());
this.editorManager.onActiveEditorChanged(() => this.updateContext());
}
protected updateContext(): void {
this._visibleBlameAnnotations.set(this.visibleBlameAnnotations());
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(BlameCommands.TOGGLE_GIT_ANNOTATIONS, {
execute: () => {
const editorWidget = this.currentFileEditorWidget;
if (editorWidget) {
if (this.showsBlameAnnotations(editorWidget.editor.uri)) {
this.clearBlame(editorWidget.editor.uri);
} else {
this.showBlame(editorWidget);
}
}
},
isVisible: () => !!this.currentFileEditorWidget,
isEnabled: () => {
const editorWidget = this.currentFileEditorWidget;
return !!editorWidget && this.isBlameable(editorWidget.editor.uri);
}
});
commands.registerCommand(BlameCommands.CLEAR_GIT_ANNOTATIONS, {
execute: () => {
const editorWidget = this.currentFileEditorWidget;
if (editorWidget) {
this.clearBlame(editorWidget.editor.uri);
}
},
isVisible: () => !!this.currentFileEditorWidget,
isEnabled: () => {
const editorWidget = this.currentFileEditorWidget;
const enabled = !!editorWidget && this.showsBlameAnnotations(editorWidget.editor.uri);
return enabled;
}
});
}
showsBlameAnnotations(uri: string | URI): boolean {
return this.appliedDecorations.get(uri.toString())?.disposed === false;
}
protected get currentFileEditorWidget(): EditorWidget | undefined {
const editorWidget = this.editorManager.currentEditor;
if (editorWidget) {
if (editorWidget.editor.uri.scheme === 'file') {
return editorWidget;
}
}
return undefined;
}
protected isBlameable(uri: string | URI): boolean {
return this.blameManager.isBlameable(uri.toString());
}
protected visibleBlameAnnotations(): boolean {
const widget = this.editorManager.activeEditor;
if (widget && widget.editor.isFocused() && this.showsBlameAnnotations(widget.editor.uri)) {
return true;
}
return false;
}
protected appliedDecorations = new Map<string, DisposableCollection>();
protected async showBlame(editorWidget: EditorWidget): Promise<void> {
const uri = editorWidget.editor.uri.toString();
if (this.appliedDecorations.get(uri)) {
return;
}
const toDispose = new DisposableCollection();
this.appliedDecorations.set(uri, toDispose);
try {
const editor = editorWidget.editor;
const document = editor.document;
const content = document.dirty ? document.getText() : undefined;
const blame = await this.blameManager.getBlame(uri, content);
if (blame) {
toDispose.push(this.decorator.decorate(blame, editor, editor.cursor.line));
toDispose.push(editor.onDocumentContentChanged(() => this.clearBlame(uri)));
toDispose.push(editor.onCursorPositionChanged(debounce(_position => {
if (!toDispose.disposed) {
this.decorator.decorate(blame, editor, editor.cursor.line);
}
}, 50)));
editorWidget.disposed.connect(() => this.clearBlame(uri));
}
} finally {
if (toDispose.disposed) {
this.appliedDecorations.delete(uri);
};
this.updateContext();
}
}
protected clearBlame(uri: string | URI): void {
const decorations = this.appliedDecorations.get(uri.toString());
if (decorations) {
this.appliedDecorations.delete(uri.toString());
decorations.dispose();
this.updateContext();
}
}
registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(EDITOR_CONTEXT_MENU_SCM, {
commandId: BlameCommands.TOGGLE_GIT_ANNOTATIONS.id,
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: BlameCommands.TOGGLE_GIT_ANNOTATIONS.id,
when: 'editorTextFocus',
keybinding: 'alt+b'
});
keybindings.registerKeybinding({
command: BlameCommands.CLEAR_GIT_ANNOTATIONS.id,
when: 'showsBlameAnnotations',
keybinding: 'esc'
});
}
}

View File

@@ -0,0 +1,250 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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, unmanaged } from '@theia/core/shared/inversify';
import { EditorManager, TextEditor, EditorDecoration, EditorDecorationOptions, Range, Position, EditorDecorationStyle } from '@theia/editor/lib/browser';
import { GitFileBlame } from '../../common';
import { Disposable, DisposableCollection, nls } from '@theia/core';
import { DateTime } from 'luxon';
import URI from '@theia/core/lib/common/uri';
import { DecorationStyle } from '@theia/core/lib/browser';
import * as monaco from '@theia/monaco-editor-core';
import { LanguageSelector } from '@theia/monaco-editor-core/esm/vs/editor/common/languageSelector';
@injectable()
export class BlameDecorator implements monaco.languages.HoverProvider {
constructor(
@unmanaged() protected blameDecorationsStyleSheet: CSSStyleSheet = DecorationStyle.createStyleSheet('gitBlameDecorationsStyle')
) {
DecorationStyle.getOrCreateStyleRule(`.${BlameDecorator.GIT_BLAME_HIGHLIGHT}`,
this.blameDecorationsStyleSheet).style.backgroundColor = 'var(--theia-gitlens-lineHighlightBackgroundColor)';
DecorationStyle.getOrCreateStyleRule(`.${BlameDecorator.GIT_BLAME_CONTINUATION_LINE}::before`, this.blameDecorationsStyleSheet).style.content = "'\u2007'"; // blank
DecorationStyle.getOrCreateStyleRule(`.${BlameDecorator.GIT_BLAME_CONTINUATION_LINE}::after`, this.blameDecorationsStyleSheet).style.content = "'\u2007'"; // blank;
}
@inject(EditorManager)
protected readonly editorManager: EditorManager;
protected registerHoverProvider(uri: string): Disposable {
// The public typedef of this method only accepts strings, but it immediately delegates to a method that accepts LanguageSelectors.
return (monaco.languages.registerHoverProvider as (languageId: LanguageSelector, provider: monaco.languages.HoverProvider) => Disposable)
([{ pattern: new URI(uri).path.toString() }], this);
}
protected emptyHover: monaco.languages.Hover = {
contents: [{
value: ''
}]
};
async provideHover(model: monaco.editor.ITextModel, position: monaco.Position, token: monaco.CancellationToken): Promise<monaco.languages.Hover> {
const line = position.lineNumber - 1;
const uri = model.uri.toString();
const applications = this.appliedDecorations.get(uri);
if (!applications) {
return this.emptyHover;
}
const blame = applications.blame;
if (!blame) {
return this.emptyHover;
}
const commitLine = blame.lines.find(l => l.line === line);
if (!commitLine) {
return this.emptyHover;
}
const sha = commitLine.sha;
const commit = blame.commits.find(c => c.sha === sha)!;
const date = new Date(commit.author.timestamp);
let commitMessage = commit.summary + '\n' + (commit.body || '');
commitMessage = commitMessage.replace(/[`\>\#\*\_\-\+]/g, '\\$&').replace(/\n/g, ' \n');
const value = `${commit.sha}\n \n ${commit.author.name}, ${date.toString()}\n \n> ${commitMessage}`;
const hover = {
contents: [{ value }],
range: monaco.Range.fromPositions(new monaco.Position(position.lineNumber, 1), new monaco.Position(position.lineNumber, 10 ^ 10))
};
return hover;
}
protected appliedDecorations = new Map<string, AppliedBlameDecorations>();
decorate(blame: GitFileBlame, editor: TextEditor, highlightLine: number): Disposable {
const uri = editor.uri.toString();
let applications = this.appliedDecorations.get(uri);
if (!applications) {
const that = applications = new AppliedBlameDecorations();
this.appliedDecorations.set(uri, applications);
applications.toDispose.push(this.registerHoverProvider(uri));
applications.toDispose.push(Disposable.create(() => {
this.appliedDecorations.delete(uri);
}));
applications.toDispose.push(Disposable.create(() => {
editor.deltaDecorations({ oldDecorations: that.previousDecorations, newDecorations: [] });
}));
}
if (applications.highlightedSha) {
const sha = this.getShaForLine(blame, highlightLine);
if (applications.highlightedSha === sha) {
return applications;
}
applications.highlightedSha = sha;
}
applications.previousStyles.dispose();
const blameDecorations = this.toDecorations(blame, highlightLine);
applications.previousStyles.pushAll(blameDecorations.styles);
const newDecorations = blameDecorations.editorDecorations;
const oldDecorations = applications.previousDecorations;
const appliedDecorations = editor.deltaDecorations({ oldDecorations, newDecorations });
applications.previousDecorations.length = 0;
applications.previousDecorations.push(...appliedDecorations);
applications.blame = blame;
return applications;
}
protected getShaForLine(blame: GitFileBlame, line: number): string | undefined {
const commitLines = blame.lines;
const commitLine = commitLines.find(c => c.line === line);
return commitLine ? commitLine.sha : undefined;
}
protected toDecorations(blame: GitFileBlame, highlightLine: number): BlameDecorations {
const beforeContentStyles = new Map<string, EditorDecorationStyle>();
const commits = blame.commits;
for (const commit of commits) {
const sha = commit.sha;
const commitTime = DateTime.fromISO(commit.author.timestamp);
const heat = this.getHeatColor(commitTime);
const content = commit.summary.replace('\n', '↩︎').replace(/'/g, "\\'");
const short = sha.substring(0, 7);
new EditorDecorationStyle('.git-' + short, style => {
Object.assign(style, BlameDecorator.defaultGutterStyles);
style.borderColor = heat;
}, this.blameDecorationsStyleSheet);
beforeContentStyles.set(sha, new EditorDecorationStyle('.git-' + short + '::before', style => {
Object.assign(style, BlameDecorator.defaultGutterBeforeStyles);
style.content = `'${content}'`;
}, this.blameDecorationsStyleSheet));
new EditorDecorationStyle('.git-' + short + '::after', style => {
Object.assign(style, BlameDecorator.defaultGutterAfterStyles);
style.content = (this.now.diff(commitTime, 'seconds').toObject().seconds ?? 0) < 60
? `'${nls.localize('theia/git/aFewSecondsAgo', 'a few seconds ago')}'`
: `'${commitTime.toRelative({ locale: nls.locale })}'`;
}, this.blameDecorationsStyleSheet);
}
const commitLines = blame.lines;
const highlightedSha = this.getShaForLine(blame, highlightLine) || '';
let previousLineSha = '';
const editorDecorations: EditorDecoration[] = [];
for (const commitLine of commitLines) {
const { line, sha } = commitLine;
const beforeContentClassName = beforeContentStyles.get(sha)!.className;
const options = <EditorDecorationOptions>{
beforeContentClassName,
};
if (sha === highlightedSha) {
options.beforeContentClassName += ' ' + BlameDecorator.GIT_BLAME_HIGHLIGHT;
}
if (sha === previousLineSha) {
options.beforeContentClassName += ' ' + BlameDecorator.GIT_BLAME_CONTINUATION_LINE + ' ' + BlameDecorator.GIT_BLAME_CONTINUATION_LINE;
}
previousLineSha = sha;
const range = Range.create(Position.create(line, 0), Position.create(line, 0));
editorDecorations.push(<EditorDecoration>{ range, options });
}
const styles = [...beforeContentStyles.values()];
return { editorDecorations, styles };
}
protected now = DateTime.now();
protected getHeatColor(commitTime: DateTime): string {
const daysFromNow = this.now.diff(commitTime, 'days').toObject().days ?? 0;
if (daysFromNow <= 2) {
return 'var(--md-orange-50)';
}
if (daysFromNow <= 5) {
return 'var(--md-orange-100)';
}
if (daysFromNow <= 10) {
return 'var(--md-orange-200)';
}
if (daysFromNow <= 15) {
return 'var(--md-orange-300)';
}
if (daysFromNow <= 60) {
return 'var(--md-orange-400)';
}
if (daysFromNow <= 180) {
return 'var(--md-deep-orange-600)';
}
if (daysFromNow <= 365) {
return 'var(--md-deep-orange-700)';
}
if (daysFromNow <= 720) {
return 'var(--md-deep-orange-800)';
}
return 'var(--md-deep-orange-900)';
}
}
export namespace BlameDecorator {
export const GIT_BLAME_HIGHLIGHT = 'git-blame-highlight';
export const GIT_BLAME_CONTINUATION_LINE = 'git-blame-continuation-line';
export const defaultGutterStyles = <CSSStyleDeclaration>{
display: 'inline-flex',
width: '50ch',
marginRight: '26px',
justifyContent: 'space-between',
backgroundColor: 'var(--theia-gitlens-gutterBackgroundColor)',
borderRight: '2px solid',
height: '100%',
overflow: 'hidden'
};
export const defaultGutterBeforeStyles = <CSSStyleDeclaration>{
color: 'var(--theia-gitlens-gutterForegroundColor)',
overflow: 'hidden',
textOverflow: 'ellipsis'
};
export const defaultGutterAfterStyles = <CSSStyleDeclaration>{
color: 'var(--theia-gitlens-gutterForegroundColor)',
marginLeft: '12px'
};
}
export interface BlameDecorations {
editorDecorations: EditorDecoration[]
styles: EditorDecorationStyle[]
}
export class AppliedBlameDecorations implements Disposable {
readonly toDispose = new DisposableCollection();
readonly previousStyles = new DisposableCollection();
readonly previousDecorations: string[] = [];
blame: GitFileBlame | undefined;
highlightedSha: string | undefined;
dispose(): void {
this.previousStyles.dispose();
this.toDispose.dispose();
this.blame = undefined;
}
}

View File

@@ -0,0 +1,43 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 } from '@theia/core/shared/inversify';
import { Git, GitFileBlame } from '../../common';
import { GitRepositoryTracker } from '../git-repository-tracker';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class BlameManager {
@inject(Git)
protected readonly git: Git;
@inject(GitRepositoryTracker)
protected readonly repositoryTracker: GitRepositoryTracker;
isBlameable(uri: string): boolean {
return !!this.repositoryTracker.getPath(new URI(uri));
}
async getBlame(uri: string, content?: string): Promise<GitFileBlame | undefined> {
const repository = this.repositoryTracker.selectedRepository;
if (!repository) {
return undefined;
}
return this.git.blame(repository, uri, { content });
}
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { interfaces } from '@theia/core/shared/inversify';
import { KeybindingContribution } from '@theia/core/lib/browser';
import { CommandContribution, MenuContribution } from '@theia/core/lib/common';
import { BlameContribution } from './blame-contribution';
import { BlameDecorator } from './blame-decorator';
import { BlameManager } from './blame-manager';
export function bindBlame(bind: interfaces.Bind): void {
bind(BlameContribution).toSelf().inSingletonScope();
bind(BlameManager).toSelf().inSingletonScope();
bind(BlameDecorator).toSelf().inSingletonScope();
for (const serviceIdentifier of [CommandContribution, KeybindingContribution, MenuContribution]) {
bind(serviceIdentifier).toService(BlameContribution);
}
}

View File

@@ -0,0 +1,254 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { CommandRegistry, Command, MenuModelRegistry, SelectionService, MessageService } from '@theia/core/lib/common';
import { FrontendApplication, AbstractViewContribution, codicon, open, OpenerService } from '@theia/core/lib/browser';
import { WidgetManager } from '@theia/core/lib/browser/widget-manager';
import { EditorManager } from '@theia/editor/lib/browser';
import { injectable, inject } from '@theia/core/shared/inversify';
import { GitDiffWidget, GIT_DIFF } from './git-diff-widget';
import { GitCommitDetailWidget } from '../history/git-commit-detail-widget';
import { GitDiffTreeModel } from './git-diff-tree-model';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { NavigatorContextMenu, FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution';
import { UriCommandHandler } from '@theia/core/lib/common/uri-command-handler';
import { GitQuickOpenService } from '../git-quick-open-service';
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
import URI from '@theia/core/lib/common/uri';
import { GIT_RESOURCE_SCHEME } from '../git-resource';
import { Git, Repository } from '../../common';
import { WorkspaceRootUriAwareCommandHandler } from '@theia/workspace/lib/browser/workspace-commands';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { TabBarToolbarAction, TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { Emitter } from '@theia/core/lib/common/event';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { nls } from '@theia/core/lib/common/nls';
export namespace GitDiffCommands {
export const OPEN_FILE_DIFF = Command.toLocalizedCommand({
id: 'git-diff:open-file-diff',
category: 'Git Diff',
label: 'Compare With...'
}, 'theia/git/compareWith');
export const TREE_VIEW_MODE = {
id: 'git.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: 'git.viewmode.list',
tooltip: nls.localizeByDefault('View as List'),
iconClass: codicon('list-flat'),
originalLabel: 'View as List',
label: nls.localizeByDefault('View as List')
};
export const PREVIOUS_CHANGE = {
id: 'git.navigate-changes.previous',
tooltip: nls.localizeByDefault('Previous Change'),
iconClass: codicon('arrow-left'),
originalLabel: 'Previous Change',
label: nls.localizeByDefault('Previous Change')
};
export const NEXT_CHANGE = {
id: 'git.navigate-changes.next',
tooltip: nls.localizeByDefault('Next Change'),
iconClass: codicon('arrow-right'),
originalLabel: 'Next Change',
label: nls.localizeByDefault('Next Change')
};
}
export namespace ScmNavigatorMoreToolbarGroups {
export const SCM = '3_navigator_scm';
}
@injectable()
export class GitDiffContribution extends AbstractViewContribution<GitDiffWidget> implements TabBarToolbarContribution {
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(FileNavigatorContribution)
protected readonly fileNavigatorContribution: FileNavigatorContribution;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
constructor(
@inject(SelectionService) protected readonly selectionService: SelectionService,
@inject(WidgetManager) protected override readonly widgetManager: WidgetManager,
@inject(FrontendApplication) protected readonly app: FrontendApplication,
@inject(GitQuickOpenService) protected readonly quickOpenService: GitQuickOpenService,
@inject(FileService) protected readonly fileService: FileService,
@inject(OpenerService) protected openerService: OpenerService,
@inject(MessageService) protected readonly notifications: MessageService,
@inject(ScmService) protected readonly scmService: ScmService
) {
super({
widgetId: GIT_DIFF,
widgetName: 'Git diff',
defaultWidgetOptions: {
area: 'left',
rank: 500
}
});
}
override registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(NavigatorContextMenu.COMPARE, {
commandId: GitDiffCommands.OPEN_FILE_DIFF.id
});
}
override registerCommands(commands: CommandRegistry): void {
commands.registerCommand(GitDiffCommands.OPEN_FILE_DIFF, this.newWorkspaceRootUriAwareCommandHandler({
isVisible: uri => !!this.findGitRepository(uri),
isEnabled: uri => !!this.findGitRepository(uri),
execute: async fileUri => {
const repository = this.findGitRepository(fileUri);
if (repository) {
await this.quickOpenService.chooseTagsAndBranches(
async (fromRevision, toRevision) => {
const uri = fileUri.toString();
const fileStat = await this.fileService.resolve(fileUri);
const diffOptions: Git.Options.Diff = {
uri,
range: {
fromRevision
}
};
if (fileStat.isDirectory) {
this.showWidget({ rootUri: repository.localUri, diffOptions });
} else {
const fromURI = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(fromRevision);
const toURI = fileUri;
const diffUri = DiffUris.encode(fromURI, toURI);
if (diffUri) {
open(this.openerService, diffUri).catch(e => {
this.notifications.error(e.message);
});
}
}
}, repository);
}
}
}));
commands.registerCommand(GitDiffCommands.PREVIOUS_CHANGE, {
execute: widget => {
if (widget instanceof GitDiffWidget) {
widget.goToPreviousChange();
}
},
isVisible: widget => widget instanceof GitDiffWidget,
});
commands.registerCommand(GitDiffCommands.NEXT_CHANGE, {
execute: widget => {
if (widget instanceof GitDiffWidget) {
widget.goToNextChange();
}
},
isVisible: widget => widget instanceof GitDiffWidget,
});
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
this.fileNavigatorContribution.registerMoreToolbarItem({
id: GitDiffCommands.OPEN_FILE_DIFF.id,
command: GitDiffCommands.OPEN_FILE_DIFF.id,
tooltip: GitDiffCommands.OPEN_FILE_DIFF.label,
group: ScmNavigatorMoreToolbarGroups.SCM,
});
const viewModeEmitter = new Emitter<void>();
const extractDiffWidget = (widget: unknown) => {
if (widget instanceof GitDiffWidget) {
return widget;
}
};
const extractCommitDetailWidget = (widget: unknown) => {
const ref = widget ? widget : this.editorManager.currentEditor;
if (ref instanceof GitCommitDetailWidget) {
return ref;
}
return undefined;
};
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 => {
const widgetWithChanges = extractDiffWidget(widget) || extractCommitDetailWidget(widget);
if (widgetWithChanges) {
widgetWithChanges.viewMode = mode;
viewModeEmitter.fire();
}
},
isVisible: widget => {
const widgetWithChanges = extractDiffWidget(widget) || extractCommitDetailWidget(widget);
if (widgetWithChanges) {
return widgetWithChanges.viewMode !== mode;
}
return false;
},
});
registry.registerItem(item);
};
registerToggleViewItem(GitDiffCommands.TREE_VIEW_MODE, 'tree');
registerToggleViewItem(GitDiffCommands.LIST_VIEW_MODE, 'list');
registry.registerItem({
id: GitDiffCommands.PREVIOUS_CHANGE.id,
command: GitDiffCommands.PREVIOUS_CHANGE.id,
tooltip: GitDiffCommands.PREVIOUS_CHANGE.label,
});
registry.registerItem({
id: GitDiffCommands.NEXT_CHANGE.id,
command: GitDiffCommands.NEXT_CHANGE.id,
tooltip: GitDiffCommands.NEXT_CHANGE.label,
});
}
protected findGitRepository(uri: URI): Repository | undefined {
const repo = this.scmService.findRepository(uri);
if (repo && repo.provider.id === 'git') {
return { localUri: repo.provider.rootUri };
}
return undefined;
}
async showWidget(options: GitDiffTreeModel.Options): Promise<GitDiffWidget> {
const widget = await this.widget;
await widget.setContent(options);
return this.openView({
activate: true
});
}
protected newWorkspaceRootUriAwareCommandHandler(handler: UriCommandHandler<URI>): WorkspaceRootUriAwareCommandHandler {
return new WorkspaceRootUriAwareCommandHandler(this.workspaceService, this.selectionService, handler);
}
}

View File

@@ -0,0 +1,53 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { interfaces, Container } from '@theia/core/shared/inversify';
import { GitDiffContribution } from './git-diff-contribution';
import { WidgetFactory, bindViewContribution, TreeModel } from '@theia/core/lib/browser';
import { GitDiffWidget, GIT_DIFF } from './git-diff-widget';
import { GitDiffHeaderWidget } from './git-diff-header-widget';
import { GitDiffTreeModel } from './git-diff-tree-model';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { createScmTreeContainer } from '@theia/scm/lib/browser/scm-frontend-module';
import { GitResourceOpener } from './git-resource-opener';
import { GitOpenerInPrimaryArea } from './git-opener-in-primary-area';
import '../../../src/browser/style/diff.css';
export function bindGitDiffModule(bind: interfaces.Bind): void {
bind(GitDiffWidget).toSelf();
bind(WidgetFactory).toDynamicValue(ctx => ({
id: GIT_DIFF,
createWidget: () => {
const child = createGitDiffWidgetContainer(ctx.container);
return child.get(GitDiffWidget);
}
})).inSingletonScope();
bindViewContribution(bind, GitDiffContribution);
bind(TabBarToolbarContribution).toService(GitDiffContribution);
}
export function createGitDiffWidgetContainer(parent: interfaces.Container): Container {
const child = createScmTreeContainer(parent);
child.bind(GitDiffHeaderWidget).toSelf();
child.bind(GitDiffTreeModel).toSelf();
child.bind(TreeModel).toService(GitDiffTreeModel);
child.bind(GitResourceOpener).to(GitOpenerInPrimaryArea);
return child;
}

View File

@@ -0,0 +1,159 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { ScmFileChangeLabelProvider } from '@theia/scm-extra/lib/browser/scm-file-change-label-provider';
import { ReactWidget, StatefulWidget, KeybindingRegistry, codicon } from '@theia/core/lib/browser';
import { Git } from '../../common';
import * as React from '@theia/core/shared/react';
/* eslint-disable no-null/no-null */
@injectable()
export class GitDiffHeaderWidget extends ReactWidget implements StatefulWidget {
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(ScmFileChangeLabelProvider) protected readonly scmLabelProvider: ScmFileChangeLabelProvider;
protected options: Git.Options.Diff;
protected authorAvatar: string;
constructor(
) {
super();
this.id = 'git-diff-header';
this.title.closable = true;
this.title.iconClass = codicon('git-commit');
}
async setContent(options: Git.Options.Diff): Promise<void> {
this.options = options;
this.update();
}
protected render(): React.ReactNode {
return React.createElement('div', this.createContainerAttributes(), this.renderDiffListHeader());
}
/**
* Create the container attributes for the widget.
*/
protected createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
return {
style: { flexGrow: 0 }
};
}
protected renderDiffListHeader(): React.ReactNode {
return this.doRenderDiffListHeader(
this.renderRepositoryHeader(),
this.renderPathHeader(),
this.renderRevisionHeader(),
);
}
protected doRenderDiffListHeader(...children: React.ReactNode[]): React.ReactNode {
return <div className='diff-header'>{...children}</div>;
}
protected renderRepositoryHeader(): React.ReactNode {
if (this.options && this.options.uri) {
return this.renderHeaderRow({ name: 'repository', value: this.getRepositoryLabel(this.options.uri) });
}
return undefined;
}
protected getRepositoryLabel(uri: string): string | undefined {
const repository = this.scmService.findRepository(new URI(uri));
const isSelectedRepo = this.scmService.selectedRepository && repository && this.scmService.selectedRepository.provider.rootUri === repository.provider.rootUri;
return repository && !isSelectedRepo ? this.labelProvider.getLongName(new URI(repository.provider.rootUri)) : undefined;
}
protected renderPathHeader(): React.ReactNode {
return this.renderHeaderRow({
classNames: ['diff-header'],
name: 'path',
value: this.renderPath()
});
}
protected renderPath(): React.ReactNode {
if (this.options.uri) {
const path = this.scmLabelProvider.relativePath(this.options.uri);
if (path.length > 0) {
return '/' + path;
} else {
return this.labelProvider.getLongName(new URI(this.options.uri));
}
}
return null;
}
protected renderRevisionHeader(): React.ReactNode {
return this.renderHeaderRow({
classNames: ['diff-header'],
name: 'revision: ',
value: this.renderRevision()
});
}
protected renderRevision(): React.ReactNode {
if (!this.fromRevision) {
return null;
}
if (typeof this.fromRevision === 'string') {
return this.fromRevision;
}
return (this.toRevision || 'HEAD') + '~' + this.fromRevision;
}
protected renderHeaderRow({ name, value, classNames, title }: { name: string, value: React.ReactNode, classNames?: string[], title?: string }): React.ReactNode {
if (!value) {
return;
}
const className = ['header-row', ...(classNames || [])].join(' ');
return <div key={name} className={className} title={title}>
<div className='theia-header'>{name}</div>
<div className='header-value'>{value}</div>
</div>;
}
protected get toRevision(): string | undefined {
return this.options.range && this.options.range.toRevision;
}
protected get fromRevision(): string | number | undefined {
return this.options.range && this.options.range.fromRevision;
}
storeState(): object {
const { options } = this;
return {
options
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restoreState(oldState: any): void {
const options = oldState['options'];
this.setContent(options);
}
}

View File

@@ -0,0 +1,131 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 } from '@theia/core/shared/inversify';
import { DisposableCollection } from '@theia/core/lib/common';
import URI from '@theia/core/lib/common/uri';
import { ScmTreeModel } from '@theia/scm/lib/browser/scm-tree-model';
import { Git, GitFileStatus } from '../../common';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { GitScmProvider, GitScmFileChange } from '../git-scm-provider';
import { ScmResourceGroup, ScmResource } from '@theia/scm/lib/browser/scm-provider';
import { ScmFileChange } from '@theia/scm-extra/lib/browser/scm-file-change-node';
import { GitResourceOpener } from './git-resource-opener';
@injectable()
export class GitDiffTreeModel extends ScmTreeModel {
@inject(Git) protected readonly git: Git;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(GitResourceOpener) protected readonly resourceOpener: GitResourceOpener;
protected diffOptions: Git.Options.Diff;
protected _groups: ScmResourceGroup[] = [];
protected readonly toDisposeOnContentChange = new DisposableCollection();
constructor() {
super();
this.toDispose.push(this.toDisposeOnContentChange);
}
async setContent(options: GitDiffTreeModel.Options): Promise<void> {
const { rootUri, diffOptions } = options;
this.toDisposeOnContentChange.dispose();
const scmRepository = this.scmService.findRepository(new URI(rootUri));
if (scmRepository && scmRepository.provider.id === 'git') {
const provider = scmRepository.provider as GitScmProvider;
this.provider = provider;
this.diffOptions = diffOptions;
this.refreshRepository(provider);
this.toDisposeOnContentChange.push(provider.onDidChange(() => {
this.refreshRepository(provider);
}));
}
}
protected async refreshRepository(provider: GitScmProvider): Promise<void> {
const repository = { localUri: provider.rootUri };
const gitFileChanges = await this.git.diff(repository, this.diffOptions);
const group: ScmResourceGroup = { id: 'changes', label: 'Files Changed', resources: [], provider, dispose: () => {} };
const resources: ScmResource[] = gitFileChanges
.map(change => new GitScmFileChange(change, provider, this.diffOptions.range))
.map(change => ({
sourceUri: new URI(change.uri),
decorations: {
letter: GitFileStatus.toAbbreviation(change.gitFileChange.status, true),
color: GitFileStatus.getColor(change.gitFileChange.status, true),
tooltip: GitFileStatus.toString(change.gitFileChange.status, true)
},
open: async () => this.open(change),
group,
}));
const changesGroup = { ...group, resources };
this._groups = [ changesGroup ];
this.root = this.createTree();
}
get rootUri(): string | undefined {
if (this.provider) {
return this.provider.rootUri;
}
};
canTabToWidget(): boolean {
return true;
}
get groups(): ScmResourceGroup[] {
return this._groups;
};
async open(change: ScmFileChange): Promise<void> {
const uriToOpen = change.getUriToOpen();
await this.resourceOpener.open(uriToOpen);
}
override storeState(): GitDiffTreeModel.Options {
if (this.provider) {
return {
...super.storeState(),
rootUri: this.provider.rootUri,
diffOptions: this.diffOptions,
};
} else {
return super.storeState();
}
}
override restoreState(oldState: GitDiffTreeModel.Options): void {
super.restoreState(oldState);
if (oldState.rootUri && oldState.diffOptions) {
this.setContent(oldState);
}
}
}
export namespace GitDiffTreeModel {
export interface Options {
rootUri: string,
diffOptions: Git.Options.Diff,
};
}

View File

@@ -0,0 +1,151 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 {
BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, Message, MessageLoop, codicon
} from '@theia/core/lib/browser';
import { EditorManager, DiffNavigatorProvider } from '@theia/editor/lib/browser';
import { GitDiffTreeModel } from './git-diff-tree-model';
import { GitWatcher } from '../../common';
import { GitDiffHeaderWidget } from './git-diff-header-widget';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { GitRepositoryProvider } from '../git-repository-provider';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmPreferences } from '@theia/scm/lib/common/scm-preferences';
import { nls } from '@theia/core';
export const GIT_DIFF = 'git-diff';
@injectable()
export class GitDiffWidget extends BaseWidget implements StatefulWidget {
protected readonly GIT_DIFF_TITLE = nls.localize('theia/git/diff', 'Diff');
@inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider;
@inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider;
@inject(EditorManager) protected readonly editorManager: EditorManager;
@inject(GitWatcher) protected readonly gitWatcher: GitWatcher;
@inject(GitDiffHeaderWidget) protected readonly diffHeaderWidget: GitDiffHeaderWidget;
@inject(ScmTreeWidget) protected readonly resourceWidget: ScmTreeWidget;
@inject(GitDiffTreeModel) protected readonly model: GitDiffTreeModel;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences;
protected panel: Panel;
constructor() {
super();
this.id = GIT_DIFF;
this.title.label = this.GIT_DIFF_TITLE;
this.title.caption = this.GIT_DIFF_TITLE;
this.title.closable = true;
this.title.iconClass = codicon('git-compare');
this.addClass('theia-scm');
this.addClass('theia-git');
this.addClass('git-diff-container');
}
@postConstruct()
protected init(): void {
const layout = new PanelLayout();
this.layout = layout;
this.panel = new Panel({
layout: new PanelLayout({
})
});
this.panel.node.tabIndex = -1;
this.panel.node.setAttribute('class', 'theia-scm-panel');
layout.addWidget(this.panel);
this.containerLayout.addWidget(this.diffHeaderWidget);
this.containerLayout.addWidget(this.resourceWidget);
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
this.toDispose.push(this.scmPreferences.onPreferenceChanged(e => {
if (e.preferenceName === 'scm.defaultViewMode') {
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
}
}));
}
set viewMode(mode: 'tree' | 'list') {
this.resourceWidget.viewMode = mode;
}
get viewMode(): 'tree' | 'list' {
return this.resourceWidget.viewMode;
}
async setContent(options: GitDiffTreeModel.Options): Promise<void> {
this.model.setContent(options);
this.diffHeaderWidget.setContent(options.diffOptions);
this.update();
}
get containerLayout(): PanelLayout {
return this.panel.layout as PanelLayout;
}
/**
* Updates the view mode based on the preference value.
* @param preference the view mode preference.
*/
protected updateViewMode(preference: 'tree' | 'list'): void {
this.viewMode = preference;
}
protected updateImmediately(): void {
this.onUpdateRequest(Widget.Msg.UpdateRequest);
}
protected override onUpdateRequest(msg: Message): void {
MessageLoop.sendMessage(this.diffHeaderWidget, msg);
MessageLoop.sendMessage(this.resourceWidget, msg);
super.onUpdateRequest(msg);
}
protected override onAfterAttach(msg: Message): void {
this.node.appendChild(this.diffHeaderWidget.node);
this.node.appendChild(this.resourceWidget.node);
super.onAfterAttach(msg);
this.update();
}
goToPreviousChange(): void {
this.resourceWidget.goToPreviousChange();
}
goToNextChange(): void {
this.resourceWidget.goToNextChange();
}
storeState(): object {
const state = {
commitState: this.diffHeaderWidget.storeState(),
changesTreeState: this.resourceWidget.storeState(),
};
return state;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restoreState(oldState: any): void {
const { commitState, changesTreeState } = oldState;
this.diffHeaderWidget.restoreState(commitState);
this.resourceWidget.restoreState(changesTreeState);
}
}

View File

@@ -0,0 +1,30 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 } from '@theia/core/shared/inversify';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { GitResourceOpener } from './git-resource-opener';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class GitOpenerInPrimaryArea implements GitResourceOpener {
@inject(EditorManager) protected readonly editorManager: EditorManager;
async open(changeUri: URI): Promise<void> {
await this.editorManager.open(changeUri, { mode: 'reveal' });
}
}

View File

@@ -0,0 +1,22 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 URI from '@theia/core/lib/common/uri';
export const GitResourceOpener = Symbol('GitResourceOpener');
export interface GitResourceOpener {
open(changeUri: URI): Promise<void>;
}

View File

@@ -0,0 +1,38 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 } from '@theia/core/shared/inversify';
import { DirtyDiffDecorator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-decorator';
import { DirtyDiffNavigator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-navigator';
import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser';
import { DirtyDiffManager } from './dirty-diff-manager';
@injectable()
export class DirtyDiffContribution implements FrontendApplicationContribution {
constructor(
@inject(DirtyDiffManager) protected readonly dirtyDiffManager: DirtyDiffManager,
@inject(DirtyDiffDecorator) protected readonly dirtyDiffDecorator: DirtyDiffDecorator,
@inject(DirtyDiffNavigator) protected readonly dirtyDiffNavigator: DirtyDiffNavigator,
) { }
onStart(app: FrontendApplication): void {
this.dirtyDiffManager.onDirtyDiffUpdate(update => {
this.dirtyDiffDecorator.applyDecorations(update);
this.dirtyDiffNavigator.handleDirtyDiffUpdate(update);
});
}
}

View File

@@ -0,0 +1,312 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { EditorManager, EditorWidget, TextEditor, TextEditorDocument, TextDocumentChangeEvent } from '@theia/editor/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { Emitter, Event, Disposable, DisposableCollection } from '@theia/core';
import { ContentLines } from '@theia/scm/lib/browser/dirty-diff/content-lines';
import { DirtyDiffUpdate } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-decorator';
import { DiffComputer, DirtyDiff } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
import { GitPreferences, GitConfiguration } from '../../common/git-preferences';
import { PreferenceChangeEvent } from '@theia/core/lib/common';
import { GIT_RESOURCE_SCHEME } from '../git-resource';
import { GitResourceResolver } from '../git-resource-resolver';
import { WorkingDirectoryStatus, GitFileStatus, GitFileChange, Repository, Git, GitStatusChangeEvent } from '../../common';
import { GitRepositoryTracker } from '../git-repository-tracker';
import throttle = require('@theia/core/shared/lodash.throttle');
@injectable()
export class DirtyDiffManager {
protected readonly models = new Map<string, DirtyDiffModel>();
protected readonly onDirtyDiffUpdateEmitter = new Emitter<DirtyDiffUpdate>();
readonly onDirtyDiffUpdate: Event<DirtyDiffUpdate> = this.onDirtyDiffUpdateEmitter.event;
@inject(Git)
protected readonly git: Git;
@inject(GitRepositoryTracker)
protected readonly repositoryTracker: GitRepositoryTracker;
@inject(GitResourceResolver)
protected readonly gitResourceResolver: GitResourceResolver;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(GitPreferences)
protected readonly preferences: GitPreferences;
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
this.editorManager.onCreated(async e => this.handleEditorCreated(e));
this.repositoryTracker.onGitEvent(throttle(async (event: GitStatusChangeEvent | undefined) =>
this.handleGitStatusUpdate(event && event.source, event && event.status), 500));
const gitStatus = this.repositoryTracker.selectedRepositoryStatus;
const repository = this.repositoryTracker.selectedRepository;
if (gitStatus && repository) {
await this.handleGitStatusUpdate(repository, gitStatus);
}
}
protected async handleEditorCreated(editorWidget: EditorWidget): Promise<void> {
const editor = editorWidget.editor;
if (!this.supportsDirtyDiff(editor)) {
return;
}
const toDispose = new DisposableCollection();
const model = this.createNewModel(editor);
toDispose.push(model);
const uri = editor.uri.toString();
this.models.set(uri, model);
toDispose.push(editor.onDocumentContentChanged(throttle((event: TextDocumentChangeEvent) => model.handleDocumentChanged(event.document), 1000)));
if (editor.onShouldDisplayDirtyDiffChanged) {
toDispose.push(editor.onShouldDisplayDirtyDiffChanged(() => model.update()));
}
editorWidget.disposed.connect(() => {
this.models.delete(uri);
toDispose.dispose();
});
const gitStatus = this.repositoryTracker.selectedRepositoryStatus;
const repository = this.repositoryTracker.selectedRepository;
if (gitStatus && repository) {
const changes = gitStatus.changes.filter(c => c.uri === uri);
await model.handleGitStatusUpdate(repository, changes);
}
model.handleDocumentChanged(editor.document);
}
protected supportsDirtyDiff(editor: TextEditor): boolean {
return editor.uri.scheme === 'file' && (editor.shouldDisplayDirtyDiff() || !!editor.onShouldDisplayDirtyDiffChanged);
}
protected createNewModel(editor: TextEditor): DirtyDiffModel {
const previousRevision = this.createPreviousFileRevision(editor.uri);
const model = new DirtyDiffModel(editor, this.preferences, previousRevision);
model.onDirtyDiffUpdate(e => this.onDirtyDiffUpdateEmitter.fire(e));
return model;
}
protected createPreviousFileRevision(fileUri: URI): DirtyDiffModel.PreviousFileRevision {
const getOriginalUri = (staged: boolean): URI => {
const query = staged ? '' : 'HEAD';
return fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
};
return <DirtyDiffModel.PreviousFileRevision>{
fileUri,
getContents: async (staged: boolean) => {
const uri = getOriginalUri(staged);
const gitResource = await this.gitResourceResolver.getResource(uri);
return gitResource.readContents();
},
isVersionControlled: async () => {
const repository = this.repositoryTracker.selectedRepository;
if (repository) {
return this.git.lsFiles(repository, fileUri.toString(), { errorUnmatch: true });
}
return false;
},
getOriginalUri
};
}
protected async handleGitStatusUpdate(repository: Repository | undefined, status: WorkingDirectoryStatus | undefined): Promise<void> {
const uris = new Set(this.models.keys());
const relevantChanges = status ? status.changes.filter(c => uris.has(c.uri)) : [];
for (const model of this.models.values()) {
const uri = model.editor.uri.toString();
const changes = relevantChanges.filter(c => c.uri === uri);
await model.handleGitStatusUpdate(repository, changes);
}
}
}
export class DirtyDiffModel implements Disposable {
protected toDispose = new DisposableCollection();
protected enabled = true;
protected staged: boolean;
protected previousContent: DirtyDiffModel.PreviousRevisionContent | undefined;
protected currentContent: ContentLines | undefined;
protected readonly onDirtyDiffUpdateEmitter = new Emitter<DirtyDiffUpdate>();
readonly onDirtyDiffUpdate: Event<DirtyDiffUpdate> = this.onDirtyDiffUpdateEmitter.event;
constructor(
readonly editor: TextEditor,
readonly preferences: GitPreferences,
protected readonly previousRevision: DirtyDiffModel.PreviousFileRevision
) {
this.toDispose.push(this.preferences.onPreferenceChanged(e => this.handlePreferenceChange(e)));
}
protected async handlePreferenceChange(event: PreferenceChangeEvent<GitConfiguration>): Promise<void> {
const { preferenceName } = event;
if (preferenceName === 'git.editor.decorations.enabled') {
this.enabled = !!this.preferences.get('git.editor.decorations.enabled');
this.update();
}
if (preferenceName === 'git.editor.dirtyDiff.linesLimit') {
this.update();
}
}
protected get linesLimit(): number {
const limit = this.preferences['git.editor.dirtyDiff.linesLimit'];
return limit > 0 ? limit : Number.MAX_SAFE_INTEGER;
}
protected updateTimeout: number | undefined;
protected shouldRender(editor: TextEditor): boolean {
if (!this.enabled || !this.previousContent || !this.currentContent || !editor.shouldDisplayDirtyDiff()) {
return false;
}
const limit = this.linesLimit;
return this.previousContent.length < limit && this.currentContent.length < limit;
}
update(): void {
const editor = this.editor;
if (!this.shouldRender(editor)) {
this.onDirtyDiffUpdateEmitter.fire({ editor, changes: [] });
return;
}
if (this.updateTimeout) {
window.clearTimeout(this.updateTimeout);
}
this.updateTimeout = window.setTimeout(() => {
this.updateTimeout = undefined;
if (!this.shouldRender(editor)) {
return;
}
const previous = this.previousContent!;
const current = this.currentContent!;
const dirtyDiff = DirtyDiffModel.computeDirtyDiff(previous, current);
if (!dirtyDiff) {
// if the computation fails, it might be because of changes in the editor, in that case
// a new update task should be scheduled anyway.
return;
}
const dirtyDiffUpdate = <DirtyDiffUpdate>{ editor, previousRevisionUri: previous.uri, ...dirtyDiff };
this.onDirtyDiffUpdateEmitter.fire(dirtyDiffUpdate);
}, 100);
}
handleDocumentChanged(document: TextEditorDocument): void {
if (this.toDispose.disposed) {
return;
}
this.currentContent = DirtyDiffModel.documentContentLines(document);
this.update();
}
async handleGitStatusUpdate(repository: Repository | undefined, relevantChanges: GitFileChange[]): Promise<void> {
const noRelevantChanges = relevantChanges.length === 0;
const isNewAndStaged = relevantChanges.some(c => c.status === GitFileStatus.New && !!c.staged);
const isNewAndUnstaged = relevantChanges.some(c => c.status === GitFileStatus.New && !c.staged);
const modifiedChange = relevantChanges.find(c => c.status === GitFileStatus.Modified);
const isModified = !!modifiedChange;
const readPreviousRevisionContent = async () => {
try {
this.previousContent = await this.getPreviousRevisionContent();
} catch {
this.previousContent = undefined;
}
};
if (isModified || isNewAndStaged) {
this.staged = isNewAndStaged || modifiedChange!.staged || false;
await readPreviousRevisionContent();
}
if (isNewAndUnstaged && !isNewAndStaged) {
this.previousContent = undefined;
}
if (noRelevantChanges) {
const inGitRepository = await this.isInGitRepository(repository);
if (inGitRepository) {
await readPreviousRevisionContent();
}
}
this.update();
}
protected async isInGitRepository(repository: Repository | undefined): Promise<boolean> {
if (!repository) {
return false;
}
const modelUri = this.editor.uri.withScheme('file').toString();
const repoUri = new URI(repository.localUri).withScheme('file').toString();
return modelUri.startsWith(repoUri) && this.previousRevision.isVersionControlled();
}
protected async getPreviousRevisionContent(): Promise<DirtyDiffModel.PreviousRevisionContent | undefined> {
const { previousRevision, staged } = this;
const contents = await previousRevision.getContents(staged);
if (contents) {
const uri = previousRevision.getOriginalUri?.(staged);
return { ...ContentLines.fromString(contents), uri };
}
}
dispose(): void {
this.toDispose.dispose();
this.onDirtyDiffUpdateEmitter.dispose();
}
}
export namespace DirtyDiffModel {
const diffComputer = new DiffComputer();
/**
* Returns an eventually consistent result. E.g. it can happen, that lines are deleted during the computation,
* which will internally produce 'line out of bound' errors, then it will return `undefined`.
*
* `ContentLines` are to avoid copying contents which improves the performance, therefore handling of the `undefined`
* result, and rescheduling of the computation should be done by caller.
*/
export function computeDirtyDiff(previous: ContentLines, current: ContentLines): DirtyDiff | undefined {
try {
return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current));
} catch {
return undefined;
}
}
export function documentContentLines(document: TextEditorDocument): ContentLines {
return ContentLines.fromTextEditorDocument(document);
}
export interface PreviousFileRevision {
readonly fileUri: URI;
getContents(staged: boolean): Promise<string>;
isVersionControlled(): Promise<boolean>;
getOriginalUri?(staged: boolean): URI;
}
export interface PreviousRevisionContent extends ContentLines {
readonly uri?: URI;
}
}

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { interfaces } from '@theia/core/shared/inversify';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { DirtyDiffContribution } from './dirty-diff-contribution';
import { DirtyDiffManager } from './dirty-diff-manager';
export function bindDirtyDiff(bind: interfaces.Bind): void {
bind(DirtyDiffManager).toSelf().inSingletonScope();
bind(DirtyDiffContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(DirtyDiffContribution);
}

View File

@@ -0,0 +1,87 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable } from '@theia/core/shared/inversify';
import { MaybePromise } from '@theia/core/lib/common/types';
import { ScmInputIssueType } from '@theia/scm/lib/browser/scm-input';
@injectable()
export class GitCommitMessageValidator {
static readonly MAX_CHARS_PER_LINE = 72;
/**
* Validates the input and returns with either a validation result with the status and message, or `undefined` if everything went fine.
*/
validate(input: string | undefined): MaybePromise<GitCommitMessageValidator.Result | undefined> {
if (input) {
const lines = input.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const result = this.isLineValid(line, i);
if (!!result) {
return result;
}
}
}
return undefined;
}
protected isLineValid(line: string, index: number): GitCommitMessageValidator.Result | undefined {
if (index === 1 && line.length !== 0) {
return {
status: ScmInputIssueType.Warning,
message: 'The second line should be empty to separate the commit message from the body'
};
}
const diff = line.length - this.maxCharsPerLine();
if (diff > 0) {
return {
status: ScmInputIssueType.Warning,
message: `${diff} characters over ${this.maxCharsPerLine()} in current line`
};
}
return undefined;
}
protected maxCharsPerLine(): number {
return GitCommitMessageValidator.MAX_CHARS_PER_LINE;
}
}
export namespace GitCommitMessageValidator {
/**
* Type for the validation result with a status and a corresponding message.
*/
export type Result = Readonly<{ message: string, status: ScmInputIssueType }>;
export namespace Result {
/**
* `true` if the `message` and the `status` properties are the same on both `left` and `right`. Or both arguments are `undefined`. Otherwise, `false`.
*/
export function equal(left: Result | undefined, right: Result | undefined): boolean {
if (left && right) {
return left.message === right.message && left.status === right.status;
}
return left === right;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
// *****************************************************************************
// Copyright (C) 2020 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 { GitFileChange, GitFileStatus, GitStatusChangeEvent } from '../common';
import { CancellationToken, Emitter, Event, PreferenceChangeEvent } from '@theia/core/lib/common';
import { Decoration, DecorationsProvider } from '@theia/core/lib/browser/decorations-service';
import { GitRepositoryTracker } from './git-repository-tracker';
import URI from '@theia/core/lib/common/uri';
import { GitConfiguration, GitPreferences } from '../common/git-preferences';
@injectable()
export class GitDecorationProvider implements DecorationsProvider {
@inject(GitPreferences) protected readonly preferences: GitPreferences;
@inject(GitRepositoryTracker) protected readonly gitRepositoryTracker: GitRepositoryTracker;
protected decorationsEnabled: boolean;
protected colorsEnabled: boolean;
protected decorations = new Map<string, Decoration>();
protected uris = new Set<string>();
private readonly onDidChangeDecorationsEmitter = new Emitter<URI[]>();
readonly onDidChange: Event<URI[]> = this.onDidChangeDecorationsEmitter.event;
@postConstruct()
protected init(): void {
this.decorationsEnabled = this.preferences['git.decorations.enabled'];
this.colorsEnabled = this.preferences['git.decorations.colors'];
this.gitRepositoryTracker.onGitEvent((event: GitStatusChangeEvent | undefined) => this.handleGitEvent(event));
this.preferences.onPreferenceChanged((event: PreferenceChangeEvent<GitConfiguration>) => this.handlePreferenceChange(event));
}
protected async handleGitEvent(event: GitStatusChangeEvent | undefined): Promise<void> {
this.updateDecorations(event);
this.triggerDecorationChange();
}
protected updateDecorations(event?: GitStatusChangeEvent): void {
if (!event) {
return;
}
const newDecorations = new Map<string, Decoration>();
this.collectDecorationData(event.status.changes, newDecorations);
this.uris = new Set([...this.decorations.keys()].concat([...newDecorations.keys()]));
this.decorations = newDecorations;
}
protected collectDecorationData(changes: GitFileChange[], bucket: Map<string, Decoration>): void {
changes.forEach(change => {
const color = GitFileStatus.getColor(change.status, change.staged);
bucket.set(change.uri, {
bubble: true,
colorId: color.substring(12, color.length - 1).replace(/-/g, '.'),
tooltip: GitFileStatus.toString(change.status),
letter: GitFileStatus.toAbbreviation(change.status, change.staged)
});
});
}
provideDecorations(uri: URI, token: CancellationToken): Decoration | Promise<Decoration | undefined> | undefined {
if (this.decorationsEnabled) {
const decoration = this.decorations.get(uri.toString());
if (decoration && !this.colorsEnabled) {
// Remove decoration color if disabled.
return {
...decoration,
colorId: undefined
};
}
return decoration;
}
return undefined;
}
protected handlePreferenceChange(event: PreferenceChangeEvent<GitConfiguration>): void {
const { preferenceName } = event;
let updateDecorations = false;
if (preferenceName === 'git.decorations.enabled') {
updateDecorations = true;
const decorationsEnabled = !!this.preferences.get('git.decorations.enabled');
if (this.decorationsEnabled !== decorationsEnabled) {
this.decorationsEnabled = decorationsEnabled;
}
}
if (preferenceName === 'git.decorations.colors') {
updateDecorations = true;
const colorsEnabled = !!this.preferences.get('git.decorations.colors');
if (this.colorsEnabled !== colorsEnabled) {
this.colorsEnabled = colorsEnabled;
}
}
if (updateDecorations) {
this.triggerDecorationChange();
}
}
/**
* Notify that the provider has been updated to trigger a re-render of decorations.
*/
protected triggerDecorationChange(): void {
this.onDidChangeDecorationsEmitter.fire(Array.from(this.uris, value => new URI(value)));
}
}

View File

@@ -0,0 +1,33 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { MessageService } from '@theia/core';
@injectable()
export class GitErrorHandler {
@inject(MessageService) protected readonly messageService: MessageService;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public handleError(error: any): void {
const message = error instanceof Error ? error.message : error;
if (message) {
this.messageService.error(message, { timeout: 0 });
}
}
}

View File

@@ -0,0 +1,33 @@
// *****************************************************************************
// Copyright (C) 2024 1C-Soft LLC 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 { interfaces } from '@theia/core/shared/inversify';
import { FileService, FileServiceContribution } from '@theia/filesystem/lib/browser/file-service';
import { GitFileSystemProvider } from './git-file-system-provider';
import { GIT_RESOURCE_SCHEME } from './git-resource';
export class GitFileServiceContribution implements FileServiceContribution {
constructor(protected readonly container: interfaces.Container) { }
registerFileSystemProviders(service: FileService): void {
service.onWillActivateFileSystemProvider(event => {
if (event.scheme === GIT_RESOURCE_SCHEME) {
service.registerProvider(GIT_RESOURCE_SCHEME, this.container.get(GitFileSystemProvider));
}
});
}
}

View File

@@ -0,0 +1,94 @@
// *****************************************************************************
// Copyright (C) 2024 1C-Soft LLC 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 } from '@theia/core/shared/inversify';
import { Event, URI, Disposable } from '@theia/core';
import {
FileChange,
FileDeleteOptions,
FileOverwriteOptions,
FileSystemProvider,
FileSystemProviderCapabilities,
FileType,
FileWriteOptions,
Stat,
WatchOptions
} from '@theia/filesystem/lib/common/files';
import { GitResourceResolver } from './git-resource-resolver';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
@injectable()
export class GitFileSystemProvider implements FileSystemProvider {
readonly capabilities = FileSystemProviderCapabilities.Readonly |
FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive;
readonly onDidChangeCapabilities: Event<void> = Event.None;
readonly onDidChangeFile: Event<readonly FileChange[]> = Event.None;
readonly onFileWatchError: Event<void> = Event.None;
@inject(GitResourceResolver)
protected readonly resourceResolver: GitResourceResolver;
@inject(EncodingService)
protected readonly encodingService: EncodingService;
watch(resource: URI, opts: WatchOptions): Disposable {
return Disposable.NULL;
}
async stat(resource: URI): Promise<Stat> {
const gitResource = await this.resourceResolver.getResource(resource);
let size = 0;
try {
size = await gitResource.getSize();
} catch (e) {
console.error(e);
}
return { type: FileType.File, mtime: 0, ctime: 0, size };
}
async readFile(resource: URI): Promise<Uint8Array> {
const gitResource = await this.resourceResolver.getResource(resource);
let contents = '';
try {
contents = await gitResource.readContents({ encoding: 'binary' });
} catch (e) {
console.error(e);
}
return this.encodingService.encode(contents, { encoding: 'binary', hasBOM: false }).buffer;
}
writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
throw new Error('Method not implemented.');
}
mkdir(resource: URI): Promise<void> {
throw new Error('Method not implemented.');
}
readdir(resource: URI): Promise<[string, FileType][]> {
throw new Error('Method not implemented.');
}
delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
throw new Error('Method not implemented.');
}
rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,99 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 '../../src/browser/style/index.css';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { CommandContribution, MenuContribution, ResourceResolver } from '@theia/core/lib/common';
import {
WebSocketConnectionProvider,
FrontendApplicationContribution,
} from '@theia/core/lib/browser';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { Git, GitPath, GitWatcher, GitWatcherPath, GitWatcherServer, GitWatcherServerProxy, ReconnectingGitWatcherServer } from '../common';
import { GitContribution } from './git-contribution';
import { bindGitDiffModule } from './diff/git-diff-frontend-module';
import { bindGitHistoryModule } from './history/git-history-frontend-module';
import { GitResourceResolver } from './git-resource-resolver';
import { GitRepositoryProvider } from './git-repository-provider';
import { GitQuickOpenService } from './git-quick-open-service';
import { bindGitPreferences } from '../common/git-preferences';
import { bindDirtyDiff } from './dirty-diff/dirty-diff-module';
import { bindBlame } from './blame/blame-module';
import { GitRepositoryTracker } from './git-repository-tracker';
import { GitCommitMessageValidator } from './git-commit-message-validator';
import { GitSyncService } from './git-sync-service';
import { GitErrorHandler } from './git-error-handler';
import { GitScmProvider, GitScmProviderOptions } from './git-scm-provider';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { ScmHistorySupport } from '@theia/scm-extra/lib/browser/history/scm-history-widget';
import { ScmHistoryProvider } from '@theia/scm-extra/lib/browser/history';
import { GitHistorySupport } from './history/git-history-support';
import { GitDecorationProvider } from './git-decoration-provider';
import { GitFileSystemProvider } from './git-file-system-provider';
import { GitFileServiceContribution } from './git-file-service-contribution';
import { FileServiceContribution } from '@theia/filesystem/lib/browser/file-service';
export default new ContainerModule(bind => {
bindGitPreferences(bind);
bindGitDiffModule(bind);
bindGitHistoryModule(bind);
bindDirtyDiff(bind);
bindBlame(bind);
bind(GitRepositoryTracker).toSelf().inSingletonScope();
bind(GitWatcherServerProxy).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, GitWatcherPath)).inSingletonScope();
bind(GitWatcherServer).to(ReconnectingGitWatcherServer).inSingletonScope();
bind(GitWatcher).toSelf().inSingletonScope();
bind(Git).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, GitPath)).inSingletonScope();
bind(GitContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(GitContribution);
bind(MenuContribution).toService(GitContribution);
bind(FrontendApplicationContribution).toService(GitContribution);
bind(TabBarToolbarContribution).toService(GitContribution);
bind(ColorContribution).toService(GitContribution);
bind(GitResourceResolver).toSelf().inSingletonScope();
bind(ResourceResolver).toService(GitResourceResolver);
bind(GitScmProvider.Factory).toFactory(createGitScmProviderFactory);
bind(GitRepositoryProvider).toSelf().inSingletonScope();
bind(GitDecorationProvider).toSelf().inSingletonScope();
bind(GitQuickOpenService).toSelf().inSingletonScope();
bind(GitCommitMessageValidator).toSelf().inSingletonScope();
bind(GitSyncService).toSelf().inSingletonScope();
bind(GitErrorHandler).toSelf().inSingletonScope();
bind(GitFileSystemProvider).toSelf().inSingletonScope();
bind(GitFileServiceContribution).toDynamicValue(ctx => new GitFileServiceContribution(ctx.container)).inSingletonScope();
bind(FileServiceContribution).toService(GitFileServiceContribution);
});
export function createGitScmProviderFactory(ctx: interfaces.Context): GitScmProvider.Factory {
return (options: GitScmProviderOptions) => {
const container = ctx.container.createChild();
container.bind(GitScmProviderOptions).toConstantValue(options);
container.bind(GitScmProvider).toSelf().inSingletonScope();
container.bind(GitHistorySupport).toSelf().inSingletonScope();
container.bind(ScmHistorySupport).toService(GitHistorySupport);
const provider = container.get(GitScmProvider);
const historySupport = container.get(GitHistorySupport);
(provider as ScmHistoryProvider).historySupport = historySupport;
return provider;
};
}

View File

@@ -0,0 +1,602 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject, optional } from '@theia/core/shared/inversify';
import { Git, Repository, Branch, BranchType, Tag, Remote, StashEntry } from '../common';
import { GitRepositoryProvider } from './git-repository-provider';
import { MessageService } from '@theia/core/lib/common/message-service';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { GitErrorHandler } from './git-error-handler';
import { ProgressService } from '@theia/core/lib/common/progress-service';
import URI from '@theia/core/lib/common/uri';
import { nls } from '@theia/core/lib/common/nls';
import { LabelProvider, QuickInputService, QuickPick, QuickPickItem } from '@theia/core/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
export enum GitAction {
PULL,
PUSH
}
/**
* Service delegating into the `Quick Input Service`, so that the Git commands can be further refined.
* For instance, the `remote` can be specified for `pull`, `push`, and `fetch`. And the branch can be
* specified for `git merge`.
*/
@injectable()
export class GitQuickOpenService {
@inject(GitErrorHandler) protected readonly gitErrorHandler: GitErrorHandler;
@inject(ProgressService) protected readonly progressService: ProgressService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(Git) protected readonly git: Git;
@inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider;
@inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
@inject(MessageService) protected readonly messageService: MessageService;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(FileService) protected readonly fileService: FileService;
async clone(url?: string, folder?: string, branch?: string): Promise<string | undefined> {
return this.withProgress(async () => {
if (!folder) {
const roots = await this.workspaceService.roots;
folder = roots[0].resource.toString();
}
if (url) {
const repo = await this.git.clone(
url,
{
localUri: await this.buildDefaultProjectPath(folder, url),
branch: branch
});
return repo.localUri;
}
this.quickInputService?.showQuickPick(
[
new GitQuickPickItem(
nls.localize('theia/git/cloneQuickInputLabel', 'Please provide a Git repository location. Press \'Enter\' to confirm or \'Escape\' to cancel.')
)
],
{
placeholder: nls.localize('vscode.git/dist/commands/selectFolder', 'Select Repository Location'),
onDidChangeValue: (quickPick: QuickPick<QuickPickItem>, filter: string) => this.query(quickPick, filter, folder)
});
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private query(quickPick: any, filter: string, folder: any): void {
quickPick.busy = true;
const { git, buildDefaultProjectPath, gitErrorHandler, wrapWithProgress } = this;
try {
if (filter === undefined || filter.length === 0) {
quickPick.items = [
new GitQuickPickItem(
nls.localize('theia/git/cloneQuickInputLabel', 'Please provide a Git repository location. Press \'Enter\' to confirm or \'Escape\' to cancel.')
)
];
} else {
quickPick.items = [
new GitQuickPickItem(
nls.localize(
'theia/git/cloneRepository',
'Clone the Git repository: {0}. Press \'Enter\' to confirm or \'Escape\' to cancel.',
filter
),
wrapWithProgress(async () => {
try {
await git.clone(filter, { localUri: await buildDefaultProjectPath(folder, filter) });
} catch (error) {
gitErrorHandler.handleError(error);
}
}))
];
}
} catch (err) {
quickPick.items = [new GitQuickPickItem('$(error) ' + nls.localizeByDefault('Error: {0}', err.message))];
console.error(err);
} finally {
quickPick.busy = false;
}
}
private buildDefaultProjectPath = this.doBuildDefaultProjectPath.bind(this);
private async doBuildDefaultProjectPath(folderPath: string, gitURI: string): Promise<string> {
if (!(await this.fileService.exists(new URI(folderPath)))) {
// user specifies its own project path, doesn't want us to guess it
return folderPath;
}
const uriSplitted = gitURI.split('/');
let projectPath = folderPath + '/' + (uriSplitted.pop() || uriSplitted.pop());
if (projectPath.endsWith('.git')) {
projectPath = projectPath.substring(0, projectPath.length - '.git'.length);
}
return projectPath;
}
async fetch(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const remotes = await this.getRemotes();
const execute = async (item: GitQuickPickItem<Remote>) => {
try {
await this.git.fetch(repository, { remote: item.ref!.name });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
};
const items = remotes.map(remote => new GitQuickPickItem<Remote>(remote.name, execute, remote, remote.fetch));
this.quickInputService?.showQuickPick(items, { placeholder: nls.localize('theia/git/fetchPickRemote', 'Pick a remote to fetch from:') });
});
}
async performDefaultGitAction(action: GitAction): Promise<void> {
const remote = await this.getRemotes();
const defaultRemote = remote[0]?.name;
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
try {
if (action === GitAction.PULL) {
await this.git.pull(repository, { remote: defaultRemote });
console.log(`Git Pull: successfully completed from ${defaultRemote}.`);
} else if (action === GitAction.PUSH) {
await this.git.push(repository, { remote: defaultRemote, setUpstream: true });
console.log(`Git Push: successfully completed to ${defaultRemote}.`);
}
} catch (error) {
this.gitErrorHandler.handleError(error);
}
});
}
async push(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const [remotes, currentBranch] = await Promise.all([this.getRemotes(), this.getCurrentBranch()]);
const execute = async (item: GitQuickPickItem<Remote>) => {
try {
await this.git.push(repository, { remote: item.label, setUpstream: true });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
};
const items = remotes.map(remote => new GitQuickPickItem<Remote>(remote.name, execute, remote, remote.push));
const branchName = currentBranch ? `'${currentBranch.name}' ` : '';
this.quickInputService?.showQuickPick(items, {
placeholder: nls.localize('vscode.git/dist/commands/pick remote', "Pick a remote to publish the branch '{0}' to:", branchName)
});
});
}
async pull(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const remotes = await this.getRemotes();
const defaultRemote = remotes[0].name; // I wish I could use assignment destructuring here. (GH-413)
const executeRemote = async (remoteItem: GitQuickPickItem<Remote>) => {
// The first remote is the default.
if (remoteItem.ref!.name === defaultRemote) {
try {
await this.git.pull(repository, { remote: remoteItem.label });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
} else {
// Otherwise we need to propose the branches from
const branches = await this.getBranches();
const executeBranch = async (branchItem: GitQuickPickItem<Branch>) => {
try {
await this.git.pull(repository, { remote: remoteItem.ref!.name, branch: branchItem.ref!.nameWithoutRemote });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
};
const branchItems = branches
.filter(branch => branch.type === BranchType.Remote)
.filter(branch => (branch.name || '').startsWith(`${remoteItem.label}/`))
.map(branch => new GitQuickPickItem(branch.name, executeBranch, branch));
this.quickInputService?.showQuickPick(branchItems, {
placeholder: nls.localize('vscode.git/dist/commands/pick branch pull', 'Pick a branch to pull from')
});
}
};
const remoteItems = remotes.map(remote => new GitQuickPickItem(remote.name, executeRemote, remote, remote.fetch));
this.quickInputService?.showQuickPick(remoteItems, {
placeholder: nls.localize('vscode.git/dist/commands/pick remote pull repo', 'Pick a remote to pull the branch from')
});
});
}
async merge(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const [branches, currentBranch] = await Promise.all([this.getBranches(), this.getCurrentBranch()]);
const execute = async (item: GitQuickPickItem<Branch>) => {
try {
await this.git.merge(repository, { branch: item.label });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
};
const items = branches.map(branch => new GitQuickPickItem<Branch>(branch.name, execute, branch));
const branchName = currentBranch ? `'${currentBranch.name}' ` : '';
this.quickInputService?.showQuickPick(
items,
{
placeholder: nls.localize('theia/git/mergeQuickPickPlaceholder', 'Pick a branch to merge into the currently active {0} branch:', branchName)
}
);
});
}
async checkout(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const [branches, currentBranch] = await Promise.all([this.getBranches(), this.getCurrentBranch()]);
if (currentBranch) {
// We do not show the current branch.
const index = branches.findIndex(branch => branch && branch.name === currentBranch.name);
branches.splice(index, 1);
}
const switchBranch = async (item: GitQuickPickItem<Branch>) => {
try {
await this.git.checkout(repository, { branch: item.ref!.nameWithoutRemote });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
};
const items = branches.map(branch => new GitQuickPickItem<Branch>(
branch.type === BranchType.Remote ? branch.name : branch.nameWithoutRemote, switchBranch,
branch,
branch.type === BranchType.Remote
? nls.localize('vscode.git/dist/commands/remote branch at', 'Remote branch at {0}', (branch.tip.sha.length > 8 ? ` ${branch.tip.sha.slice(0, 7)}` : ''))
: (branch.tip.sha.length > 8 ? ` ${branch.tip.sha.slice(0, 7)}` : '')));
const createBranchItem = async <T>() => {
const { git, gitErrorHandler, wrapWithProgress } = this;
const getItems = (lookFor?: string) => {
const dynamicItems: GitQuickPickItem<T>[] = [];
if (lookFor === undefined || lookFor.length === 0) {
dynamicItems.push(new GitQuickPickItem(
nls.localize('theia/git/checkoutProvideBranchName', 'Please provide a branch name. '),
() => { })
);
} else {
dynamicItems.push(new GitQuickPickItem(
nls.localize(
'theia/git/checkoutCreateLocalBranchWithName',
"Create a new local branch with name: {0}. Press 'Enter' to confirm or 'Escape' to cancel.",
lookFor
),
wrapWithProgress(async () => {
try {
await git.branch(repository, { toCreate: lookFor });
await git.checkout(repository, { branch: lookFor });
} catch (error) {
gitErrorHandler.handleError(error);
}
})
));
}
return dynamicItems;
};
this.quickInputService?.showQuickPick(getItems(), {
placeholder: nls.localize('vscode.git/dist/commands/branch name', 'Branch name'),
onDidChangeValue: (quickPick: QuickPick<QuickPickItem>, filter: string) => {
quickPick.items = getItems(filter);
}
});
};
items.unshift(new GitQuickPickItem(nls.localize('vscode.git/dist/commands/create branch', 'Create new branch...'), createBranchItem));
this.quickInputService?.showQuickPick(items, { placeholder: nls.localize('theia/git/checkoutSelectRef', 'Select a ref to checkout or create a new local branch:') });
});
}
async chooseTagsAndBranches(execFunc: (branchName: string, currentBranchName: string) => void, repository: Repository | undefined = this.getRepository()): Promise<void> {
if (!repository) {
return;
}
return this.withProgress(async () => {
const [branches, tags, currentBranch] = await Promise.all([this.getBranches(repository), this.getTags(repository), this.getCurrentBranch(repository)]);
const execute = async (item: GitQuickPickItem<Branch | Tag>) => {
execFunc(item.ref!.name, currentBranch ? currentBranch.name : '');
};
const branchItems = branches.map(branch => new GitQuickPickItem<Branch>(branch.name, execute, branch));
const branchName = currentBranch ? `'${currentBranch.name}' ` : '';
const tagItems = tags.map(tag => new GitQuickPickItem<Tag>(tag.name, execute, tag));
this.quickInputService?.showQuickPick([...branchItems, ...tagItems],
{ placeholder: nls.localize('theia/git/compareWithBranchOrTag', 'Pick a branch or tag to compare with the currently active {0} branch:', branchName) });
});
}
async commitMessageForAmend(): Promise<string> {
const repository = this.getRepository();
if (!repository) {
throw new Error(nls.localize('theia/git/noRepositoriesSelected', 'No repositories were selected.'));
}
return this.withProgress(async () => {
const lastMessage = (await this.git.exec(repository, ['log', '--format=%B', '-n', '1'])).stdout.trim();
if (lastMessage.length === 0) {
throw new Error(nls.localize('theia/git/repositoryNotInitialized', 'Repository {0} is not yet initialized.', repository.localUri));
}
const message = lastMessage.replace(/[\r\n]+/g, ' ');
const result = await new Promise<string>(async (resolve, reject) => {
const getItems = (lookFor?: string) => {
const items = [];
if (!lookFor) {
const label = nls.localize('theia/git/amendReuseMessage', "To reuse the last commit message, press 'Enter' or 'Escape' to cancel.");
items.push(new GitQuickPickItem(label, () => resolve(lastMessage), label));
} else {
items.push(new GitQuickPickItem(
nls.localize('theia/git/amendRewrite', "Rewrite previous commit message. Press 'Enter' to confirm or 'Escape' to cancel."),
() => resolve(lookFor))
);
}
return items;
};
const updateItems = (quickPick: QuickPick<QuickPickItem>, filter: string) => {
quickPick.items = getItems(filter);
};
this.quickInputService?.showQuickPick(getItems(), { placeholder: message, onDidChangeValue: updateItems });
});
return result;
});
}
async stash(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const doStash = this.wrapWithProgress(async (message: string) => {
this.git.stash(repository, { message });
});
const getItems = (lookFor?: string) => {
const items = [];
if (lookFor === undefined || lookFor.length === 0) {
items.push(new GitQuickPickItem(nls.localize('theia/git/stashChanges', "Stash changes. Press 'Enter' to confirm or 'Escape' to cancel."), () => doStash('')));
} else {
items.push(new GitQuickPickItem(
nls.localize('theia/git/stashChangesWithMessage', "Stash changes with message: {0}. Press 'Enter' to confirm or 'Escape' to cancel.", lookFor),
() => doStash(lookFor))
);
}
return items;
};
const updateItems = (quickPick: QuickPick<QuickPickItem>, filter: string) => {
quickPick.items = getItems(filter);
};
this.quickInputService?.showQuickPick(getItems(), {
placeholder: nls.localize('vscode.git/dist/commands/stash message', 'Stash message'), onDidChangeValue: updateItems
});
});
}
protected async doStashAction(action: 'pop' | 'apply' | 'drop', text: string, getMessage?: () => Promise<string>): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
const list = await this.git.stash(repository, { action: 'list' });
if (list) {
const items = list.map(stash => new GitQuickPickItem<StashEntry>(stash.message,
this.wrapWithProgress(async () => {
try {
await this.git.stash(repository, { action, id: stash.id });
if (getMessage) {
this.messageService.info(await getMessage());
}
} catch (error) {
this.gitErrorHandler.handleError(error);
}
})));
this.quickInputService?.showQuickPick(items, { placeholder: text });
}
});
}
async applyStash(): Promise<void> {
this.doStashAction('apply', nls.localize('vscode.git/dist/commands/pick stash to apply', 'Pick a stash to apply'));
}
async popStash(): Promise<void> {
this.doStashAction('pop', nls.localize('vscode.git/dist/commands/pick stash to pop', 'Pick a stash to pop'));
}
async dropStash(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
this.doStashAction(
'drop',
nls.localize('vscode.git/dist/commands/pick stash to drop', 'Pick a stash to drop'),
async () => nls.localize('theia/git/dropStashMessage', 'Stash successfully removed.')
);
}
async applyLatestStash(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
try {
await this.git.stash(repository, {
action: 'apply'
});
} catch (error) {
this.gitErrorHandler.handleError(error);
}
});
}
async popLatestStash(): Promise<void> {
const repository = this.getRepository();
if (!repository) {
return;
}
return this.withProgress(async () => {
try {
await this.git.stash(repository, {
action: 'pop'
});
} catch (error) {
this.gitErrorHandler.handleError(error);
}
});
}
async initRepository(): Promise<void> {
const wsRoots = await this.workspaceService.roots;
if (wsRoots && wsRoots.length > 1) {
const items = wsRoots.map<GitQuickPickItem<URI>>(root => this.toRepositoryPathQuickOpenItem(root));
this.quickInputService?.showQuickPick(items, { placeholder: nls.localize('vscode.git/dist/commands/init', 'Pick workspace folder to initialize git repo in') });
} else {
const rootUri = wsRoots[0].resource;
this.doInitRepository(rootUri.toString());
}
}
private async doInitRepository(uri: string): Promise<void> {
this.withProgress(async () => this.git.exec({ localUri: uri }, ['init']));
}
private toRepositoryPathQuickOpenItem(root: FileStat): GitQuickPickItem<URI> {
const rootUri = root.resource;
const execute = async (item: GitQuickPickItem<URI>) => {
const wsRoot = item.ref!.toString();
this.doInitRepository(wsRoot);
};
return new GitQuickPickItem<URI>(this.labelProvider.getName(rootUri), execute, rootUri, this.labelProvider.getLongName(rootUri.parent));
}
private getRepository(): Repository | undefined {
return this.repositoryProvider.selectedRepository;
}
private async getRemotes(): Promise<Remote[]> {
const repository = this.getRepository();
if (!repository) {
return [];
}
return this.withProgress(async () => {
try {
return await this.git.remote(repository, { verbose: true });
} catch (error) {
this.gitErrorHandler.handleError(error);
return [];
}
});
}
private async getTags(repository: Repository | undefined = this.getRepository()): Promise<Tag[]> {
if (!repository) {
return [];
}
return this.withProgress(async () => {
const result = await this.git.exec(repository, ['tag', '--sort=-creatordate']);
return result.stdout !== '' ? result.stdout.trim().split('\n').map(tag => ({ name: tag })) : [];
});
}
private async getBranches(repository: Repository | undefined = this.getRepository()): Promise<Branch[]> {
if (!repository) {
return [];
}
return this.withProgress(async () => {
try {
const [local, remote] = await Promise.all([
this.git.branch(repository, { type: 'local' }),
this.git.branch(repository, { type: 'remote' })
]);
return [...local, ...remote];
} catch (error) {
this.gitErrorHandler.handleError(error);
return [];
}
});
}
private async getCurrentBranch(repository: Repository | undefined = this.getRepository()): Promise<Branch | undefined> {
if (!repository) {
return undefined;
}
return this.withProgress(async () => {
try {
return await this.git.branch(repository, { type: 'current' });
} catch (error) {
this.gitErrorHandler.handleError(error);
return undefined;
}
});
}
protected withProgress<In, Out>(fn: (...arg: In[]) => Promise<Out>): Promise<Out> {
return this.progressService.withProgress('', 'scm', fn);
}
protected readonly wrapWithProgress = <In, Out>(fn: (...args: In[]) => Promise<Out>) => this.doWrapWithProgress(fn);
protected doWrapWithProgress<In, Out>(fn: (...args: In[]) => Promise<Out>): (...args: In[]) => Promise<Out> {
return (...args: In[]) => this.withProgress(() => fn(...args));
}
}
class GitQuickPickItem<T> implements QuickPickItem {
readonly execute?: () => void;
constructor(
public label: string,
execute?: (item: QuickPickItem) => void,
public readonly ref?: T,
public description?: string,
public alwaysShow = true,
public sortByLabel = false) {
this.execute = execute ? createExecFunction(execute, this) : undefined;
}
}
function createExecFunction(f: (item: QuickPickItem) => void, item: QuickPickItem): () => void {
return () => { f(item); };
}

View File

@@ -0,0 +1,244 @@
// *****************************************************************************
// Copyright (C) 2018 Ericsson 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { Container } from '@theia/core/shared/inversify';
import { Git, Repository } from '../common';
import { DugiteGit } from '../node/dugite-git';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { FileStat, FileChangesEvent } from '@theia/filesystem/lib/common/files';
import { Emitter, CommandService, Disposable } from '@theia/core';
import { LocalStorageService, StorageService, LabelProvider, OpenerService } from '@theia/core/lib/browser';
import { GitRepositoryProvider } from './git-repository-provider';
import * as sinon from 'sinon';
import * as chai from 'chai';
import { GitCommitMessageValidator } from './git-commit-message-validator';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { ScmContextKeyService } from '@theia/scm/lib/browser/scm-context-key-service';
import { ContextKeyService, ContextKeyServiceDummyImpl } from '@theia/core/lib/browser/context-key-service';
import { GitScmProvider } from './git-scm-provider';
import { createGitScmProviderFactory } from './git-frontend-module';
import { EditorManager } from '@theia/editor/lib/browser';
import { GitErrorHandler } from './git-error-handler';
import { GitPreferences } from '../common/git-preferences';
import { GitRepositoryTracker } from './git-repository-tracker';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
const expect = chai.expect;
disableJSDOM();
const folderA = FileStat.dir('file:///home/repoA');
const repoA1 = <Repository>{
localUri: `${folderA.resource.toString()}/1`
};
const repoA2 = <Repository>{
localUri: `${folderA.resource.toString()}/2`
};
const folderB = FileStat.dir('file:///home/repoB');
const repoB = <Repository>{
localUri: folderB.resource.toString()
};
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('GitRepositoryProvider', () => {
let testContainer: Container;
let mockGit: DugiteGit;
let mockWorkspaceService: WorkspaceService;
let mockFilesystem: FileService;
let mockStorageService: StorageService;
let mockGitRepositoryTracker: GitRepositoryTracker;
let gitRepositoryProvider: GitRepositoryProvider;
const mockRootChangeEmitter: Emitter<FileStat[]> = new Emitter();
const mockFileChangeEmitter: Emitter<FileChangesEvent> = new Emitter();
before(() => {
disableJSDOM = enableJSDOM();
});
after(() => {
disableJSDOM();
});
beforeEach(() => {
mockGit = sinon.createStubInstance(DugiteGit);
mockWorkspaceService = sinon.createStubInstance(WorkspaceService);
mockFilesystem = sinon.createStubInstance(FileService);
mockStorageService = sinon.createStubInstance(LocalStorageService);
mockGitRepositoryTracker = sinon.createStubInstance(GitRepositoryTracker);
testContainer = new Container();
testContainer.bind(GitRepositoryProvider).toSelf().inSingletonScope();
testContainer.bind(Git).toConstantValue(mockGit);
testContainer.bind(WorkspaceService).toConstantValue(mockWorkspaceService);
testContainer.bind(FileService).toConstantValue(mockFilesystem);
testContainer.bind(StorageService).toConstantValue(mockStorageService);
testContainer.bind(ScmService).toSelf().inSingletonScope();
testContainer.bind(GitScmProvider.Factory).toFactory(createGitScmProviderFactory);
testContainer.bind(ScmContextKeyService).toSelf().inSingletonScope();
testContainer.bind(ContextKeyService).to(ContextKeyServiceDummyImpl).inSingletonScope();
testContainer.bind(GitCommitMessageValidator).toSelf().inSingletonScope();
testContainer.bind(OpenerService).toConstantValue(<OpenerService>{});
testContainer.bind(EditorManager).toConstantValue(<EditorManager>{});
testContainer.bind(GitErrorHandler).toConstantValue(<GitErrorHandler>{});
testContainer.bind(CommandService).toConstantValue(<CommandService>{});
testContainer.bind(LabelProvider).toConstantValue(<LabelProvider>{});
testContainer.bind(GitPreferences).toConstantValue({ onPreferenceChanged: () => Disposable.NULL });
testContainer.bind(GitRepositoryTracker).toConstantValue(mockGitRepositoryTracker);
sinon.stub(mockWorkspaceService, 'onWorkspaceChanged').value(mockRootChangeEmitter.event);
sinon.stub(mockFilesystem, 'onDidFilesChange').value(mockFileChangeEmitter.event);
});
it('should adds all existing git repo(s) on theia loads', async () => {
const allRepos = [repoA1, repoA2];
const roots = [folderA];
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allRepos[0]);
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allRepos);
sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve(roots));
(<sinon.SinonStub>mockWorkspaceService.tryGetRoots).returns(roots);
gitRepositoryProvider = testContainer.get<GitRepositoryProvider>(GitRepositoryProvider);
(<sinon.SinonStub>mockFilesystem.exists).resolves(true);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), {}).resolves(allRepos);
await gitRepositoryProvider['doInit']();
expect(gitRepositoryProvider.allRepositories.length).to.eq(allRepos.length);
expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allRepos[0].localUri);
expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allRepos[1].localUri);
expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allRepos[0].localUri);
});
// tslint:disable-next-line:no-void-expression
it.skip('should refresh git repo(s) on receiving a root change event from WorkspaceService', done => {
const allReposA = [repoA1, repoA2];
const oldRoots = [folderA];
const allReposB = [repoB];
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allReposA[0]);
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allReposA);
sinon.stub(mockWorkspaceService, 'roots').resolves(oldRoots);
const stubWsRoots = <sinon.SinonStub>mockWorkspaceService.tryGetRoots;
stubWsRoots.returns(oldRoots);
gitRepositoryProvider = testContainer.get<GitRepositoryProvider>(GitRepositoryProvider);
(<sinon.SinonStub>mockFilesystem.exists).resolves(true);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), {}).resolves(allReposA);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderB.resource.toString(), {}).resolves(allReposB);
let counter = 0;
gitRepositoryProvider.onDidChangeRepository(selected => {
counter++;
if (counter === 3) {
expect(gitRepositoryProvider.allRepositories.length).to.eq(allReposA.concat(allReposB).length);
expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allReposA[0].localUri);
expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allReposA[1].localUri);
expect(gitRepositoryProvider.allRepositories[2].localUri).to.eq(allReposB[0].localUri);
expect(selected && selected.localUri).to.eq(allReposA[0].localUri);
done();
}
});
gitRepositoryProvider['doInit']().then(() => {
const newRoots = [folderA, folderB];
stubWsRoots.returns(newRoots);
sinon.stub(mockWorkspaceService, 'roots').resolves(newRoots);
mockRootChangeEmitter.fire(newRoots);
}).catch(e =>
done(new Error('gitRepositoryProvider.initialize() throws an error'))
);
});
// tslint:disable-next-line:no-void-expression
it.skip('should refresh git repo(s) on receiving a file system change event', done => {
const allReposA = [repoA1, repoA2];
const oldRoots = [folderA];
const allReposB = [repoB];
const newRoots = [folderA, folderB];
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allReposA[0]);
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allReposA);
sinon.stub(mockWorkspaceService, 'roots').onCall(0).resolves(oldRoots);
sinon.stub(mockWorkspaceService, 'roots').onCall(1).resolves(oldRoots);
sinon.stub(mockWorkspaceService, 'roots').onCall(2).resolves(newRoots);
const stubWsRoots = <sinon.SinonStub>mockWorkspaceService.tryGetRoots;
stubWsRoots.onCall(0).returns(oldRoots);
stubWsRoots.onCall(1).returns(oldRoots);
stubWsRoots.onCall(2).returns(newRoots);
gitRepositoryProvider = testContainer.get<GitRepositoryProvider>(GitRepositoryProvider);
(<sinon.SinonStub>mockFilesystem.exists).resolves(true);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), {}).resolves(allReposA);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderB.resource.toString(), {}).resolves(allReposB);
let counter = 0;
gitRepositoryProvider.onDidChangeRepository(selected => {
counter++;
if (counter === 3) {
expect(gitRepositoryProvider.allRepositories.length).to.eq(allReposA.concat(allReposB).length);
expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allReposA[0].localUri);
expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allReposA[1].localUri);
expect(gitRepositoryProvider.allRepositories[2].localUri).to.eq(allReposB[0].localUri);
expect(selected && selected.localUri).to.eq(allReposA[0].localUri);
done();
}
});
gitRepositoryProvider['doInit']().then(() =>
mockFileChangeEmitter.fire(new FileChangesEvent([]))
).catch(e =>
done(new Error('gitRepositoryProvider.initialize() throws an error'))
);
});
// tslint:disable-next-line:no-void-expression
it.skip('should ignore the invalid or nonexistent root(s)', async () => {
const allReposA = [repoA1, repoA2];
const roots = [folderA, folderB];
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(allReposA[0]);
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(allReposA);
sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve(roots));
(<sinon.SinonStub>mockWorkspaceService.tryGetRoots).returns(roots);
gitRepositoryProvider = testContainer.get<GitRepositoryProvider>(GitRepositoryProvider);
(<sinon.SinonStub>mockFilesystem.exists).withArgs(folderA.resource.toString()).resolves(true); // folderA exists
(<sinon.SinonStub>mockFilesystem.exists).withArgs(folderB.resource.toString()).resolves(false); // folderB does not exist
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), {}).resolves(allReposA);
await gitRepositoryProvider['doInit']();
expect(gitRepositoryProvider.allRepositories.length).to.eq(allReposA.length);
expect(gitRepositoryProvider.allRepositories[0].localUri).to.eq(allReposA[0].localUri);
expect(gitRepositoryProvider.allRepositories[1].localUri).to.eq(allReposA[1].localUri);
expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allReposA[0].localUri);
});
it('should mark the first repo in the first root as "selectedRepository", if the "selectedRepository" is unavailable in the first place', async () => {
const allReposA = [repoA1, repoA2];
const roots = [folderA, folderB];
const allReposB = [repoB];
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-selected-repository').resolves(undefined);
(<sinon.SinonStub>mockStorageService.getData).withArgs('theia-git-all-repositories').resolves(undefined);
sinon.stub(mockWorkspaceService, 'roots').value(Promise.resolve(roots));
(<sinon.SinonStub>mockWorkspaceService.tryGetRoots).returns(roots);
gitRepositoryProvider = testContainer.get<GitRepositoryProvider>(GitRepositoryProvider);
(<sinon.SinonStub>mockFilesystem.exists).resolves(true);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), {}).resolves(allReposA);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderA.resource.toString(), { maxCount: 1 }).resolves([allReposA[0]]);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderB.resource.toString(), {}).resolves(allReposB);
(<sinon.SinonStub>mockGit.repositories).withArgs(folderB.resource.toString(), { maxCount: 1 }).resolves([allReposB[0]]);
await gitRepositoryProvider['doInit']();
expect(gitRepositoryProvider.selectedRepository && gitRepositoryProvider.selectedRepository.localUri).to.eq(allReposA[0].localUri);
});
});

View File

@@ -0,0 +1,214 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 debounce = require('@theia/core/shared/lodash.debounce');
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { StorageService } from '@theia/core/lib/browser/storage-service';
import { Git, Repository } from '../common';
import { GitCommitMessageValidator } from './git-commit-message-validator';
import { GitScmProvider } from './git-scm-provider';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
export interface GitRefreshOptions {
readonly maxCount: number
}
@injectable()
export class GitRepositoryProvider {
protected readonly onDidChangeRepositoryEmitter = new Emitter<Repository | undefined>();
protected readonly selectedRepoStorageKey = 'theia-git-selected-repository';
protected readonly allRepoStorageKey = 'theia-git-all-repositories';
@inject(GitScmProvider.Factory)
protected readonly scmProviderFactory: GitScmProvider.Factory;
@inject(GitCommitMessageValidator)
protected readonly commitMessageValidator: GitCommitMessageValidator;
@inject(Git) protected readonly git: Git;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(StorageService) protected readonly storageService: StorageService;
@inject(FileService)
protected readonly fileService: FileService;
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
const [selectedRepository, allRepositories] = await Promise.all([
this.storageService.getData<Repository | undefined>(this.selectedRepoStorageKey),
this.storageService.getData<Repository[]>(this.allRepoStorageKey)
]);
this.scmService.onDidChangeSelectedRepository(scmRepository => this.fireDidChangeRepository(this.toGitRepository(scmRepository)));
if (allRepositories) {
this.updateRepositories(allRepositories);
} else {
await this.refresh({ maxCount: 1 });
}
this.selectedRepository = selectedRepository;
await this.refresh();
this.fileService.onDidFilesChange(_ => this.lazyRefresh());
}
protected lazyRefresh: () => Promise<void> | undefined = debounce(() => this.refresh(), 1000);
/**
* Returns with the previously selected repository, or if no repository has been selected yet,
* it picks the first available repository from the backend and sets it as the selected one and returns with that.
* If no repositories are available, returns `undefined`.
*/
get selectedRepository(): Repository | undefined {
return this.toGitRepository(this.scmService.selectedRepository);
}
/**
* Sets the selected repository, but do nothing if the given repository is not a Git repository
* registered with the SCM service. We must be sure not to clear the selection if the selected
* repository is managed by an SCM other than Git.
*/
set selectedRepository(repository: Repository | undefined) {
const scmRepository = this.toScmRepository(repository);
if (scmRepository) {
this.scmService.selectedRepository = scmRepository;
}
}
get selectedScmRepository(): GitScmRepository | undefined {
return this.toGitScmRepository(this.scmService.selectedRepository);
}
get selectedScmProvider(): GitScmProvider | undefined {
return this.toGitScmProvider(this.scmService.selectedRepository);
}
get onDidChangeRepository(): Event<Repository | undefined> {
return this.onDidChangeRepositoryEmitter.event;
}
protected fireDidChangeRepository(repository: Repository | undefined): void {
this.storageService.setData<Repository | undefined>(this.selectedRepoStorageKey, repository);
this.onDidChangeRepositoryEmitter.fire(repository);
}
/**
* Returns with all know repositories.
*/
get allRepositories(): Repository[] {
const repositories = [];
for (const scmRepository of this.scmService.repositories) {
const repository = this.toGitRepository(scmRepository);
if (repository) {
repositories.push(repository);
}
}
return repositories;
}
async refresh(options?: GitRefreshOptions): Promise<void> {
const repositories: Repository[] = [];
const refreshing: Promise<void>[] = [];
for (const root of await this.workspaceService.roots) {
refreshing.push(this.git.repositories(root.resource.toString(), { ...options }).then(
result => { repositories.push(...result); },
() => { /* no-op*/ }
));
}
await Promise.all(refreshing);
this.updateRepositories(repositories);
}
protected updateRepositories(repositories: Repository[]): void {
this.storageService.setData<Repository[]>(this.allRepoStorageKey, repositories);
const registered = new Set<string>();
const toUnregister = new Map<string, ScmRepository>();
for (const scmRepository of this.scmService.repositories) {
const repository = this.toGitRepository(scmRepository);
if (repository) {
registered.add(repository.localUri);
toUnregister.set(repository.localUri, scmRepository);
}
}
for (const repository of repositories) {
toUnregister.delete(repository.localUri);
if (!registered.has(repository.localUri)) {
registered.add(repository.localUri);
this.registerScmProvider(repository);
}
}
for (const [, scmRepository] of toUnregister) {
scmRepository.dispose();
}
}
protected registerScmProvider(repository: Repository): void {
const provider = this.scmProviderFactory({ repository });
const scmRepository = this.scmService.registerScmProvider(provider, {
input: {
placeholder: 'Message (press {0} to commit)',
validator: async value => {
const issue = await this.commitMessageValidator.validate(value);
return issue && {
message: issue.message,
type: issue.status
};
}
}
});
provider.input = scmRepository.input;
}
protected toScmRepository(repository: Repository | undefined): ScmRepository | undefined {
return repository && this.scmService.repositories.find(scmRepository => Repository.equal(this.toGitRepository(scmRepository), repository));
}
protected toGitRepository(scmRepository: ScmRepository | undefined): Repository | undefined {
const provider = this.toGitScmProvider(scmRepository);
return provider && provider.repository;
}
protected toGitScmProvider(scmRepository: ScmRepository | undefined): GitScmProvider | undefined {
const gitScmRepository = this.toGitScmRepository(scmRepository);
return gitScmRepository && gitScmRepository.provider;
}
protected toGitScmRepository(scmRepository: ScmRepository | undefined): GitScmRepository | undefined {
return GitScmRepository.is(scmRepository) ? scmRepository : undefined;
}
}
export interface GitScmRepository extends ScmRepository {
readonly provider: GitScmProvider;
}
export namespace GitScmRepository {
export function is(scmRepository: ScmRepository | undefined): scmRepository is GitScmRepository {
return !!scmRepository && scmRepository.provider instanceof GitScmProvider;
}
}

View File

@@ -0,0 +1,133 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { Git, Repository, WorkingDirectoryStatus } from '../common';
import { Event, Emitter, Disposable, DisposableCollection, CancellationToken, CancellationTokenSource } from '@theia/core';
import { GitRepositoryProvider } from './git-repository-provider';
import { GitWatcher, GitStatusChangeEvent } from '../common/git-watcher';
import URI from '@theia/core/lib/common/uri';
import debounce = require('@theia/core/shared/lodash.debounce');
/**
* The repository tracker watches the selected repository for status changes. It provides a convenient way to listen on status updates.
*/
@injectable()
export class GitRepositoryTracker {
protected toDispose = new DisposableCollection();
protected workingDirectoryStatus: WorkingDirectoryStatus | undefined;
protected readonly onGitEventEmitter = new Emitter<GitStatusChangeEvent | undefined>();
constructor(
@inject(Git) protected readonly git: Git,
@inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider,
@inject(GitWatcher) protected readonly gitWatcher: GitWatcher,
) { }
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
this.updateStatus();
this.repositoryProvider.onDidChangeRepository(() => this.updateStatus());
}
protected updateStatus = debounce(async (): Promise<void> => {
this.toDispose.dispose();
const tokenSource = new CancellationTokenSource();
this.toDispose.push(Disposable.create(() => tokenSource.cancel()));
const token = tokenSource.token;
const source = this.selectedRepository;
if (source) {
const status = await this.git.status(source);
this.setStatus({ source, status }, token);
this.toDispose.push(this.gitWatcher.onGitEvent(event => {
if (event.source.localUri === source.localUri) {
this.setStatus(event, token);
}
}));
this.toDispose.push(await this.gitWatcher.watchGitChanges(source));
} else {
this.setStatus(undefined, token);
}
}, 50);
protected setStatus(event: GitStatusChangeEvent | undefined, token: CancellationToken): void {
const status = event && event.status;
const scmProvider = this.repositoryProvider.selectedScmProvider;
if (scmProvider) {
scmProvider.setStatus(status);
}
this.workingDirectoryStatus = status;
this.onGitEventEmitter.fire(event);
}
/**
* Returns the selected repository, or `undefined` if no repositories are available.
*/
get selectedRepository(): Repository | undefined {
return this.repositoryProvider.selectedRepository;
}
/**
* Returns all known repositories.
*/
get allRepositories(): Repository[] {
return this.repositoryProvider.allRepositories;
}
/**
* Returns the last known status of the selected repository, or `undefined` if no repositories are available.
*/
get selectedRepositoryStatus(): WorkingDirectoryStatus | undefined {
return this.workingDirectoryStatus;
}
/**
* Emits when the selected repository has changed.
*/
get onDidChangeRepository(): Event<Repository | undefined> {
return this.repositoryProvider.onDidChangeRepository;
}
/**
* Emits when status has changed in the selected repository.
*/
get onGitEvent(): Event<GitStatusChangeEvent | undefined> {
return this.onGitEventEmitter.event;
}
getPath(uri: URI): string | undefined {
const { repositoryUri } = this;
const relativePath = repositoryUri && Repository.relativePath(repositoryUri, uri);
return relativePath && relativePath.toString();
}
getUri(path: string): URI | undefined {
const { repositoryUri } = this;
return repositoryUri && repositoryUri.resolve(path);
}
get repositoryUri(): URI | undefined {
const repository = this.selectedRepository;
return repository && new URI(repository.localUri);
}
}

View File

@@ -0,0 +1,67 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { Git, Repository } from '../common';
import { Resource, ResourceResolver } from '@theia/core';
import URI from '@theia/core/lib/common/uri';
import { GitRepositoryProvider } from './git-repository-provider';
import { GIT_RESOURCE_SCHEME, GitResource } from './git-resource';
@injectable()
export class GitResourceResolver implements ResourceResolver {
constructor(
@inject(Git) protected readonly git: Git,
@inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider
) { }
resolve(uri: URI): Resource | Promise<Resource> {
if (uri.scheme !== GIT_RESOURCE_SCHEME) {
throw new Error(`Expected a URI with ${GIT_RESOURCE_SCHEME} scheme. Was: ${uri}.`);
}
return this.getResource(uri);
}
async getResource(uri: URI): Promise<GitResource> {
const repository = await this.getRepository(uri);
return new GitResource(uri, repository, this.git);
}
async getRepository(uri: URI): Promise<Repository | undefined> {
const fileUri = uri.withScheme('file');
const repositories = this.repositoryProvider.allRepositories;
// The layout restorer might ask for the known repositories this point.
if (repositories.length === 0) {
// So let's make sure, the repository provider state is in sync with the backend.
await this.repositoryProvider.refresh();
repositories.push(...this.repositoryProvider.allRepositories);
}
// We sort by length so that we visit the nested repositories first.
// We do not want to get the repository A instead of B if we have:
// repository A, another repository B inside A and a resource A/B/C.ext.
const sortedRepositories = repositories.sort((a, b) => b.localUri.length - a.localUri.length);
for (const repository of sortedRepositories) {
const localUri = new URI(repository.localUri);
// make sure that localUri of repository has file scheme.
const localUriStr = localUri.withScheme('file').toString();
if (fileUri.toString().startsWith(localUriStr)) {
return { localUri: localUriStr };
}
}
return undefined;
}
}

View File

@@ -0,0 +1,65 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { Git, Repository } from '../common';
import { Resource } from '@theia/core';
import URI from '@theia/core/lib/common/uri';
export const GIT_RESOURCE_SCHEME = 'gitrev';
export class GitResource implements Resource {
constructor(readonly uri: URI, protected readonly repository: Repository | undefined, protected readonly git: Git) { }
async readContents(options?: { encoding?: string }): Promise<string> {
if (this.repository) {
const commitish = this.uri.query;
let encoding: Git.Options.Show['encoding'];
if (options?.encoding === 'utf8' || options?.encoding === 'binary') {
encoding = options?.encoding;
}
return this.git.show(this.repository, this.uri.toString(), { commitish, encoding });
}
return '';
}
async getSize(): Promise<number> {
if (this.repository) {
const path = Repository.relativePath(this.repository, this.uri.withScheme('file'))?.toString();
if (path) {
const commitish = this.uri.query || 'index';
if ([':1', ':2', ':3'].includes(commitish)) { // special case: index stage number during merge
const lines = (await this.git.exec(this.repository, ['ls-files', '--format=%(stage) %(objectsize)', '--', path])).stdout.split('\n');
for (const line of lines) {
const [stage, size] = line.trim().split(' ');
if (stage === commitish.substring(1) && size) {
return parseInt(size);
}
}
} else {
const args = commitish !== 'index' ? ['ls-tree', '--format=%(objectsize)', commitish, path] : ['ls-files', '--format=%(objectsize)', '--', path];
const size = (await this.git.exec(this.repository, args)).stdout.split('\n').filter(line => !!line.trim())[0];
if (size) {
return parseInt(size);
}
}
}
}
return 0;
}
dispose(): void { }
}

View File

@@ -0,0 +1,141 @@
// *****************************************************************************
// Copyright (C) 2022 Toro Cloud Pty Ltd 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { CommandService, Disposable, ILogger, MessageService } from '@theia/core';
import { LabelProvider, OpenerService } from '@theia/core/lib/browser';
import { FileUri } from '@theia/core/lib/node';
import { Container } from '@theia/core/shared/inversify';
import { EditorManager } from '@theia/editor/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ScmInput } from '@theia/scm/lib/browser/scm-input';
import { expect } from 'chai';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { rimraf } from 'rimraf';
import * as sinon from 'sinon';
import { Git, GitFileStatus, Repository } from '../common';
import { DugiteGit } from '../node/dugite-git';
import { DefaultGitEnvProvider, GitEnvProvider } from '../node/env/git-env-provider';
import { bindGit } from '../node/git-backend-module';
import { GitRepositoryWatcher, GitRepositoryWatcherFactory } from '../node/git-repository-watcher';
import { GitErrorHandler } from './git-error-handler';
import { GitPreferences } from '../common/git-preferences';
import { GitScmProvider, GitScmProviderOptions } from './git-scm-provider';
disableJSDOM();
describe('GitScmProvider', () => {
let testContainer: Container;
let mockOpenerService: OpenerService;
let mockEditorManager: EditorManager;
let mockGitErrorHandler: GitErrorHandler;
let mockFileService: FileService;
let git: Git;
let mockCommandService: CommandService;
let mockLabelProvider: LabelProvider;
let gitScmProvider: GitScmProvider;
const repository: Repository = {
localUri: FileUri.create(path.join(os.tmpdir(), 'GitScmProvider.test', 'repoA')).toString()
};
before(() => {
disableJSDOM = enableJSDOM();
});
after(async () => {
disableJSDOM();
});
beforeEach(async () => {
mockOpenerService = {} as OpenerService;
mockEditorManager = sinon.createStubInstance(EditorManager);
mockGitErrorHandler = sinon.createStubInstance(GitErrorHandler);
mockFileService = sinon.createStubInstance(FileService);
git = sinon.createStubInstance(DugiteGit);
mockCommandService = {} as CommandService;
mockLabelProvider = sinon.createStubInstance(LabelProvider);
testContainer = new Container();
testContainer.bind(OpenerService).toConstantValue(mockOpenerService);
testContainer.bind(EditorManager).toConstantValue(mockEditorManager);
testContainer.bind(GitErrorHandler).toConstantValue(mockGitErrorHandler);
testContainer.bind(FileService).toConstantValue(mockFileService);
testContainer.bind(ILogger).toConstantValue(console);
testContainer.bind(GitEnvProvider).to(DefaultGitEnvProvider);
bindGit(testContainer.bind.bind(testContainer));
// We have to mock the watcher because it runs after the afterEach
// which removes the git repository, causing an error in the watcher
// which tries to get the git repo status.
testContainer.rebind(GitRepositoryWatcherFactory).toConstantValue(() => {
const mockWatcher = sinon.createStubInstance(GitRepositoryWatcher);
mockWatcher.sync.resolves();
return mockWatcher;
});
testContainer.bind(MessageService).toConstantValue(sinon.createStubInstance(MessageService));
testContainer.bind(CommandService).toConstantValue(mockCommandService);
testContainer.bind(LabelProvider).toConstantValue(mockLabelProvider);
testContainer.rebind(GitPreferences).toConstantValue({ onPreferenceChanged: () => Disposable.NULL });
testContainer.bind(GitScmProviderOptions).toConstantValue({
repository
} as GitScmProviderOptions);
testContainer.bind(GitScmProvider).toSelf();
gitScmProvider = testContainer.get(GitScmProvider);
gitScmProvider.input = sinon.createStubInstance(ScmInput);
git = testContainer.get(Git);
await fs.mkdirp(FileUri.fsPath(repository.localUri));
await git.exec(repository, ['init']);
});
afterEach(async () => {
await rimraf(FileUri.fsPath(repository.localUri));
});
it('should unstage all the changes', async () => {
const uris = [
repository.localUri + '/test1.txt',
repository.localUri + '/test2.txt'
];
await Promise.all(uris.map(uri => fs.createFile(FileUri.fsPath(uri))));
await git.add(repository, uris);
gitScmProvider.setStatus({
changes: uris.map(uri => ({
status: GitFileStatus.New,
uri,
staged: true
})),
exists: true
});
expect(gitScmProvider.stagedChanges.length).to.eq(2);
await gitScmProvider.unstageAll();
const status = await git.status(repository);
expect(status.changes.filter(change => change.staged).length).to.eq(0);
expect(status.changes.filter(change => !change.staged).length).to.eq(2);
});
});

View File

@@ -0,0 +1,689 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { open, OpenerService } from '@theia/core/lib/browser';
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
import { Emitter } from '@theia/core';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { CommandService } from '@theia/core/lib/common/command';
import { ConfirmDialog } from '@theia/core/lib/browser/dialogs';
import { EditorOpenerOptions, EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { WorkspaceCommands } from '@theia/workspace/lib/browser';
import { Repository, Git, CommitWithChanges, GitFileChange, WorkingDirectoryStatus, GitFileStatus } from '../common';
import { GIT_RESOURCE_SCHEME } from './git-resource';
import { GitErrorHandler } from './git-error-handler';
import { EditorWidget } from '@theia/editor/lib/browser';
import { ScmProvider, ScmCommand, ScmResourceGroup, ScmAmendSupport, ScmCommit } from '@theia/scm/lib/browser/scm-provider';
import { ScmHistoryCommit, ScmFileChange } from '@theia/scm-extra/lib/browser/scm-file-change-node';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { GitCommitDetailWidgetOptions } from './history/git-commit-detail-widget-options';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ScmInput } from '@theia/scm/lib/browser/scm-input';
import { MergeEditorOpenerOptions, MergeEditorSideWidgetState, MergeEditorUri } from '@theia/scm/lib/browser/merge-editor/merge-editor';
import { nls } from '@theia/core/lib/common/nls';
import { GitPreferences } from '../common/git-preferences';
@injectable()
export class GitScmProviderOptions {
repository: Repository;
}
@injectable()
export class GitScmProvider implements ScmProvider {
public input: ScmInput;
protected readonly onDidChangeEmitter = new Emitter<void>();
readonly onDidChange = this.onDidChangeEmitter.event;
protected fireDidChange(): void {
this.onDidChangeEmitter.fire(undefined);
}
private readonly onDidChangeCommitTemplateEmitter = new Emitter<string>();
readonly onDidChangeCommitTemplate = this.onDidChangeCommitTemplateEmitter.event;
private readonly onDidChangeStatusBarCommandsEmitter = new Emitter<ScmCommand[] | undefined>();
readonly onDidChangeStatusBarCommands = this.onDidChangeStatusBarCommandsEmitter.event;
private readonly toDispose = new DisposableCollection(
this.onDidChangeEmitter,
this.onDidChangeCommitTemplateEmitter,
this.onDidChangeStatusBarCommandsEmitter
);
@inject(OpenerService)
protected openerService: OpenerService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(GitErrorHandler)
protected readonly gitErrorHandler: GitErrorHandler;
@inject(FileService)
protected readonly fileService: FileService;
@inject(Git)
protected readonly git: Git;
@inject(CommandService)
protected readonly commands: CommandService;
@inject(GitScmProviderOptions)
protected readonly options: GitScmProviderOptions;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(GitPreferences)
protected readonly gitPreferences: GitPreferences;
readonly id = 'git';
readonly label = nls.localize('vscode.git/package/displayName', 'Git');
dispose(): void {
this.toDispose.dispose();
}
@postConstruct()
protected init(): void {
this._amendSupport = new GitAmendSupport(this, this.repository, this.git);
this.toDispose.push(this.gitPreferences.onPreferenceChanged(e => {
if (e.preferenceName === 'git.untrackedChanges' && e.affects(this.rootUri)) {
this.setStatus(this.getStatus());
}
}));
}
get repository(): Repository {
return this.options.repository;
}
get rootUri(): string {
return this.repository.localUri;
}
protected _amendSupport: GitAmendSupport;
get amendSupport(): GitAmendSupport {
return this._amendSupport;
}
get acceptInputCommand(): ScmCommand | undefined {
return {
command: 'git.commit.all',
tooltip: nls.localize('vscode.git/package/command.commitAll', 'Commit all the staged changes'),
title: nls.localizeByDefault('Commit')
};
}
protected _statusBarCommands: ScmCommand[] | undefined;
get statusBarCommands(): ScmCommand[] | undefined {
return this._statusBarCommands;
}
set statusBarCommands(statusBarCommands: ScmCommand[] | undefined) {
this._statusBarCommands = statusBarCommands;
this.onDidChangeStatusBarCommandsEmitter.fire(statusBarCommands);
}
protected state = GitScmProvider.initState();
get groups(): ScmResourceGroup[] {
return this.state.groups;
}
get stagedChanges(): GitFileChange[] {
return this.state.stagedChanges;
}
get unstagedChanges(): GitFileChange[] {
return this.state.unstagedChanges;
}
get mergeChanges(): GitFileChange[] {
return this.state.mergeChanges;
}
getStatus(): WorkingDirectoryStatus | undefined {
return this.state.status;
}
setStatus(status: WorkingDirectoryStatus | undefined): void {
const state = GitScmProvider.initState(status);
if (status) {
for (const change of status.changes) {
if (GitFileStatus[GitFileStatus.Conflicted.valueOf()] !== GitFileStatus[change.status]) {
if (change.staged) {
state.stagedChanges.push(change);
} else {
state.unstagedChanges.push(change);
}
} else {
if (!change.staged) {
state.mergeChanges.push(change);
}
}
}
}
const untrackedChangesPreference = this.gitPreferences['git.untrackedChanges'];
const forWorkingTree = untrackedChangesPreference === 'mixed'
? state.unstagedChanges
: state.unstagedChanges.filter(change => change.status !== GitFileStatus.New);
const forUntracked = untrackedChangesPreference === 'separate'
? state.unstagedChanges.filter(change => change.status === GitFileStatus.New)
: [];
const hideWorkingIfEmpty = forUntracked.length > 0;
state.groups.push(this.createGroup('merge', nls.localize('vscode.git/repository/merge changes', 'Merge Changes'), state.mergeChanges, true));
state.groups.push(this.createGroup('index', nls.localize('vscode.git/repository/staged changes', 'Staged changes'), state.stagedChanges, true));
state.groups.push(this.createGroup('workingTree', nls.localizeByDefault('Changes'), forWorkingTree, hideWorkingIfEmpty));
state.groups.push(this.createGroup('untrackedChanges', nls.localize('vscode.git/repository/untracked changes', 'Untracked Changes'), forUntracked, true));
this.state = state;
if (status && status.branch) {
this.input.placeholder = nls.localize('vscode.git/repository/commitMessageWithHeadLabel', 'Message (press {0} to commit on {1})', '{0}', status.branch);
} else {
this.input.placeholder = nls.localize('vscode.git/repository/commitMessage', 'Message (press {0} to commit)');
}
this.fireDidChange();
}
protected createGroup(id: string, label: string, changes: GitFileChange[], hideWhenEmpty?: boolean): ScmResourceGroup {
const group: ScmResourceGroup = {
id,
label,
hideWhenEmpty,
provider: this,
resources: [],
dispose: () => { }
};
for (const change of changes) {
this.addScmResource(group, change);
}
return group;
}
protected addScmResource(group: ScmResourceGroup, change: GitFileChange): void {
const sourceUri = new URI(change.uri);
group.resources.push({
group,
sourceUri,
decorations: {
letter: GitFileStatus.toAbbreviation(change.status, change.staged),
color: GitFileStatus.getColor(change.status, change.staged),
tooltip: GitFileStatus.toString(change.status),
strikeThrough: GitFileStatus.toStrikethrough(change.status)
},
open: async () => this.open(change, { mode: 'reveal' })
});
}
async open(change: GitFileChange, options?: EditorOpenerOptions): Promise<void> {
const uriToOpen = this.getUriToOpen(change);
await open(this.openerService, uriToOpen, options);
}
// note: the implementation has to ensure that `GIT_RESOURCE_SCHEME` URIs it returns either directly or within a diff-URI always have a query;
// as an example of an issue that can otherwise arise, the VS Code `media-preview` plugin is known to mangle resource URIs without the query:
// https://github.com/microsoft/vscode/blob/6eaf6487a4d8301b981036bfa53976546eb6694f/extensions/media-preview/src/imagePreview/index.ts#L205-L209
getUriToOpen(change: GitFileChange): URI {
const changeUri: URI = new URI(change.uri);
const fromFileUri = change.oldUri ? new URI(change.oldUri) : changeUri; // set oldUri on renamed and copied
if (change.status === GitFileStatus.Deleted) {
if (change.staged) {
return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD');
} else {
return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index');
}
}
if (change.status !== GitFileStatus.New) {
if (change.staged) {
return DiffUris.encode(
fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'),
changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'),
nls.localize(
'theia/git/tabTitleIndex',
'{0} (Index)',
this.labelProvider.getName(changeUri)
));
}
if (this.stagedChanges.find(c => c.uri === change.uri)) {
return DiffUris.encode(
fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'),
changeUri,
nls.localize(
'theia/git/tabTitleWorkingTree',
'{0} (Working tree)',
this.labelProvider.getName(changeUri)
));
}
if (this.mergeChanges.find(c => c.uri === change.uri)) {
return changeUri;
}
return DiffUris.encode(
fromFileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('HEAD'),
changeUri,
nls.localize(
'theia/git/tabTitleWorkingTree',
'{0} (Working tree)',
this.labelProvider.getName(changeUri)
));
}
if (change.staged) {
return changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index');
}
if (this.stagedChanges.find(c => c.uri === change.uri)) {
return DiffUris.encode(
changeUri.withScheme(GIT_RESOURCE_SCHEME).withQuery('index'),
changeUri,
nls.localize(
'theia/git/tabTitleWorkingTree',
'{0} (Working tree)',
this.labelProvider.getName(changeUri)
));
}
return changeUri;
}
async openChange(change: GitFileChange, options?: EditorOpenerOptions): Promise<EditorWidget> {
const uriToOpen = this.getUriToOpen(change);
return this.editorManager.open(uriToOpen, options);
}
findChange(uri: URI): GitFileChange | undefined {
const stringUri = uri.toString();
const merge = this.mergeChanges.find(c => c.uri.toString() === stringUri);
if (merge) {
return merge;
}
const unstaged = this.unstagedChanges.find(c => c.uri.toString() === stringUri);
if (unstaged) {
return unstaged;
}
return this.stagedChanges.find(c => c.uri.toString() === stringUri);
}
async openMergeEditor(uri: URI): Promise<void> {
const baseUri = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(':1');
let side1Uri = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(':2');
let side2Uri = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(':3');
// eslint-disable-next-line @theia/localization-check -- need to create its own key instead of using localizeByDefault
let side1State: MergeEditorSideWidgetState = { title: nls.localize('theia/git/mergeEditor/currentSideTitle', 'Current') };
let side2State: MergeEditorSideWidgetState = { title: nls.localize('theia/git/mergeEditor/incomingSideTitle', 'Incoming') };
let isRebasing = false;
try {
const getCommitInfo = async (ref: string) => {
const hash = await this.git.revParse(this.repository, { ref });
if (hash) {
const refNames = (await this.git.exec(this.repository, ['log', '-n', '1', '--decorate=full', '--format=%D', hash])).stdout.trim();
return { hash, refNames };
}
};
const [head, mergeHead, rebaseHead] = await Promise.all([getCommitInfo('HEAD'), getCommitInfo('MERGE_HEAD'), getCommitInfo('REBASE_HEAD')]);
isRebasing = !!rebaseHead;
if (head) {
side1State.description = '$(git-commit) ' + head.hash.substring(0, 7);
side1State.detail = head.refNames.replace(/^HEAD -> /, '');
}
const rebaseOrMergeHead = rebaseHead || mergeHead;
if (rebaseOrMergeHead) {
side2State.description = '$(git-commit) ' + rebaseOrMergeHead.hash.substring(0, 7);
side2State.detail = rebaseOrMergeHead.refNames;
}
} catch (error) {
console.error(error);
}
if (!isRebasing) {
[side1Uri, side2Uri] = [side2Uri, side1Uri];
[side1State, side2State] = [side2State, side1State];
}
const options: MergeEditorOpenerOptions = { widgetState: { side1State, side2State } };
await open(this.openerService, MergeEditorUri.encode({ baseUri, side1Uri, side2Uri, resultUri: uri }), options);
}
async stageAll(): Promise<void> {
try {
// TODO resolve deletion conflicts
// TODO confirm staging unresolved files
await this.git.add(this.repository, []);
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
async stage(uriArg: string | string[]): Promise<void> {
try {
const { repository, unstagedChanges, mergeChanges } = this;
const uris = Array.isArray(uriArg) ? uriArg : [uriArg];
const unstagedUris = uris
.filter(uri => {
const resourceUri = new URI(uri);
return unstagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)))
|| mergeChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)));
});
if (unstagedUris.length !== 0) {
// TODO resolve deletion conflicts
// TODO confirm staging of a unresolved file
await this.git.add(repository, uris);
}
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
async unstageAll(): Promise<void> {
try {
const { repository, stagedChanges } = this;
const uris = stagedChanges.map(c => c.uri);
await this.git.unstage(repository, uris, { reset: 'index' });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
async unstage(uriArg: string | string[]): Promise<void> {
try {
const { repository, stagedChanges } = this;
const uris = Array.isArray(uriArg) ? uriArg : [uriArg];
const stagedUris = uris
.filter(uri => {
const resourceUri = new URI(uri);
return stagedChanges.some(change => resourceUri.isEqualOrParent(new URI(change.uri)));
}
);
if (stagedUris.length !== 0) {
await this.git.unstage(repository, uris, { reset: 'index' });
}
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
async discardAll(): Promise<void> {
if (await this.confirmAll()) {
try {
// discard new files
const newUris = this.unstagedChanges.filter(c => c.status === GitFileStatus.New).map(c => c.uri);
await this.deleteAll(newUris);
// unstage changes
const uris = this.unstagedChanges.filter(c => c.status !== GitFileStatus.New).map(c => c.uri);
await this.git.unstage(this.repository, uris, { treeish: 'HEAD', reset: 'working-tree' });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
}
async discard(uriArg: string | string[]): Promise<void> {
const { repository } = this;
const uris = Array.isArray(uriArg) ? uriArg : [uriArg];
const status = this.getStatus();
if (!status) {
return;
}
const pairs = await Promise.all(
uris
.filter(uri => {
const uriAsUri = new URI(uri);
return status.changes.some(change => uriAsUri.isEqualOrParent(new URI(change.uri)));
})
.map(uri => {
const includeIndexFlag = async () => {
// Allow deletion, only iff the same file is not yet in the Git index.
const isInIndex = await this.git.lsFiles(repository, uri, { errorUnmatch: true });
return { uri, isInIndex };
};
return includeIndexFlag();
})
);
const urisInIndex = pairs.filter(pair => pair.isInIndex).map(pair => pair.uri);
if (urisInIndex.length !== 0) {
if (!await this.confirm(urisInIndex)) {
return;
}
}
await Promise.all(
pairs.map(pair => {
const discardSingle = async () => {
if (pair.isInIndex) {
try {
await this.git.unstage(repository, pair.uri, { treeish: 'HEAD', reset: 'working-tree' });
} catch (error) {
this.gitErrorHandler.handleError(error);
}
} else {
await this.commands.executeCommand(WorkspaceCommands.FILE_DELETE.id, [new URI(pair.uri)]);
}
};
return discardSingle();
})
);
}
protected confirm(paths: string[]): Promise<boolean | undefined> {
let fileText: string;
if (paths.length <= 3) {
fileText = paths.map(path => this.labelProvider.getName(new URI(path))).join(', ');
} else {
fileText = `${paths.length} files`;
}
return new ConfirmDialog({
title: nls.localize('vscode.git/package/command.clean', 'Discard Changes'),
msg: nls.localize('vscode.git/commands/confirm discard', 'Do you really want to discard changes in {0}?', fileText)
}).open();
}
protected confirmAll(): Promise<boolean | undefined> {
return new ConfirmDialog({
title: nls.localize('vscode.git/package/command.cleanAll', 'Discard All Changes'),
msg: nls.localize('vscode.git/commands/confirm discard all', 'Do you really want to discard all changes?')
}).open();
}
protected async delete(uri: URI): Promise<void> {
try {
await this.fileService.delete(uri, { recursive: true });
} catch (e) {
console.error(e);
}
}
protected async deleteAll(uris: string[]): Promise<void> {
await Promise.all(uris.map(uri => this.delete(new URI(uri))));
}
public createScmCommit(gitCommit: CommitWithChanges): ScmCommit {
const scmCommit: ScmCommit = {
id: gitCommit.sha,
summary: gitCommit.summary,
authorName: gitCommit.author.name,
authorEmail: gitCommit.author.email,
authorDateRelative: gitCommit.authorDateRelative,
};
return scmCommit;
}
public createScmHistoryCommit(gitCommit: CommitWithChanges): ScmHistoryCommit {
const range = {
fromRevision: gitCommit.sha + '~1',
toRevision: gitCommit.sha
};
const scmCommit: GitScmCommit = {
...this.createScmCommit(gitCommit),
commitDetailUri: this.toCommitDetailUri(gitCommit.sha),
scmProvider: this,
gitFileChanges: gitCommit.fileChanges.map(change => new GitScmFileChange(change, this, range)),
get fileChanges(): ScmFileChange[] {
return this.gitFileChanges;
},
get commitDetailOptions(): GitCommitDetailWidgetOptions {
return {
rootUri: this.scmProvider.rootUri,
commitSha: gitCommit.sha,
commitMessage: gitCommit.summary,
messageBody: gitCommit.body,
authorName: gitCommit.author.name,
authorEmail: gitCommit.author.email,
authorDate: gitCommit.author.timestamp,
authorDateRelative: gitCommit.authorDateRelative,
};
}
};
return scmCommit;
}
public relativePath(uri: string): string {
const parsedUri = new URI(uri);
const gitRepo = { localUri: this.rootUri };
const relativePath = Repository.relativePath(gitRepo, parsedUri);
if (relativePath) {
return relativePath.toString();
}
return this.labelProvider.getLongName(parsedUri);
}
protected toCommitDetailUri(commitSha: string): URI {
return new URI('').withScheme(GitScmProvider.GIT_COMMIT_DETAIL).withFragment(commitSha);
}
}
export namespace GitScmProvider {
export const GIT_COMMIT_DETAIL = 'git-commit-detail-widget';
export interface State {
status?: WorkingDirectoryStatus
stagedChanges: GitFileChange[]
unstagedChanges: GitFileChange[]
mergeChanges: GitFileChange[],
groups: ScmResourceGroup[]
}
export function initState(status?: WorkingDirectoryStatus): GitScmProvider.State {
return {
status,
stagedChanges: [],
unstagedChanges: [],
mergeChanges: [],
groups: []
};
}
export const Factory = Symbol('GitScmProvider.Factory');
export type Factory = (options: GitScmProviderOptions) => GitScmProvider;
}
export class GitAmendSupport implements ScmAmendSupport {
constructor(protected readonly provider: GitScmProvider, protected readonly repository: Repository, protected readonly git: Git) { }
public async getInitialAmendingCommits(amendingHeadCommitSha: string, latestCommitSha: string | undefined): Promise<ScmCommit[]> {
const commits = await this.git.log(
this.repository,
{
range: { toRevision: amendingHeadCommitSha, fromRevision: latestCommitSha },
maxCount: 50
}
);
return commits.map(commit => this.provider.createScmCommit(commit));
}
public async getMessage(commit: string): Promise<string> {
return (await this.git.exec(this.repository, ['log', '-n', '1', '--format=%B', commit])).stdout.trim();
}
public async reset(commit: string): Promise<void> {
if (commit === 'HEAD~' && await this.isHeadInitialCommit()) {
await this.git.exec(this.repository, ['update-ref', '-d', 'HEAD']);
} else {
await this.git.exec(this.repository, ['reset', commit, '--soft']);
}
}
protected async isHeadInitialCommit(): Promise<boolean> {
const result = await this.git.revParse(this.repository, { ref: 'HEAD~' });
return !result;
}
public async getLastCommit(): Promise<ScmCommit | undefined> {
const commits = await this.git.log(this.repository, { maxCount: 1 });
if (commits.length > 0) {
return this.provider.createScmCommit(commits[0]);
}
}
}
export interface GitScmCommit extends ScmHistoryCommit {
scmProvider: GitScmProvider;
gitFileChanges: GitScmFileChange[];
}
export class GitScmFileChange implements ScmFileChange {
constructor(
protected readonly fileChange: GitFileChange,
protected readonly scmProvider: GitScmProvider,
protected readonly range?: Git.Options.Range
) { }
get gitFileChange(): GitFileChange {
return this.fileChange;
}
get uri(): string {
return this.fileChange.uri;
}
getCaption(): string {
const provider = this.scmProvider;
let result = `${provider.relativePath(this.fileChange.uri)} - ${GitFileStatus.toString(this.fileChange.status, true)}`;
if (this.fileChange.oldUri) {
result = `${provider.relativePath(this.fileChange.oldUri)} -> ${result}`;
}
return result;
}
getStatusCaption(): string {
return GitFileStatus.toString(this.fileChange.status, true);
}
getStatusAbbreviation(): string {
return GitFileStatus.toAbbreviation(this.fileChange.status, this.fileChange.staged);
}
getClassNameForStatus(): string {
return 'git-status staged ' + GitFileStatus[this.fileChange.status].toLowerCase();
}
getUriToOpen(): URI {
const uri: URI = new URI(this.fileChange.uri);
const fromFileURI = this.fileChange.oldUri ? new URI(this.fileChange.oldUri) : uri; // set oldUri on renamed and copied
if (!this.range) {
return uri;
}
const fromURI = this.range.fromRevision
? fromFileURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.range.fromRevision.toString())
: fromFileURI;
const toURI = this.range.toRevision
? uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(this.range.toRevision.toString())
: uri;
let uriToOpen = uri;
if (this.fileChange.status === GitFileStatus.Deleted) {
uriToOpen = fromURI;
} else if (this.fileChange.status === GitFileStatus.New) {
uriToOpen = toURI;
} else {
uriToOpen = DiffUris.encode(fromURI, toURI);
}
return uriToOpen;
}
}

View File

@@ -0,0 +1,186 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject, optional } from '@theia/core/shared/inversify';
import { MessageService, Emitter, Event } from '@theia/core';
import { ConfirmDialog, QuickInputService } from '@theia/core/lib/browser';
import { GitRepositoryTracker } from './git-repository-tracker';
import { Git, Repository, WorkingDirectoryStatus } from '../common';
import { GitErrorHandler } from './git-error-handler';
@injectable()
export class GitSyncService {
@inject(Git)
protected readonly git: Git;
@inject(GitRepositoryTracker)
protected readonly repositoryTracker: GitRepositoryTracker;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(GitErrorHandler)
protected readonly gitErrorHandler: GitErrorHandler;
@inject(QuickInputService) @optional()
protected readonly quickInputService: QuickInputService;
protected readonly onDidChangeEmitter = new Emitter<void>();
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
protected fireDidChange(): void {
this.onDidChangeEmitter.fire(undefined);
}
protected syncing = false;
isSyncing(): boolean {
return this.syncing;
}
setSyncing(syncing: boolean): void {
this.syncing = syncing;
this.fireDidChange();
}
canSync(): boolean {
if (this.isSyncing()) {
return false;
}
const status = this.repositoryTracker.selectedRepositoryStatus;
return !!status && !!status.branch && !!status.upstreamBranch;
}
async sync(): Promise<void> {
const repository = this.repositoryTracker.selectedRepository;
if (!this.canSync() || !repository) {
return;
}
this.setSyncing(true);
try {
await this.git.fetch(repository);
let status = await this.git.status(repository);
this.setSyncing(false);
const method = await this.getSyncMethod(status);
if (method === undefined) {
return;
}
this.setSyncing(true);
if (method === 'pull-push' || method === 'rebase-push') {
await this.git.pull(repository, {
rebase: method === 'rebase-push'
});
status = await this.git.status(repository);
}
if (this.shouldPush(status)) {
await this.git.push(repository, {
force: method === 'force-push'
});
}
} catch (error) {
this.gitErrorHandler.handleError(error);
} finally {
this.setSyncing(false);
}
}
protected async getSyncMethod(status: WorkingDirectoryStatus): Promise<GitSyncService.SyncMethod | undefined> {
if (!status.upstreamBranch || !status.branch) {
return undefined;
}
const { branch, upstreamBranch } = status;
if (!this.shouldPull(status) && !this.shouldPush(status)) {
this.messageService.info(`${branch} is already in sync with ${upstreamBranch}`);
return undefined;
}
const methods: {
label: string
warning: string
detail: GitSyncService.SyncMethod
}[] = [{
label: `Pull and push commits from and to '${upstreamBranch}'`,
warning: `This action will pull and push commits from and to '${upstreamBranch}'.`,
detail: 'pull-push'
}, {
label: `Fetch, rebase and push commits from and to '${upstreamBranch}'`,
warning: `This action will fetch, rebase and push commits from and to '${upstreamBranch}'.`,
detail: 'rebase-push'
}, {
label: `Force push commits to '${upstreamBranch}'`,
warning: `This action will override commits in '${upstreamBranch}'.`,
detail: 'force-push'
}];
const selectedCWD = await this.quickInputService?.showQuickPick(methods, { placeholder: 'Select current working directory for new terminal' });
if (selectedCWD && await this.confirm('Synchronize Changes', methods.find(({ detail }) => detail === selectedCWD.detail)!.warning)) {
return (selectedCWD.detail as GitSyncService.SyncMethod);
} else {
return (undefined);
}
}
canPublish(): boolean {
if (this.isSyncing()) {
return false;
}
const status = this.repositoryTracker.selectedRepositoryStatus;
return !!status && !!status.branch && !status.upstreamBranch;
}
async publish(): Promise<void> {
const repository = this.repositoryTracker.selectedRepository;
const status = this.repositoryTracker.selectedRepositoryStatus;
const localBranch = status && status.branch;
if (!this.canPublish() || !repository || !localBranch) {
return;
}
const remote = await this.getRemote(repository, localBranch);
if (remote &&
await this.confirm('Publish changes', `This action will push commits to '${remote}/${localBranch}' and track it as an upstream branch.`)
) {
try {
await this.git.push(repository, {
remote, localBranch, setUpstream: true
});
} catch (error) {
this.gitErrorHandler.handleError(error);
}
}
}
protected async getRemote(repository: Repository, branch: string): Promise<string | undefined> {
const remotes = await this.git.remote(repository);
if (remotes.length === 0) {
this.messageService.warn('Your repository has no remotes configured to publish to.');
}
const selectedRemote = await this.quickInputService?.showQuickPick(remotes.map(remote => ({ label: remote })),
{ placeholder: `Pick a remote to publish the branch ${branch} to:` });
return selectedRemote?.label;
}
protected shouldPush(status: WorkingDirectoryStatus): boolean {
return status.aheadBehind ? status.aheadBehind.ahead > 0 : true;
}
protected shouldPull(status: WorkingDirectoryStatus): boolean {
return status.aheadBehind ? status.aheadBehind.behind > 0 : true;
}
protected async confirm(title: string, msg: string): Promise<boolean> {
return !!await new ConfirmDialog({ title, msg, }).open();
}
}
export namespace GitSyncService {
export type SyncMethod = 'pull-push' | 'rebase-push' | 'force-push';
}

View File

@@ -0,0 +1,63 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { LabelProviderContribution, LabelProvider, DidChangeLabelEvent } from '@theia/core/lib/browser/label-provider';
import URI from '@theia/core/lib/common/uri';
import { GIT_RESOURCE_SCHEME } from './git-resource';
@injectable()
export class GitUriLabelProviderContribution implements LabelProviderContribution {
constructor(@inject(LabelProvider) protected labelProvider: LabelProvider) {
}
canHandle(element: object): number {
if (element instanceof URI && element.scheme === GIT_RESOURCE_SCHEME) {
return 20;
}
return 0;
}
getLongName(uri: URI): string {
return this.labelProvider.getLongName(this.toFileUri(uri).withoutQuery().withoutFragment());
}
getName(uri: URI): string {
return this.labelProvider.getName(this.toFileUri(uri)) + this.getTagSuffix(uri);
}
getIcon(uri: URI): string {
return this.labelProvider.getIcon(this.toFileUri(uri));
}
affects(uri: URI, event: DidChangeLabelEvent): boolean {
const fileUri = this.toFileUri(uri);
return event.affects(fileUri) || event.affects(fileUri.withoutQuery().withoutFragment());
}
protected toFileUri(uri: URI): URI {
return uri.withScheme('file');
}
protected getTagSuffix(uri: URI): string {
if (uri.query) {
return ` (${uri.query})`;
} else {
return '';
}
}
}

View File

@@ -0,0 +1,99 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { ScmAvatarService } from '@theia/scm/lib/browser/scm-avatar-service';
import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options';
import { ReactWidget, KeybindingRegistry, codicon } from '@theia/core/lib/browser';
import { Git } from '../../common';
import * as React from '@theia/core/shared/react';
@injectable()
export class GitCommitDetailHeaderWidget extends ReactWidget {
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;
@inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService;
protected options: Git.Options.Diff;
protected authorAvatar: string;
constructor(
@inject(GitCommitDetailWidgetOptions) protected readonly commitDetailOptions: GitCommitDetailWidgetOptions
) {
super();
this.id = 'commit-header' + commitDetailOptions.commitSha;
this.title.label = commitDetailOptions.commitSha.substring(0, 8);
this.options = {
range: {
fromRevision: commitDetailOptions.commitSha + '~1',
toRevision: commitDetailOptions.commitSha
}
};
this.title.closable = true;
this.title.iconClass = codicon('git-commit');
}
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
this.authorAvatar = await this.avatarService.getAvatar(this.commitDetailOptions.authorEmail);
}
protected render(): React.ReactNode {
return React.createElement('div', this.createContainerAttributes(), this.renderDiffListHeader());
}
protected createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
return {
style: { flexGrow: 0 }
};
}
protected renderDiffListHeader(): React.ReactNode {
const authorEMail = this.commitDetailOptions.authorEmail;
const subject = <div className='subject'>{this.commitDetailOptions.commitMessage}</div>;
const body = <div className='body'>{this.commitDetailOptions.messageBody || ''}</div>;
const subjectRow = <div className='header-row'><div className='subjectContainer'>{subject}{body}</div></div>;
const author = <div className='author header-value noWrapInfo'>{this.commitDetailOptions.authorName}</div>;
const mail = <div className='mail header-value noWrapInfo'>{`<${authorEMail}>`}</div>;
const authorRow = <div className='header-row noWrapInfo'><div className='theia-header'>author: </div>{author}</div>;
const mailRow = <div className='header-row noWrapInfo'><div className='theia-header'>e-mail: </div>{mail}</div>;
const authorDate = new Date(this.commitDetailOptions.authorDate);
const dateStr = authorDate.toLocaleDateString('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour12: true,
hour: 'numeric',
minute: 'numeric'
});
const date = <div className='date header-value noWrapInfo'>{dateStr}</div>;
const dateRow = <div className='header-row noWrapInfo'><div className='theia-header'>date: </div>{date}</div>;
const revisionRow = <div className='header-row noWrapInfo'>
<div className='theia-header'>revision: </div>
<div className='header-value noWrapInfo'>{this.commitDetailOptions.commitSha}</div>
</div>;
const gravatar = <div className='image-container'>
<img className='gravatar' src={this.authorAvatar}></img></div>;
const commitInfo = <div className='header-row commit-info-row'>{gravatar}<div className='commit-info'>{authorRow}{mailRow}{dateRow}{revisionRow}</div></div>;
return <div className='diff-header'>{subjectRow}{commitInfo}</div>;
}
}

View File

@@ -0,0 +1,53 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable } from '@theia/core/shared/inversify';
import { WidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options';
import { GitCommitDetailWidget } from './git-commit-detail-widget';
import { GitScmProvider } from '../git-scm-provider';
export namespace GitCommitDetailUri {
export const scheme = GitScmProvider.GIT_COMMIT_DETAIL;
export function toCommitSha(uri: URI): string {
if (uri.scheme === scheme) {
return uri.fragment;
}
throw new Error('The given uri is not an commit detail URI, uri: ' + uri);
}
}
export type GitCommitDetailOpenerOptions = WidgetOpenerOptions & GitCommitDetailWidgetOptions;
@injectable()
export class GitCommitDetailOpenHandler extends WidgetOpenHandler<GitCommitDetailWidget> {
readonly id = GitScmProvider.GIT_COMMIT_DETAIL;
canHandle(uri: URI): number {
try {
GitCommitDetailUri.toCommitSha(uri);
return 200;
} catch {
return 0;
}
}
protected createWidgetOptions(uri: URI, commit: GitCommitDetailOpenerOptions): GitCommitDetailWidgetOptions {
return commit;
}
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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
// *****************************************************************************
export const GitCommitDetailWidgetOptions = Symbol('GitCommitDetailWidgetOptions');
export interface GitCommitDetailWidgetOptions {
rootUri: string;
commitSha: string;
commitMessage: string;
messageBody?: string;
authorName: string;
authorEmail: string;
authorDate: string;
authorDateRelative: string;
}

View File

@@ -0,0 +1,136 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Message } from '@theia/core/shared/@lumino/messaging';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import {
BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, MessageLoop, codicon
} from '@theia/core/lib/browser';
import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options';
import { GitCommitDetailHeaderWidget } from './git-commit-detail-header-widget';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
import { GitDiffTreeModel } from '../diff/git-diff-tree-model';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmPreferences } from '@theia/scm/lib/common/scm-preferences';
@injectable()
export class GitCommitDetailWidget extends BaseWidget implements StatefulWidget {
protected panel: Panel;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(GitCommitDetailHeaderWidget) protected readonly commitDetailHeaderWidget: GitCommitDetailHeaderWidget;
@inject(ScmTreeWidget) protected readonly resourceWidget: ScmTreeWidget;
@inject(GitDiffTreeModel) protected readonly model: GitDiffTreeModel;
@inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences;
set viewMode(mode: 'tree' | 'list') {
this.resourceWidget.viewMode = mode;
}
get viewMode(): 'tree' | 'list' {
return this.resourceWidget.viewMode;
}
constructor(
@inject(GitCommitDetailWidgetOptions) protected readonly options: GitCommitDetailWidgetOptions
) {
super();
this.id = 'commit' + options.commitSha;
this.title.label = options.commitSha.substring(0, 8);
this.title.closable = true;
this.title.iconClass = codicon('git-commit');
this.addClass('theia-scm');
this.addClass('theia-git');
this.addClass('git-diff-container');
}
@postConstruct()
protected init(): void {
const layout = new PanelLayout();
this.layout = layout;
this.panel = new Panel({
layout: new PanelLayout({
})
});
this.panel.node.tabIndex = -1;
this.panel.node.setAttribute('class', 'theia-scm-panel');
layout.addWidget(this.panel);
this.containerLayout.addWidget(this.commitDetailHeaderWidget);
this.containerLayout.addWidget(this.resourceWidget);
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
this.toDispose.push(this.scmPreferences.onPreferenceChanged(e => {
if (e.preferenceName === 'scm.defaultViewMode') {
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
}
}));
const diffOptions = {
range: {
fromRevision: this.options.commitSha + '~1',
toRevision: this.options.commitSha
}
};
this.model.setContent({ rootUri: this.options.rootUri, diffOptions });
}
get containerLayout(): PanelLayout {
return this.panel.layout as PanelLayout;
}
/**
* Updates the view mode based on the preference value.
* @param preference the view mode preference.
*/
protected updateViewMode(preference: 'tree' | 'list'): void {
this.viewMode = preference;
}
protected updateImmediately(): void {
this.onUpdateRequest(Widget.Msg.UpdateRequest);
}
protected override onUpdateRequest(msg: Message): void {
MessageLoop.sendMessage(this.commitDetailHeaderWidget, msg);
MessageLoop.sendMessage(this.resourceWidget, msg);
super.onUpdateRequest(msg);
}
protected override onAfterAttach(msg: Message): void {
this.node.appendChild(this.commitDetailHeaderWidget.node);
this.node.appendChild(this.resourceWidget.node);
super.onAfterAttach(msg);
this.update();
}
storeState(): any {
const state: object = {
changesTreeState: this.resourceWidget.storeState(),
};
return state;
}
restoreState(oldState: any): void {
const { changesTreeState } = oldState;
this.resourceWidget.restoreState(changesTreeState);
}
}

View File

@@ -0,0 +1,60 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { interfaces, Container } from '@theia/core/shared/inversify';
import { WidgetFactory, OpenHandler, TreeModel } from '@theia/core/lib/browser';
import { GitCommitDetailWidgetOptions } from './git-commit-detail-widget-options';
import { GitCommitDetailWidget } from './git-commit-detail-widget';
import { GitCommitDetailHeaderWidget } from './git-commit-detail-header-widget';
import { GitDiffTreeModel } from '../diff/git-diff-tree-model';
import { GitCommitDetailOpenHandler } from './git-commit-detail-open-handler';
import { GitScmProvider } from '../git-scm-provider';
import { createScmTreeContainer } from '@theia/scm/lib/browser/scm-frontend-module';
import { GitResourceOpener } from '../diff/git-resource-opener';
import { GitOpenerInSecondaryArea } from './git-opener-in-secondary-area';
import '../../../src/browser/style/git-icons.css';
export function bindGitHistoryModule(bind: interfaces.Bind): void {
bind(WidgetFactory).toDynamicValue(ctx => ({
id: GitScmProvider.GIT_COMMIT_DETAIL,
createWidget: (options: GitCommitDetailWidgetOptions) => {
const child = createGitCommitDetailWidgetContainer(ctx.container, options);
return child.get(GitCommitDetailWidget);
}
}));
bind(GitCommitDetailOpenHandler).toSelf();
bind(OpenHandler).toService(GitCommitDetailOpenHandler);
}
export function createGitCommitDetailWidgetContainer(parent: interfaces.Container, options: GitCommitDetailWidgetOptions): Container {
const child = createScmTreeContainer(parent);
child.bind(GitCommitDetailWidget).toSelf();
child.bind(GitCommitDetailHeaderWidget).toSelf();
child.bind(GitDiffTreeModel).toSelf();
child.bind(TreeModel).toService(GitDiffTreeModel);
child.bind(GitOpenerInSecondaryArea).toSelf();
child.bind(GitResourceOpener).toService(GitOpenerInSecondaryArea);
child.bind(GitCommitDetailWidgetOptions).toConstantValue(options);
const opener = child.get(GitOpenerInSecondaryArea);
const widget = child.get(GitCommitDetailWidget);
opener.setRefWidget(widget);
return child;
}

View File

@@ -0,0 +1,82 @@
// *****************************************************************************
// Copyright (C) 2019 Arm 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 } from '@theia/core/shared/inversify';
import { Emitter, Disposable } from '@theia/core';
import { Git } from '../../common';
import { ScmHistorySupport, HistoryWidgetOptions } from '@theia/scm-extra/lib/browser/history/scm-history-widget';
import { ScmHistoryCommit } from '@theia/scm-extra/lib/browser/scm-file-change-node';
import { GitScmProvider } from '../git-scm-provider';
import { GitRepositoryTracker } from '../git-repository-tracker';
@injectable()
export class GitHistorySupport implements ScmHistorySupport {
@inject(GitScmProvider) protected readonly provider: GitScmProvider;
@inject(Git) protected readonly git: Git;
@inject(GitRepositoryTracker) protected readonly repositoryTracker: GitRepositoryTracker;
async getCommitHistory(options?: HistoryWidgetOptions): Promise<ScmHistoryCommit[]> {
const repository = this.provider.repository;
const gitOptions: Git.Options.Log = {
uri: options ? options.uri : undefined,
maxCount: options ? options.maxCount : undefined,
range: options?.range,
shortSha: true
};
const commits = await this.git.log(repository, gitOptions);
if (commits.length > 0) {
return commits.map(commit => this.provider.createScmHistoryCommit(commit));
} else {
const pathIsUnderVersionControl = !options || !options.uri || await this.git.lsFiles(repository, options.uri, { errorUnmatch: true });
if (!pathIsUnderVersionControl) {
throw new Error('It is not under version control.');
} else {
throw new Error('No commits have been committed.');
}
}
}
protected readonly onDidChangeHistoryEmitter = new Emitter<void>({
onFirstListenerAdd: () => this.onFirstListenerAdd(),
onLastListenerRemove: () => this.onLastListenerRemove()
});
readonly onDidChangeHistory = this.onDidChangeHistoryEmitter.event;
protected onGitEventDisposable: Disposable | undefined;
protected onFirstListenerAdd(): void {
this.onGitEventDisposable = this.repositoryTracker.onGitEvent(event => {
const { status, oldStatus } = event || { status: undefined, oldStatus: undefined };
let isBranchChanged = false;
let isHeaderChanged = false;
if (oldStatus) {
isBranchChanged = !!status && status.branch !== oldStatus.branch;
isHeaderChanged = !!status && status.currentHead !== oldStatus.currentHead;
}
if (isBranchChanged || isHeaderChanged || oldStatus === undefined) {
this.onDidChangeHistoryEmitter.fire(undefined);
}
});
}
protected onLastListenerRemove(): void {
if (this.onGitEventDisposable) {
this.onGitEventDisposable.dispose();
this.onGitEventDisposable = undefined;
}
}
}

View File

@@ -0,0 +1,51 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 } from '@theia/core/shared/inversify';
import { Widget } from '@theia/core/shared/@lumino/widgets';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { GitResourceOpener } from '../diff/git-resource-opener';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class GitOpenerInSecondaryArea implements GitResourceOpener {
@inject(EditorManager) protected readonly editorManager: EditorManager;
protected refWidget: Widget;
setRefWidget(refWidget: Widget): void {
this.refWidget = refWidget;
}
protected ref: Widget | undefined;
async open(changeUri: URI): Promise<void> {
const ref = this.ref;
const widget = await this.editorManager.open(changeUri, {
mode: 'reveal',
widgetOptions: ref ?
{ area: 'main', mode: 'tab-after', ref } :
{ area: 'main', mode: 'split-right', ref: this.refWidget }
});
this.ref = widget instanceof Widget ? widget : undefined;
if (this.ref) {
this.ref.disposed.connect(() => {
if (this.ref === widget) {
this.ref = undefined;
}
});
}
}
}

View File

@@ -0,0 +1,29 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider';
import { GitPrompt, GitPromptServer, GitPromptServerProxy, GitPromptServerImpl } from '../../common/git-prompt';
export default new ContainerModule(bind => {
bind(GitPrompt).toSelf();
bindPromptServer(bind);
});
export function bindPromptServer(bind: interfaces.Bind): void {
bind(GitPromptServer).to(GitPromptServerImpl).inSingletonScope();
bind(GitPromptServerProxy).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, GitPrompt.WS_PATH)).inSingletonScope();
}

View File

@@ -0,0 +1,108 @@
/********************************************************************************
* Copyright (C) 2018 TypeFox 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
********************************************************************************/
.theia-git.git-diff-container {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
}
.theia-git.git-diff-container .noWrapInfo {
width: 100%;
}
.theia-git .listContainer {
flex: 1;
position: relative;
}
.theia-git .listContainer .commitList {
height: 100%;
}
.theia-git .subject {
font-size: var(--theia-ui-font-size2);
font-weight: bold;
}
.theia-git .revision .row-title {
width: 35px;
display: inline-block;
}
.theia-git .diff-header {
flex-shrink: 0;
}
.theia-git .header-row {
display: flex;
flex-direction: row;
}
.theia-git .header-row.diff-header,
.theia-git .header-row.diff-nav {
margin-bottom: 10px;
}
.theia-git .header-value {
margin: 9px 0px 5px 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theia-git .diff-header .header-value {
align-self: center;
margin: 0px;
}
.theia-git .diff-header .theia-header {
align-self: center;
padding-right: 5px;
}
.theia-git .diff-header .subject {
font-size: var(--theia-ui-font-size2);
font-weight: bold;
}
.theia-git .commit-info {
padding-left: 10px;
box-sizing: border-box;
overflow: hidden;
}
.theia-git .commit-info-row {
align-items: center;
margin-top: 10px;
}
.theia-git .commit-info .header-row {
margin: 4px 0;
}
.theia-git .commit-info .header-row .theia-header {
margin: 1px 0;
}
.theia-git .commit-info .header-row .header-value {
margin: 0 0 0 5px;
}
.theia-git .commit-info-row .image-container {
display: flex;
}

View File

@@ -0,0 +1,6 @@
<!--Copyright (c) Microsoft Corporation. All rights reserved.-->
<!--Copyright (C) 2019 TypeFox and others.-->
<!--Licensed under the MIT License. See License.txt in the project root for license information.-->
<svg fill="#F6F6F6" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="m6 12h-1c-.27-.02-.48-.11-.69-.31s-.3-.42-.31-.69v-6.28c.59-.34 1-.98 1-1.72 0-1.11-.89-2-2-2s-2 .89-2 2c0 .73.41 1.38 1 1.72v6.28c.03.78.34 1.47.94 2.06s1.28.91 2.06.94c0 0 1.02 0 1 0v2l3-3-3-3zm-3-10.2c.66 0 1.2.55 1.2 1.2s-.55 1.2-1.2 1.2-1.2-.55-1.2-1.2.55-1.2 1.2-1.2zm11 9.48c0-1.73 0-6.28 0-6.28-.03-.78-.34-1.47-.94-2.06s-1.28-.91-2.06-.94h-1v-2l-3 3 3 3v-2h1c.27.02.48.11.69.31s.3.42.31.69v6.28c-.59.34-1 .98-1 1.72 0 1.11.89 2 2 2s2-.89 2-2c0-.73-.41-1.38-1-1.72zm-1 2.92c-.66 0-1.2-.55-1.2-1.2s.55-1.2 1.2-1.2 1.2.55 1.2 1.2-.55 1.2-1.2 1.2z" fill="#F6F6F6" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 942 B

View File

@@ -0,0 +1,24 @@
/********************************************************************************
* Copyright (C) 2018 TypeFox 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
********************************************************************************/
.icon-git-commit {
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: url("~octicons/build/svg/git-commit.svg");
-webkit-mask-image: url("~octicons/build/svg/git-commit.svg");
}

View File

@@ -0,0 +1,4 @@
<!--Copyright (c) Microsoft Corporation. All rights reserved.-->
<!--Copyright (C) 2019 TypeFox and others.-->
<!--Licensed under the MIT License. See License.txt in the project root for license information.-->
<svg fill="#F6F6F6" height="28" viewBox="0 0 28 28" width="28" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m20 0c-2.22 0-4 1.78-4 4 0 1.46.82 2.76 2 3.44v2.56l-4 4-4-4v-2.56c1.18-.68 2-1.96 2-3.44 0-2.22-1.78-4-4-4s-4 1.78-4 4c0 1.46.82 2.76 2 3.44v3.56l6 6v3.56c-1.18.68-2 1.96-2 3.44 0 2.22 1.78 4 4 4s4-1.78 4-4c0-1.46-.82-2.76-2-3.44v-3.56l6-6v-3.56c1.18-.68 2-1.96 2-3.44 0-2.22-1.78-4-4-4zm-12 6.4c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4zm6 20c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4zm6-20c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4z" fill="#F6F6F6" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 908 B

View File

@@ -0,0 +1,56 @@
/********************************************************************************
* Copyright (C) 2018 TypeFox 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
********************************************************************************/
.theia-git {
padding: 5px;
box-sizing: border-box;
}
.theia-side-panel .theia-git {
padding-left: 19px;
}
.theia-git .space-between {
justify-content: space-between;
}
.theia-scm .scmItem .git-status.new {
color: var(--theia-gitDecoration-untrackedResourceForeground);
}
.theia-scm .scmItem .git-status.new.staged {
color: var(--theia-gitDecoration-addedResourceForeground);
}
.theia-scm .scmItem .git-status.modified {
color: var(--theia-gitDecoration-modifiedResourceForeground);
}
.theia-scm .scmItem .git-status.deleted {
color: var(--theia-gitDecoration-deletedResourceForeground);
}
.theia-scm .scmItem .git-status.renamed {
color: var(--theia-gitDecoration-untrackedResourceForeground);
}
.theia-scm .scmItem .git-status.conflicted {
color: var(--theia-gitDecoration-conflictingResourceForeground);
}
.theia-scm .scmItem .git-status.copied {
color: var(--theia-gitDecoration-modifiedResourceForeground);
}

View File

@@ -0,0 +1,497 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 URI from '@theia/core/lib/common/uri';
import { Path, nls, isObject } from '@theia/core';
export interface WorkingDirectoryStatus {
/**
* `true` if the repository exists, otherwise `false`.
*/
readonly exists: boolean;
/**
* An array of changed files.
*/
readonly changes: GitFileChange[];
/**
* The optional name of the branch. Can be absent.
*/
readonly branch?: string;
/**
* The name of the upstream branch. Optional.
*/
readonly upstreamBranch?: string;
/**
* Wraps the `ahead` and `behind` numbers.
*/
readonly aheadBehind?: { ahead: number, behind: number };
/**
* The hash string of the current HEAD.
*/
readonly currentHead?: string;
/**
* `true` if a limit was specified and reached during get `git status`, so this result is not complete. Otherwise, (including `undefined`) is complete.
*/
readonly incomplete?: boolean;
}
export namespace WorkingDirectoryStatus {
/**
* `true` if the directory statuses are deep equal, otherwise `false`.
*/
export function equals(left: WorkingDirectoryStatus | undefined, right: WorkingDirectoryStatus | undefined): boolean {
if (left && right) {
return left.exists === right.exists
&& left.branch === right.branch
&& left.upstreamBranch === right.upstreamBranch
&& left.currentHead === right.currentHead
&& (left.aheadBehind ? left.aheadBehind.ahead : -1) === (right.aheadBehind ? right.aheadBehind.ahead : -1)
&& (left.aheadBehind ? left.aheadBehind.behind : -1) === (right.aheadBehind ? right.aheadBehind.behind : -1)
&& left.changes.length === right.changes.length
&& !!left.incomplete === !!right.incomplete
&& JSON.stringify(left) === JSON.stringify(right);
} else {
return left === right;
}
}
}
/**
* Enumeration of states that a file resource can have in the working directory.
*/
export enum GitFileStatus {
'New',
'Copied',
'Modified',
'Renamed',
'Deleted',
'Conflicted',
}
export namespace GitFileStatus {
/**
* Compares the statuses based on the natural order of the enumeration.
*/
export const statusCompare = (left: GitFileStatus, right: GitFileStatus): number => left - right;
/**
* Returns with human readable representation of the Git file status argument. If the `staged` argument is `undefined`,
* it will be treated as `false`.
*/
export const toString = (status: GitFileStatus, staged?: boolean): string => {
switch (status) {
case GitFileStatus.New: return !!staged ? nls.localize('theia/git/added', 'Added') : nls.localize('theia/git/unstaged', 'Unstaged');
case GitFileStatus.Renamed: return nls.localize('theia/git/renamed', 'Renamed');
case GitFileStatus.Copied: return nls.localize('theia/git/copied', 'Copied');
// eslint-disable-next-line @theia/localization-check
case GitFileStatus.Modified: return nls.localize('vscode.git/repository/modified', 'Modified');
// eslint-disable-next-line @theia/localization-check
case GitFileStatus.Deleted: return nls.localize('vscode.git/repository/deleted', 'Deleted');
case GitFileStatus.Conflicted: return nls.localize('theia/git/conflicted', 'Conflicted');
default: throw new Error(`Unexpected Git file stats: ${status}.`);
}
};
/**
* Returns with the human readable abbreviation of the Git file status argument. `staged` argument defaults to `false`.
*/
export const toAbbreviation = (status: GitFileStatus, staged?: boolean): string => {
switch (status) {
case GitFileStatus.New: return !!staged ? 'A' : 'U';
case GitFileStatus.Renamed: return 'R';
case GitFileStatus.Copied: return 'C';
case GitFileStatus.Modified: return 'M';
case GitFileStatus.Deleted: return 'D';
case GitFileStatus.Conflicted: return 'C';
default: throw new Error(`Unexpected Git file stats: ${status}.`);
}
};
/**
* It should be aligned with https://github.com/microsoft/vscode/blob/0dfa355b3ad185a6289ba28a99c141ab9e72d2be/extensions/git/src/repository.ts#L197
*/
export function getColor(status: GitFileStatus, staged?: boolean): string {
switch (status) {
case GitFileStatus.New: {
if (!staged) {
return 'var(--theia-gitDecoration-untrackedResourceForeground)';
}
return 'var(--theia-gitDecoration-addedResourceForeground)';
}
case GitFileStatus.Renamed: return 'var(--theia-gitDecoration-untrackedResourceForeground)';
case GitFileStatus.Copied: // Fall through.
case GitFileStatus.Modified: return 'var(--theia-gitDecoration-modifiedResourceForeground)';
case GitFileStatus.Deleted: return 'var(--theia-gitDecoration-deletedResourceForeground)';
case GitFileStatus.Conflicted: return 'var(--theia-gitDecoration-conflictingResourceForeground)';
}
}
export function toStrikethrough(status: GitFileStatus): boolean {
return status === GitFileStatus.Deleted;
}
}
/**
* Representation of an individual file change in the working directory.
*/
export interface GitFileChange {
/**
* The current URI of the changed file resource.
*/
readonly uri: string;
/**
* The file status.
*/
readonly status: GitFileStatus;
/**
* The previous URI of the changed URI. Can be absent if the file is new, or just changed and so on.
*/
readonly oldUri?: string;
/**
* `true` if the file is staged or committed, `false` if not staged. If absent, it means not staged.
*/
readonly staged?: boolean;
}
/**
* An object encapsulating the changes to a committed file.
*/
export interface CommittedFileChange extends GitFileChange {
/**
* A commit SHA or some other identifier that ultimately dereferences to a commit.
* This is the pointer to the `after` version of this change. For instance, the parent of this
* commit will contain the `before` (or nothing, if the file change represents a new file).
*/
readonly commitish: string;
}
/**
* Bare minimum representation of a local Git clone.
*/
export interface Repository {
/**
* The FS URI of the local clone.
*/
readonly localUri: string;
}
export namespace Repository {
export function equal(repository: Repository | undefined, repository2: Repository | undefined): boolean {
if (repository && repository2) {
return repository.localUri === repository2.localUri;
}
return repository === repository2;
}
export function is(repository: unknown): repository is Repository {
return isObject(repository) && 'localUri' in repository;
}
export function relativePath(repository: Repository | URI, uri: URI | string): Path | undefined {
const repositoryUri = new URI(Repository.is(repository) ? repository.localUri : String(repository));
return repositoryUri.relative(new URI(String(uri)));
}
}
/**
* Representation of a Git remote.
*/
export interface Remote {
/**
* The name of the remote.
*/
readonly name: string,
/**
* The remote fetch url.
*/
readonly fetch: string,
/**
* The remote git url.
*/
readonly push: string,
}
/**
* The branch type. Either local or remote.
* The order matters.
*/
export enum BranchType {
/**
* The local branch type.
*/
Local = 0,
/**
* The remote branch type.
*/
Remote = 1
}
/**
* Representation of a Git branch.
*/
export interface Branch {
/**
* The short name of the branch. For instance; `master`.
*/
readonly name: string;
/**
* The remote-prefixed upstream name. For instance; `origin/master`.
*/
readonly upstream?: string;
/**
* The type of branch. Could be either [local](BranchType.Local) or [remote](BranchType.Remote).
*/
readonly type: BranchType;
/**
* The commit associated with this branch.
*/
readonly tip: Commit;
/**
* The name of the remote of the upstream.
*/
readonly remote?: string;
/**
* The name of the branch's upstream without the remote prefix.
*/
readonly upstreamWithoutRemote?: string;
/**
* The name of the branch without the remote prefix. If the branch is a local
* branch, this is the same as its `name`.
*/
readonly nameWithoutRemote: string;
}
/**
* Representation of a Git tag.
*/
export interface Tag {
/**
* The name of the tag.
*/
readonly name: string;
}
/**
* A Git commit.
*/
export interface Commit {
/**
* The commit SHA.
*/
readonly sha: string;
/**
* The first line of the commit message.
*/
readonly summary: string;
/**
* The commit message without the first line and CR.
*/
readonly body?: string;
/**
* Information about the author of this commit. It includes name, email and date.
*/
readonly author: CommitIdentity;
/**
* The SHAs for the parents of the commit.
*/
readonly parentSHAs?: string[];
}
/**
* Representation of a Git commit, plus the changes that were performed in that particular commit.
*/
export interface CommitWithChanges extends Commit {
/**
* The date when the commit was authored (ISO format).
*/
readonly authorDateRelative: string;
/**
* The file changes in the commit.
*/
readonly fileChanges: GitFileChange[];
}
/**
* A tuple of name, email, and a date for the author or commit info in a commit.
*/
export interface CommitIdentity {
/**
* The name for the commit.
*/
readonly name: string;
/**
* The email address for the user who did the commit.
*/
readonly email: string;
/**
* The date of the commit in ISO format.
*/
readonly timestamp: string;
}
/**
* The result of shelling out to Git.
*/
export interface GitResult {
/**
* The standard output from Git.
*/
readonly stdout: string;
/**
* The standard error output from Git.
*/
readonly stderr: string;
/**
* The exit code of the Git process.
*/
readonly exitCode: number;
}
/**
* StashEntry
*/
export interface StashEntry {
readonly id: string;
readonly message: string;
}
/**
* The Git errors which can be parsed from failed Git commands.
*/
export enum GitError {
SSHKeyAuditUnverified = 0,
SSHAuthenticationFailed = 1,
SSHPermissionDenied = 2,
HTTPSAuthenticationFailed = 3,
RemoteDisconnection = 4,
HostDown = 5,
RebaseConflicts = 6,
MergeConflicts = 7,
HTTPSRepositoryNotFound = 8,
SSHRepositoryNotFound = 9,
PushNotFastForward = 10,
BranchDeletionFailed = 11,
DefaultBranchDeletionFailed = 12,
RevertConflicts = 13,
EmptyRebasePatch = 14,
NoMatchingRemoteBranch = 15,
NoExistingRemoteBranch = 16,
NothingToCommit = 17,
NoSubmoduleMapping = 18,
SubmoduleRepositoryDoesNotExist = 19,
InvalidSubmoduleSHA = 20,
LocalPermissionDenied = 21,
InvalidMerge = 22,
InvalidRebase = 23,
NonFastForwardMergeIntoEmptyHead = 24,
PatchDoesNotApply = 25,
BranchAlreadyExists = 26,
BadRevision = 27,
NotAGitRepository = 28,
CannotMergeUnrelatedHistories = 29,
LFSAttributeDoesNotMatch = 30,
BranchRenameFailed = 31,
PathDoesNotExist = 32,
InvalidObjectName = 33,
OutsideRepository = 34,
LockFileAlreadyExists = 35,
NoMergeToAbort = 36,
LocalChangesOverwritten = 37,
UnresolvedConflicts = 38,
GPGFailedToSignData = 39,
ConflictModifyDeletedInBranch = 40,
// GitHub-specific error codes
PushWithFileSizeExceedingLimit = 41,
HexBranchNameRejected = 42,
ForcePushRejected = 43,
InvalidRefLength = 44,
ProtectedBranchRequiresReview = 45,
ProtectedBranchForcePush = 46,
ProtectedBranchDeleteRejected = 47,
ProtectedBranchRequiredStatus = 48,
PushWithPrivateEmail = 49,
// End of GitHub-specific error codes
ConfigLockFileAlreadyExists = 50,
RemoteAlreadyExists = 51,
TagAlreadyExists = 52,
MergeWithLocalChanges = 53,
RebaseWithLocalChanges = 54,
MergeCommitNoMainlineOption = 55,
UnsafeDirectory = 56,
PathExistsButNotInRef = 57
}
export interface GitFileBlame {
readonly uri: string;
readonly commits: Commit[];
readonly lines: CommitLine[];
}
export interface CommitLine {
readonly sha: string;
readonly line: number;
}

View File

@@ -0,0 +1,94 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { interfaces } from '@theia/core/shared/inversify';
import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema, PreferenceScope } from '@theia/core/lib/common';
import { nls } from '@theia/core/lib/common/nls';
/* eslint-disable max-len */
export const GitConfigSchema: PreferenceSchema = {
'properties': {
'git.decorations.enabled': {
'type': 'boolean',
'description': nls.localize('vscode.git/package/config.decorations.enabled', 'Show Git file status in the navigator.'),
'default': true
},
'git.decorations.colors': {
'type': 'boolean',
'description': nls.localize('theia/git/gitDecorationsColors', 'Use color decoration in the navigator.'),
'default': true
},
'git.editor.decorations.enabled': {
'type': 'boolean',
'description': nls.localize('theia/git/editorDecorationsEnabled', 'Show git decorations in the editor.'),
'default': true
},
'git.editor.dirtyDiff.linesLimit': {
'type': 'number',
'description': nls.localize('theia/git/dirtyDiffLinesLimit', 'Do not show dirty diff decorations, if editor\'s line count exceeds this limit.'),
'default': 1000
},
'git.alwaysSignOff': {
'type': 'boolean',
'description': nls.localize('vscode.git/package/config.alwaysSignOff', 'Always sign off commits.'),
'default': false
},
'git.untrackedChanges': {
type: 'string',
enum: [
nls.localize('theia/scm/config.untrackedChanges.mixed', 'mixed'),
nls.localize('theia/scm/config.untrackedChanges.separate', 'separate'),
nls.localize('theia/scm/config.untrackedChanges.hidden', 'hidden')
],
enumDescriptions: [
nls.localize('theia/scm/config.untrackedChanges.mixed/description', 'All changes, tracked and untracked, appear together and behave equally.'),
nls.localize('theia/scm/config.untrackedChanges.separate/description', 'Untracked changes appear separately in the Source Control view. They are also excluded from several actions.'),
nls.localize('theia/scm/config.untrackedChanges.hidden/description', 'Untracked changes are hidden and excluded from several actions.'),
],
description: nls.localize('theia/scm/config.untrackedChanges', 'Controls how untracked changes behave.'),
default: 'mixed',
scope: PreferenceScope.Folder,
}
}
};
export interface GitConfiguration {
'git.decorations.enabled': boolean,
'git.decorations.colors': boolean,
'git.editor.decorations.enabled': boolean,
'git.editor.dirtyDiff.linesLimit': number,
'git.alwaysSignOff': boolean,
'git.untrackedChanges': 'mixed' | 'separate' | 'hidden';
}
export const GitPreferenceContribution = Symbol('GitPreferenceContribution');
export const GitPreferences = Symbol('GitPreferences');
export type GitPreferences = PreferenceProxy<GitConfiguration>;
export function createGitPreferences(preferences: PreferenceService, schema: PreferenceSchema = GitConfigSchema): GitPreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindGitPreferences(bind: interfaces.Bind): void {
bind(GitPreferences).toDynamicValue(ctx => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(GitPreferenceContribution);
return createGitPreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(GitPreferenceContribution).toConstantValue({ schema: GitConfigSchema });
bind(PreferenceContribution).toService(GitPreferenceContribution);
}

View File

@@ -0,0 +1,173 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { RpcProxy, RpcServer } from '@theia/core/lib/common/messaging/proxy-factory';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
export const GitPromptServer = Symbol('GitPromptServer');
export interface GitPromptServer extends RpcServer<GitPromptClient> {
}
export const GitPromptServerProxy = Symbol('GitPromptServerProxy');
export interface GitPromptServerProxy extends RpcProxy<GitPromptServer> {
}
@injectable()
export class GitPrompt implements GitPromptClient, Disposable {
@inject(GitPromptServer)
protected readonly server: GitPromptServer;
protected readonly toDispose = new DisposableCollection();
@postConstruct()
protected init(): void {
this.server.setClient(this);
}
dispose(): void {
this.toDispose.dispose();
}
async ask(question: GitPrompt.Question): Promise<GitPrompt.Answer> {
return GitPrompt.Failure.create('Interactive Git prompt is not supported in the browser.');
}
}
export namespace GitPrompt {
/**
* Unique WS endpoint path for the Git prompt service.
*/
export const WS_PATH = 'services/git-prompt';
export interface Question {
readonly text: string;
readonly details?: string;
readonly password?: boolean;
}
export interface Answer {
readonly type: Answer.Type;
}
export interface Success {
readonly type: Answer.Type.SUCCESS;
readonly result: string | boolean;
}
export namespace Success {
export function is(answer: Answer): answer is Success {
return answer.type === Answer.Type.SUCCESS
&& 'result' in answer
&& ((typeof (answer as Success).result) === 'string' || (typeof (answer as Success).result) === 'boolean');
}
export function create(result: string | boolean): Success {
return {
type: Answer.Type.SUCCESS,
result
};
}
}
export interface Cancel extends Answer {
readonly type: Answer.Type.CANCEL;
}
export namespace Cancel {
export function is(answer: Answer): answer is Cancel {
return answer.type === Answer.Type.CANCEL;
}
export function create(): Cancel {
return {
type: Answer.Type.CANCEL
};
}
}
export interface Failure extends Answer {
readonly type: Answer.Type.FAILURE;
readonly error: string | Error;
}
export namespace Failure {
export function is(answer: Answer): answer is Failure {
return answer.type === Answer.Type.FAILURE
&& 'error' in answer
&& ((typeof (answer as Failure).error) === 'string' || (answer as Failure).error instanceof Error);
}
export function create(error: string | Error): Failure {
return {
type: Answer.Type.FAILURE,
error
};
}
}
export namespace Answer {
export enum Type {
SUCCESS,
CANCEL,
FAILURE
}
}
}
export const GitPromptClient = Symbol('GitPromptClient');
export interface GitPromptClient {
ask(question: GitPrompt.Question): Promise<GitPrompt.Answer>;
// TODO: implement `confirm` with boolean return type.
// TODO: implement `select` with possible answers.
}
/**
* Note: This implementation is not reconnecting.
* Git prompting is not supported in the browser. In electron, there's no need to reconnect.
*/
@injectable()
export class GitPromptServerImpl implements GitPromptServer {
@inject(GitPromptServerProxy)
protected readonly proxy: GitPromptServerProxy;
setClient(client: GitPromptClient): void {
this.proxy.setClient(client);
}
dispose(): void {
this.proxy.dispose();
}
}

View File

@@ -0,0 +1,184 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { RpcServer, RpcProxy, isObject } from '@theia/core';
import { Repository, WorkingDirectoryStatus } from './git-model';
import { Disposable, DisposableCollection, Emitter, Event } from '@theia/core/lib/common';
/**
* An event representing a `Git` status change in one of the watched local working directory.
*/
export interface GitStatusChangeEvent {
/**
* The source `Git` repository where the event belongs to.
*/
readonly source: Repository;
/**
* The new working directory state.
*/
readonly status: WorkingDirectoryStatus;
/**
* The previous working directory state, if any.
*/
readonly oldStatus?: WorkingDirectoryStatus;
}
export namespace GitStatusChangeEvent {
/**
* `true` if the argument is a `GitStatusEvent`, otherwise `false`.
* @param event the argument to check whether it is a Git status change event or not.
*/
export function is(event: unknown): event is GitStatusChangeEvent {
return isObject(event) && ('source' in event) && ('status' in event);
}
}
/**
* Client watcher for `Git`.
*/
export interface GitWatcherClient {
/**
* Invoked with the event that encapsulates the status change in the repository.
*/
onGitChanged(event: GitStatusChangeEvent): Promise<void>;
}
/**
* The symbol of the Git watcher backend for DI.
*/
export const GitWatcherServer = Symbol('GitWatcherServer');
/**
* Service representation communicating between the backend and the frontend.
*/
export interface GitWatcherServer extends RpcServer<GitWatcherClient> {
/**
* Watches status changes in the given repository.
*/
watchGitChanges(repository: Repository): Promise<number>;
/**
* De-registers any previously added watchers identified by the unique `watcher` argument. If the watcher cannot be found
* with its unique ID, the request will be rejected.
*/
unwatchGitChanges(watcher: number): Promise<void>;
}
export const GitWatcherServerProxy = Symbol('GitWatcherServerProxy');
export type GitWatcherServerProxy = RpcProxy<GitWatcherServer>;
@injectable()
export class ReconnectingGitWatcherServer implements GitWatcherServer {
private watcherSequence = 1;
private readonly watchParams = new Map<number, Repository>();
private readonly localToRemoteWatcher = new Map<number, number>();
constructor(
@inject(GitWatcherServerProxy) private readonly proxy: GitWatcherServerProxy
) {
this.proxy.onDidOpenConnection(() => this.reconnect());
}
async watchGitChanges(repository: Repository): Promise<number> {
const watcher = this.watcherSequence++;
this.watchParams.set(watcher, repository);
return this.doWatchGitChanges([watcher, repository]);
}
async unwatchGitChanges(watcher: number): Promise<void> {
this.watchParams.delete(watcher);
const remote = this.localToRemoteWatcher.get(watcher);
if (remote) {
this.localToRemoteWatcher.delete(remote);
return this.proxy.unwatchGitChanges(remote);
} else {
throw new Error(`No Git watchers were registered with ID: ${watcher}.`);
}
}
dispose(): void {
this.proxy.dispose();
}
setClient(client: GitWatcherClient): void {
this.proxy.setClient(client);
}
private reconnect(): void {
[...this.watchParams.entries()].forEach(entry => this.doWatchGitChanges(entry));
}
private async doWatchGitChanges(entry: [number, Repository]): Promise<number> {
const [watcher, repository] = entry;
const remote = await this.proxy.watchGitChanges(repository);
this.localToRemoteWatcher.set(watcher, remote);
return watcher;
}
}
/**
* Unique WS endpoint path to the Git watcher service.
*/
export const GitWatcherPath = '/services/git-watcher';
@injectable()
export class GitWatcher implements GitWatcherClient, Disposable {
private readonly toDispose: DisposableCollection;
private readonly onGitEventEmitter: Emitter<GitStatusChangeEvent>;
constructor(
@inject(GitWatcherServer) private readonly server: GitWatcherServer
) {
this.toDispose = new DisposableCollection();
this.onGitEventEmitter = new Emitter<GitStatusChangeEvent>();
this.toDispose.push(this.onGitEventEmitter);
this.server.setClient({ onGitChanged: e => this.onGitChanged(e) });
}
dispose(): void {
this.toDispose.dispose();
}
get onGitEvent(): Event<GitStatusChangeEvent> {
return this.onGitEventEmitter.event;
}
async onGitChanged(event: GitStatusChangeEvent): Promise<void> {
this.onGitEventEmitter.fire(event);
}
async watchGitChanges(repository: Repository): Promise<Disposable> {
const watcher = await this.server.watchGitChanges(repository);
const toDispose = new DisposableCollection();
toDispose.push(Disposable.create(() => this.server.unwatchGitChanges(watcher)));
return toDispose;
}
}

View File

@@ -0,0 +1,955 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { ChildProcess } from 'child_process';
import { Disposable, isObject } from '@theia/core';
import {
Repository, WorkingDirectoryStatus, Branch, GitResult, GitError, GitFileStatus,
GitFileChange, CommitWithChanges, GitFileBlame, Remote as RemoteModel, StashEntry
} from './git-model';
/**
* The WS endpoint path to the Git service.
*/
export const GitPath = '/services/git';
/**
* Git symbol for DI.
*/
export const Git = Symbol('Git');
export namespace Git {
/**
* Options for various Git commands.
*/
export namespace Options {
/**
* Refinements for the `git branch` command.
*/
export namespace BranchCommand {
/**
* Option for listing branches in a Git repository.
*/
export interface List {
/**
* The type of the branches that has to be listed. If not
* - `current` returns with the name of the currently active branch.
* - `local` lists all locally available branch names.
* - `remote` for listing all remote branches. One might has to perform a `git fetch` to integrate all the remote branches.
* - `all` lists all remote and local branches including the currently active one.
*/
readonly type: 'current' | 'local' | 'remote' | 'all';
}
/**
* Option for creating a new branch.
*/
export interface Create {
/**
* The desired name of the new branch.
*/
readonly toCreate: string;
/**
* The new branch head will point to this commit. It may be given as a branch name, a commit-id, or a tag.
* If this option is omitted, the current `HEAD` will be used instead.
*/
readonly startPoint?: string;
}
/**
* Option for deleting a branch. The branch must be fully merged in its upstream branch, or in `HEAD` if no upstream was set.
*/
export interface Delete {
/**
* The name of the branch to delete.
*/
readonly toDelete: string;
/**
* When set to `true`, then allows deleting the branch irrespective of its merged status. `false` by default.
*/
readonly force?: boolean;
/**
* When set to `true` then deletes the remote-tracking branches as well. It is `false` by default.
*/
readonly remote?: boolean;
}
/**
* Option for renaming an existing branch.
*/
export interface Rename {
/**
* The desired new name of the branch.
*/
readonly newName: string;
/**
* The name of the branch to rename. If not given, then the currently active branch will be renamed.
*/
readonly oldName?: string;
/**
* If set to `true`, the allows renaming the branch even if the new branch name already exists. It is `false` by default.
*/
readonly force?: boolean;
}
}
/**
* Git clone options.
*/
export interface Clone {
/**
* The desired destination path (given as a URI) for the cloned repository.
* If the path does not exist it will be created. Cloning into an existing
* directory is only allowed if the directory is empty.
*/
readonly localUri: string;
/**
* The branch to checkout after the clone has completed. If not given,
* the default branch will will be the current one which is usually the `master`.
*/
readonly branch?: string;
}
/**
* Git repositories options.
*/
export interface Repositories {
/**
* The maximum count of repositories to look up, should be greater than 0.
* Undefined to look up all repositories.
*/
readonly maxCount?: number;
}
/**
* Further refinements for unstaging files from either from the index or from the working-tree. Alternatively, resetting both.
*/
export interface Unstage {
/**
* What to reset; the state of the index, the working-tree, or both. If not given, `all` will be used.
*/
readonly reset?: 'index' | 'working-tree' | 'all';
/**
* The treeish to reset to. Defaults to `HEAD`.
*/
readonly treeish?: string;
}
/**
* Options for further `git checkout` refinements.
*/
export namespace Checkout {
/**
* Options for checking out branches.
*/
export interface CheckoutBranch {
/**
* Branch to checkout; if it refers to a branch, then that branch is checked out.
* Otherwise, if it refers to a valid commit, your `HEAD` becomes "detached" and you are no
* longer on any branch.
*/
readonly branch: string;
/**
* When switching branches, proceed even if the index or the working tree differs from `HEAD`.
* This is used to throw away local changes.
*/
readonly force?: boolean;
/**
* When switching branches, if you have local modifications to one or more files that are different
* between the current branch and the branch to which you are switching, the command refuses to
* switch branches in order to preserve your modifications in context. However, with this option,
* a three-way merge between the current branch, your working tree contents, and the new branch is done,
* and you will be on the new branch.
*/
readonly merge?: boolean;
/**
* The name for the new local branch.
*/
readonly newBranch?: string;
}
/**
* Options for checking out files from the working tree.
*
* - When trying to revert a resource to the state of the index, set `paths`.
* - When trying to revert the state of a resource to the repository `HEAD`, then set `paths` and `treeish` to `HEAD`.
* - If you would like to check out the state of a file from the `HEAD` of a branch, set `treeish` to `nameOfTheBranch`.
* - And if you would like to check out a historical revision of a branch, set `treeish` to `nameOfTheBranch~2` which will be
* two commits before the most recent one on the desired branch.
*/
export interface WorkingTreeFile {
/**
* This is used to restore modified or deleted paths to their original contents from the index or replace
* paths with the contents from a named tree-ish (most often a commit-ish).
*
* One can specify a regular expression for the paths, in such cases, it must be escaped with single-quotes.
* For instance checking out a `Hello.ts` file will be simply `"Hello.ts"`, if one would like to checkout
* all TS files, then this for should be used: ```ts
* const options = {
* paths: `'*.ts'`
* }
* ```.
*/
readonly paths: string | string[];
/**
* When checking out paths from the index, do not fail upon unmerged entries; instead, unmerged
* entries are ignored.
*/
readonly force?: boolean;
/**
* When checking out paths from the index, this option lets you recreate the conflicted merge in the
* specified paths.
*/
readonly merge?: boolean;
/**
* Tree to checkout from. If not specified, the index will be used. `git checkout -- ./fileName.ext`.
* If you want to get the state from the repository ,use `HEAD` which will be equivalent with `git checkout HEAD -- ./fileName.ext`.
*/
readonly treeish?: string;
}
}
/**
* Options for the `git commit` command refinement.
*/
export interface Commit {
/**
* If `true` replaces the tip of the current branch by creating a new commit.
* The recorded tree is prepared as usual, and the message from the original commit is used as the starting point, instead of an empty message,
* when no other message is specified. The new commit has the same parents and author as the current one. Defaults to `false`.
*/
readonly amend?: boolean;
/**
* Adds the `Signed-off-by` line by the committer at the end of the commit log message. `false` by default.
*/
readonly signOff?: boolean;
}
/**
* Options for further refining the `git show` command.
*/
export interface Show {
/**
* The desired encoding which should be used when retrieving the file content.
* `utf8` should be used for text content and `binary` for blobs. The default one is `utf8`.
*/
readonly encoding?: 'utf8' | 'binary';
/**
* A commit SHA or some other identifier that ultimately dereferences to a commit/tree.
* `HEAD` is the local `HEAD`, and `index` is the the staged. If not specified,
* then `index` will be used instead. But this can be `HEAD~2` or `ed14ef1~1` where `ed14ef1` is a commit hash.
*/
readonly commitish?: 'index' | string;
}
/**
* Options for further refining the `git stash` command.
*/
export interface Stash {
/**
* The kind of stash action.
*/
readonly action?: 'push' | 'apply' | 'pop' | 'list' | 'drop' | 'clear';
/**
* The stash id.
* This is an optional argument for actions of kind 'apply', 'pop' and 'drop'.
*/
readonly id?: string;
/**
* The stash message.
* This is an optional argument for the `push` action.
*/
readonly message?: string;
}
/**
* Options for the `git fetch` command.
*/
export interface Fetch {
/**
* The name of the remote to fetch from. If not given, then the default remote will be used. Defaults to the `origin`.
*/
readonly remote?: string;
}
/**
* Further refinements for the `git push` command.
*/
export interface Push {
/**
* The name of the remote to push to. If not given, then the default remote will be used. It is the `origin` by default.
*/
readonly remote?: string;
/**
* The name of the local branch to push. If not given, then the currently active branch will be used instead.
*/
readonly localBranch?: string;
/**
* The name of the remote branch to push to. If not given then the changes will be pushed to the remote branch associated with the
* local branch.
*
* `git push <remote> <localBranch>:<remoteBranch>`
*/
readonly remoteBranch?: string;
/**
* Set upstream for every branch that is up to date or successfully pushed,
* add upstream (tracking) reference, used by argument-less git-pull and other commands.
*/
readonly setUpstream?: boolean;
/**
* Usually, the command refuses to update a remote ref that is not an ancestor of the local ref used to overwrite it.
* This flag disables these checks, and can cause the remote repository to lose commits; use it with care.
*/
readonly force?: boolean;
}
/**
* Options for the `git pull` command.
*/
export interface Pull {
/**
* The name of the remote to pull from. If not given, then the default remote will be used. Defaults to the `origin`.
*/
readonly remote?: string;
/**
* The name of the branch to pull form. This is required when one performs a `git pull` from a remote which is not
* the default remote tracking of the currently active branch.
*/
readonly branch?: string;
/**
* When true, rebase the current branch on top of the upstream branch after fetching.
*/
readonly rebase?: boolean
}
/**
* Additional technical rectifications for the `git reset` command.
*/
export interface Reset {
/**
* The `git reset` mode. The followings are supported:
* - `hard`,
* - `sort`, or
* - `mixed`.
*
* Those correspond to the consecutive `--hard`, `--soft`, and `--mixed` Git options.
*/
readonly mode: 'hard' | 'soft' | 'mixed';
/**
* The reference to reset to. By default, resets to `HEAD`.
*/
readonly ref?: string;
}
/**
* Additional options for the `git merge` command.
*/
export interface Merge {
/**
* The name of the branch that should be merged into the current branch.
*/
readonly branch: string;
}
/**
* A set of configuration options that can be passed when executing a low-level Git command.
*/
export interface Execution {
/**
* The exit codes which indicate success to the caller. Unexpected exit codes will be logged and an
* error thrown. Defaults to `0` if `undefined`.
*/
readonly successExitCodes?: ReadonlyArray<number>;
/**
* The Git errors which are expected by the caller. Unexpected errors will
* be logged and an error thrown.
*/
readonly expectedErrors?: ReadonlyArray<GitError>;
/**
* An optional collection of key-value pairs which will be
* set as environment variables before executing the Git
* process.
*/
readonly env?: Object;
/**
* An optional string which will be written to the child process
* stdin stream immediately after spawning the process.
*/
readonly stdin?: string;
/**
* The encoding to use when writing to `stdin`, if the `stdin`
* parameter is a string.
*/
readonly stdinEncoding?: string;
/**
* The size the output buffer to allocate to the spawned process. Set this
* if you are anticipating a large amount of output.
*
* If not specified, this will be 10MB (10485760 bytes) which should be
* enough for most Git operations.
*/
readonly maxBuffer?: number;
/**
* An optional callback which will be invoked with the child
* process instance after spawning the Git process.
*
* Note that if the `stdin` parameter was specified the `stdin`
* stream will be closed by the time this callback fires.
*
* Defining this property could make the `exec` function invocation **non-client** compatible.
*/
readonly processCallback?: (process: ChildProcess) => void;
/**
* The name for the command based on its caller's context.
* This could be used only for performance measurements and debugging. It has no runtime behavior effects.
*/
readonly name?: string;
}
/**
* Range that is used for representing to individual commitish when calculating either `git log` or `git diff`.
*/
export interface Range {
/**
* The last revision that should be included among the result running this query. Here, the revision can be a tag, a commitish,
* or even an expression (`HEAD~3`). For more details to specify the revision, see [here](https://git-scm.com/docs/gitrevisions#_specifying_revisions).
*/
readonly toRevision?: string;
/**
* Either the from revision (`string`) or a positive integer that is equivalent to the `~` suffix, which means the commit object that is the `fromRevision`<sup>th</sup>
* generation ancestor of the named, `toRevision` commit object, following only the first parents. If not specified, equivalent to `origin..toRevision`.
*/
readonly fromRevision?: number | string;
}
/**
* Optional configuration for the `git diff` command.
*/
export interface Diff {
/**
* The Git revision range that will be used when calculating the diff.
*/
readonly range?: Range;
/**
* The URI of the resource in the repository to get the diff. Can be an individual file or a directory.
*/
readonly uri?: string;
}
/**
* Optional configuration for the `git log` command.
*/
export interface Log extends Diff {
/**
* The name of the branch to run the `git log` command. If not specified, then the currently active branch will be used.
*/
readonly branch?: string;
/**
* Limits the number of commits. Also known as `-n` or `--number. If not specified, or not a positive integer, then will be ignored, and the returning list
* of commits will not be limited.
*/
readonly maxCount?: number;
/**
* Decides whether the commit hash should be the abbreviated version.
*/
readonly shortSha?: boolean;
}
/**
* Optional configuration for the `git blame` command.
*/
export interface Blame {
/**
* Dirty state content.
*/
readonly content?: string;
}
/**
* Further refinements for the `git ls-files`.
*/
export interface LsFiles {
/**
* If any the file does not appear in the index, treat this as an error that results in the error code 1.
*/
readonly errorUnmatch?: true;
}
/**
* Options for the `git remote` command.
*/
export interface Remote {
/**
* Be more verbose and get remote url for `fetch` and `push` actions.
*/
readonly verbose?: true,
}
/**
* Options for the `git rev-parse` command.
*/
export interface RevParse {
/**
* The reference to parse.
*/
readonly ref: string;
}
}
}
/**
* Provides basic functionality for Git.
*/
export interface Git extends Disposable {
/**
* Clones a remote repository into the desired local location.
*
* @param remoteUrl the URL of the remote.
* @param options the clone options.
*/
clone(remoteUrl: string, options: Git.Options.Clone): Promise<Repository>;
/**
* Resolves to an array of repositories discovered in the workspace given with the workspace root URI.
*/
repositories(workspaceRootUri: string, options: Git.Options.Repositories): Promise<Repository[]>;
/**
* Returns with the working directory status of the given Git repository.
*
* @param the repository to get the working directory status from.
*/
status(repository: Repository): Promise<WorkingDirectoryStatus>;
/**
* Stages the given file or files in the working clone. The invocation will be rejected if
* any files (given with their file URIs) is not among the changed files.
*
* @param repository the repository to stage the files.
* @param uri one or multiple file URIs to stage in the working clone.
*/
add(repository: Repository, uri: string | string[]): Promise<void>;
/**
* Removes the given file or files among the staged files in the working clone. The invocation will be rejected if
* any files (given with their file URIs) is not among the staged files.
*
* @param repository the repository to where the staged files have to be removed from.
* @param uri one or multiple file URIs to unstage in the working clone. If the array is empty, all the changed files will be staged.
* @param options optional refinements for the the unstaging operation.
*/
unstage(repository: Repository, uri: string | string[], options?: Git.Options.Unstage): Promise<void>;
/**
* Returns with the currently active branch, or `undefined` if the current branch is in detached mode.
*
* @param the repository where the current branch has to be queried.
* @param options the type of the branch, which is always the `current`.
*/
branch(repository: Repository, options: { type: 'current' }): Promise<Branch | undefined>;
/**
* Returns with an array of branches.
*
* @param the repository where the branches has to be queried.
* @param options the type of the branch, which is either the `local`, the `remote`, or `all` of them.
*/
branch(repository: Repository, options: { type: 'local' | 'remote' | 'all' }): Promise<Branch[]>;
/**
* Creates, renames, and deletes a branch.
*
* @param the repository where the branch modification has to be performed.
* @param options further Git command refinements for the branch modification.
*/
/* eslint-disable @typescript-eslint/indent */
branch(repository: Repository, options:
Git.Options.BranchCommand.Create |
Git.Options.BranchCommand.Rename |
Git.Options.BranchCommand.Delete
): Promise<void>;
/* eslint-enable @typescript-eslint/indent */
/**
* Switches branches or restores working tree files.
*
* @param repository the repository to where the `git checkout` has to be performed.
* @param options further checkout options.
*/
checkout(repository: Repository, options: Git.Options.Checkout.CheckoutBranch | Git.Options.Checkout.WorkingTreeFile): Promise<void>;
/**
* Commits the changes of all staged files in the working directory.
*
* @param repository the repository where the staged changes has to be committed.
* @param message the optional commit message.
*/
commit(repository: Repository, message?: string, options?: Git.Options.Commit): Promise<void>;
/**
* Fetches branches and/or tags (collectively, `refs`) from the repository, along with the objects necessary to complete their histories.
* The remotely-tracked branches will be updated too.
*
* @param repository the repository to fetch from.
* @param options optional options for `git fetch` refinement.
*/
fetch(repository: Repository, options?: Git.Options.Fetch): Promise<void>;
/**
* Updates the `refs` using local `refs`, while sending objects necessary to complete the given `refs` by pushing
* all committed changed from the local Git repository to the `remote` one.
*
* @param repository the repository to push to.
* @param options optional refinements for the `git push` command.
*/
push(repository: Repository, options?: Git.Options.Push): Promise<void>;
/**
* Fetches from and integrates with another repository. It incorporates changes from a repository into the current branch.
* In its default mode, `git pull` is shorthand for `git fetch` followed by `git merge FETCH_HEAD`.
*
* @param repository the repository to pull from.
* @param options optional refinements for the `git pull` command.
*/
pull(repository: Repository, options?: Git.Options.Pull): Promise<void>;
/**
* Resets the current `HEAD` of the entire working directory to the specified state.
*
* @param repository the repository which state has to be reset.
* @param options further clarifying the `git reset` command.
*/
reset(repository: Repository, options: Git.Options.Reset): Promise<void>;
/**
* Merges the given branch into the currently active branch.
*
* @param repository the repository to merge from.
* @param options `git merge` command refinements.
*/
merge(repository: Repository, options: Git.Options.Merge): Promise<void>;
/**
* Retrieves and shows the content of a resource from the repository at a given reference, commit, or tree.
* Resolves to a promise that will produce a string containing the contents of the file or an error if the file does not exists in the given revision.
*
* @param repository the repository to get the file content from.
* @param uri the URI of the file who's content has to be retrieved and shown.
* @param options the options for further refining the `git show`.
*/
show(repository: Repository, uri: string, options?: Git.Options.Show): Promise<string>;
/**
* The default `git stash` command. Equivalent to `git stash push`. If the `message` is not defined, the Git default *WIP on branchname* will be used instead.
*/
stash(repository: Repository, options?: Readonly<{ action?: 'push', message?: string }>): Promise<void>;
/**
* Resolves to an array of stashed entries that you currently have. Same as `git stash list`.
*/
stash(repository: Repository, options: Readonly<{ action: 'list' }>): Promise<StashEntry[]>;
/**
* Removes all the stash entries.
*/
stash(repository: Repository, options: Readonly<{ action: 'clear' }>): Promise<void>;
/**
* Performs stash actions depending on given action option.
* pop:
* Removes a single stashed state from the stash list and applies it on top of the current working tree state.
* The single stashed state is identified by the optional `id`. If the `id` is not defined the latest stash will be popped.
*
* apply:
* Like `git stash pop`, but does not remove the state from the stash list.
*
* drop:
* Removes a single stash entry from the list of stash entries. When the `id` is not given, it removes the latest one.
*/
stash(repository: Repository, options: Readonly<{ action: 'apply' | 'pop' | 'drop', id?: string }>): Promise<void>;
/**
* It resolves to an array of configured remotes names for the given repository.
*
* @param repository the repository to get the remote names.
*/
remote(repository: Repository): Promise<string[]>;
/**
* It resolves to an array of configured remote objects for the given Git action.
*
* @param repository the repository to get the remote objects.
* @param options `git remote` command refinements.
*/
remote(repository: Repository, options: { verbose: true }): Promise<RemoteModel[]>;
/**
* Executes the Git command and resolves to the result. If an executed Git command exits with a code that is not in the `successExitCodes` or an error not in `expectedErrors`,
* a `GitError` will be thrown.
*
* @param repository the repository where one can execute the command. (Although the repository path is not necessarily mandatory for each Git commands,
* such as `git config -l`, or `git --version`, we treat the repository as a required argument to have a symmetric API.)
* @param args the array of arguments for Git.
* @param options options can be used to tweaked additional configurations for the low-level command execution.
*/
exec(repository: Repository, args: string[], options?: Git.Options.Execution): Promise<GitResult>;
/**
* Shows the difference between content pairs in the working tree, commits, or index.
*
* @param repository the repository where where the diff has to be calculated.
* @param options optional configuration for further refining the `git diff` command execution.
*/
diff(repository: Repository, options?: Git.Options.Diff): Promise<GitFileChange[]>;
/**
* Returns a list with commits and their respective file changes.
*
* @param repository the repository where the log has to be calculated.
* @param options optional configuration for further refining the `git log` command execution.
*/
log(repository: Repository, options?: Git.Options.Log): Promise<CommitWithChanges[]>;
/**
* Returns the commit SHA of the given ref if the ref exists, or returns 'undefined' if the
* given ref does not exist.
*
* @param repository the repository where the ref may be found.
* @param options configuration containing the ref and optionally other properties for further refining the `git rev-parse` command execution.
*/
revParse(repository: Repository, options: Git.Options.RevParse): Promise<string | undefined>;
/**
* Returns the annotations of each line in the given file.
*
* @param repository the repository which contains the given file.
* @param uri the URI of the file to get the annotations for.
* @param options more options refining the `git blame`.
*/
blame(repository: Repository, uri: string, options?: Git.Options.Blame): Promise<GitFileBlame | undefined>;
/**
* Resolves to `true` if the file is managed by the Git repository. Otherwise, `false`.
*/
lsFiles(repository: Repository, uri: string, options: { errorUnmatch: true }): Promise<boolean>;
/**
* Shows information about files in the index and the working tree
*
* @param repository the repository where the `git lf-files` has to be executed.
* @param uri the URI of the file to check.
* @param options further options for the command executions.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lsFiles(repository: Repository, uri: string, options?: Git.Options.LsFiles): Promise<any>;
}
/**
* Contains a set of utility functions for {@link Git}.
*/
export namespace GitUtils {
/**
* `true` if the argument is an option for renaming an existing branch in the repository.
*/
export function isBranchRename(arg: unknown): arg is Git.Options.BranchCommand.Rename {
return isObject(arg) && 'newName' in arg;
}
/**
* `true` if the argument is an option for deleting an existing branch in the repository.
*/
export function isBranchDelete(arg: unknown): arg is Git.Options.BranchCommand.Delete {
return isObject(arg) && 'toDelete' in arg;
}
/**
* `true` if the argument is an option for creating a new branch in the repository.
*/
export function isBranchCreate(arg: unknown): arg is Git.Options.BranchCommand.Create {
return isObject(arg) && 'toCreate' in arg;
}
/**
* `true` if the argument is an option for listing the branches in a repository.
*/
export function isBranchList(arg: unknown): arg is Git.Options.BranchCommand.List {
return isObject(arg) && 'type' in arg;
}
/**
* `true` if the argument is an option for checking out a new local branch.
*/
export function isBranchCheckout(arg: unknown): arg is Git.Options.Checkout.CheckoutBranch {
return isObject(arg) && 'branch' in arg;
}
/**
* `true` if the argument is an option for checking out a working tree file.
*/
export function isWorkingTreeFileCheckout(arg: unknown): arg is Git.Options.Checkout.WorkingTreeFile {
return isObject(arg) && 'paths' in arg;
}
/**
* The error code for when the path to a repository doesn't exist.
*/
const RepositoryDoesNotExistErrorCode = 'repository-does-not-exist-error';
/**
* `true` if the argument is an error indicating the absence of a local Git repository.
* Otherwise, `false`.
*/
export function isRepositoryDoesNotExistError(error: unknown): boolean {
// TODO this is odd here.This piece of code is already implementation specific, so this should go to the Git API.
// But how can we ensure that the `any` type error is serializable?
if (error instanceof Error && ('code' in error)) {
return (error as { code: string }).code === RepositoryDoesNotExistErrorCode;
}
return false;
}
/**
* Maps the raw status text from Git to a Git file status enumeration.
*/
export function mapStatus(rawStatus: string): GitFileStatus {
const status = rawStatus.trim();
if (status === 'M') {
return GitFileStatus.Modified;
} // modified
if (status === 'A') {
return GitFileStatus.New;
} // added
if (status === 'D') {
return GitFileStatus.Deleted;
} // deleted
if (status === 'R') {
return GitFileStatus.Renamed;
} // renamed
if (status === 'C') {
return GitFileStatus.Copied;
} // copied
// git log -M --name-status will return a RXXX - where XXX is a percentage
if (status.match(/R[0-9]+/)) {
return GitFileStatus.Renamed;
}
// git log -C --name-status will return a CXXX - where XXX is a percentage
if (status.match(/C[0-9]+/)) {
return GitFileStatus.Copied;
}
return GitFileStatus.Modified;
}
/**
* `true` if the argument is a raw Git status with similarity percentage. Otherwise, `false`.
*/
export function isSimilarityStatus(rawStatus: string): boolean {
return !!rawStatus.match(/R[0-9][0-9][0-9]/) || !!rawStatus.match(/C[0-9][0-9][0-9]/);
}
}

View File

@@ -0,0 +1,19 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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
// *****************************************************************************
export * from './git';
export * from './git-model';
export * from './git-watcher';

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { ContainerModule } from '@theia/core/shared/inversify';
import { GitPrompt } from '../../common/git-prompt';
import { bindPromptServer } from '../../browser/prompt/git-prompt-module';
import { GitQuickOpenPrompt } from './git-quick-open-prompt';
export default new ContainerModule(bind => {
bind(GitQuickOpenPrompt).toSelf().inSingletonScope();
bind(GitPrompt).toService(GitQuickOpenPrompt);
bindPromptServer(bind);
});

View File

@@ -0,0 +1,54 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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, optional } from '@theia/core/shared/inversify';
import { QuickInputService } from '@theia/core/lib/browser';
import PQueue from 'p-queue';
import { GitPrompt } from '../../common/git-prompt';
@injectable()
export class GitQuickOpenPrompt extends GitPrompt {
@inject(QuickInputService) @optional()
protected readonly quickInputService: QuickInputService;
protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
override async ask(question: GitPrompt.Question): Promise<GitPrompt.Answer> {
return this.queue.add(() => {
const { details, text, password } = question;
return new Promise<GitPrompt.Answer>(async resolve => {
const result = await this.quickInputService?.input({
placeHolder: text,
prompt: details!,
password,
});
resolve(GitPrompt.Success.create(result!));
});
}).then((answer: GitPrompt.Answer | void) => {
if (!answer) {
return { type: GitPrompt.Answer.Type.CANCEL };
}
return answer;
});
}
override dispose(): void {
if (!this.queue.isPaused) {
this.queue.pause();
}
this.queue.clear();
}
}

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# Based on: https://github.com/Microsoft/vscode/blob/b1d403f8665603d1db44d3dc013f7ebd06bc526e/extensions/git/src/askpass-empty.sh
echo ''

View File

@@ -0,0 +1,80 @@
// tslint:disable:file-header
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Based on: https://github.com/Microsoft/vscode/blob/dd3e2d94f81139f9d18ba15a24c16c6061880b93/extensions/git/src/askpass-main.ts.
import * as url from 'url';
import * as http from 'http';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fatal(err: any): void {
console.error('Missing or invalid credentials.');
console.error(err);
process.exit(1);
}
// 1. Node.js executable path. In this particular case it is Electron.
// 2. The location of the corresponding JS file of the current (`__filename`) file.
// 3. `Username`/`Password`.
// 4. `for`.
// 5. The host. For example: `https://github.com`.
const expectedArgvCount = 5;
function main(argv: string[]): void {
if (argv.length !== expectedArgvCount) {
fatal(`Wrong number of arguments. Expected ${expectedArgvCount}. Got ${argv.length} instead.`);
return;
}
if (!process.env['THEIA_GIT_ASKPASS_HANDLE']) {
fatal("Missing 'THEIA_GIT_ASKPASS_HANDLE' handle.");
return;
}
const handle = process.env['THEIA_GIT_ASKPASS_HANDLE'] as string;
const { host, hostname, port, protocol } = url.parse(handle);
const gitRequest = argv[2];
const gitHost = argv[4].substring(1, argv[4].length - 2);
const opts: http.RequestOptions = {
host,
hostname,
port,
protocol,
path: '/',
method: 'POST'
};
const req = http.request(opts, res => {
if (res.statusCode !== 200) {
fatal(`Bad status code: ${res.statusCode}.`);
return;
}
const chunks: string[] = [];
res.setEncoding('utf8');
res.on('data', (d: string) => chunks.push(d));
res.on('end', () => {
const raw = chunks.join('');
try {
const result = JSON.parse(raw);
process.stdout.write(result);
} catch (err) {
fatal('Error parsing the response.');
return;
}
setTimeout(() => process.exit(0), 0);
});
});
req.on('error', err => fatal(err));
req.write(JSON.stringify({ gitRequest, gitHost }));
req.end();
}
main(process.argv);

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# Based on: https://github.com/Microsoft/vscode/blob/77f0e95307675c3936c05d641f72b8b32dc8e274/extensions/git/src/askpass.sh
"$THEIA_GIT_ASKPASS_NODE" "$THEIA_GIT_ASKPASS_MAIN" $*

View File

@@ -0,0 +1,203 @@
// tslint:disable:file-header
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Based on: https://github.com/Microsoft/vscode/blob/dd3e2d94f81139f9d18ba15a24c16c6061880b93/extensions/git/src/askpass.ts
import { injectable, postConstruct, inject } from '@theia/core/shared/inversify';
import * as path from 'path';
import * as http from 'http';
import { ILogger } from '@theia/core/lib/common/logger';
import { Disposable } from '@theia/core/lib/common/disposable';
import { MaybePromise } from '@theia/core/lib/common/types';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { GitPrompt } from '../../common/git-prompt';
import { DugiteGitPromptServer } from '../../node/dugite-git-prompt';
import { AddressInfo } from 'net';
/**
* Environment for the Git askpass helper.
*/
export interface AskpassEnvironment {
/**
* The path to the external script to run by Git when authentication is required.
*/
readonly GIT_ASKPASS: string;
/**
* Starts the process as a normal Node.js process. User `"1"` if you want to enable it.
*/
readonly ELECTRON_RUN_AS_NODE?: string;
/**
* The path to the Node.js executable that will run the external `ASKPASS` script.
*/
readonly THEIA_GIT_ASKPASS_NODE?: string;
/**
* The JS file to run.
*/
readonly THEIA_GIT_ASKPASS_MAIN?: string;
/**
* The Git askpass handle path. In our case, this is the address of the HTTP server listening on the `Username` and `Password` requests.
*/
readonly THEIA_GIT_ASKPASS_HANDLE?: string;
}
export interface Address {
readonly port: number;
readonly family: string;
readonly address: string;
}
@injectable()
export class Askpass implements Disposable {
@inject(ILogger)
protected readonly logger: ILogger;
@inject(DugiteGitPromptServer)
protected readonly promptServer: DugiteGitPromptServer;
protected server: http.Server;
protected serverAddress: Address | undefined;
protected ready = new Deferred<boolean>();
@postConstruct()
protected init(): void {
this.server = http.createServer((req, res) => this.onRequest(req, res));
this.setup().then(serverAddress => {
if (serverAddress) {
this.serverAddress = serverAddress;
const { address, port } = this.serverAddress;
this.logger.info(`Git askpass helper is listening on http://${address}:${port}.`);
this.ready.resolve(true);
} else {
this.logger.warn("Couldn't start the HTTP server for the Git askpass helper.");
this.ready.resolve(false);
}
}).catch(() => {
this.ready.resolve(false);
});
}
protected async setup(): Promise<Address | undefined> {
try {
return new Promise<Address>(resolve => {
this.server.on('error', err => this.logger.error(err));
this.server.listen(0, this.hostname(), () => {
resolve(this.server.address() as AddressInfo);
});
});
} catch (err) {
this.logger.error('Could not launch Git askpass helper.', err);
return undefined;
}
}
protected onRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const chunks: string[] = [];
req.setEncoding('utf8');
req.on('data', (d: string) => chunks.push(d));
req.on('end', () => {
const { gitRequest, gitHost } = JSON.parse(chunks.join(''));
this.prompt(gitHost, gitRequest).then(result => {
res.writeHead(200);
res.end(JSON.stringify(result));
}, err => {
this.logger.error(err);
res.writeHead(500);
res.end();
});
});
}
protected async prompt(requestingHost: string, request: string): Promise<string> {
try {
const answer = await this.promptServer.ask({
password: /password/i.test(request),
text: request,
details: `Git: ${requestingHost} (Press 'Enter' to confirm or 'Escape' to cancel.)`
});
if (GitPrompt.Success.is(answer) && typeof answer.result === 'string') {
return answer.result;
} else if (GitPrompt.Cancel.is(answer)) {
return '';
} else if (GitPrompt.Failure.is(answer)) {
const { error } = answer;
throw error;
}
throw new Error('Unexpected answer.'); // Do not ever log the `answer`, it might contain the password.
} catch (e) {
this.logger.error(`An unexpected error occurred when requesting ${request} by ${requestingHost}.`, e);
return '';
}
}
async getEnv(): Promise<AskpassEnvironment> {
const ok = await this.ready.promise;
if (!ok) {
return {
GIT_ASKPASS: path.join(__dirname, '..', '..', '..', 'src', 'electron-node', 'askpass', 'askpass-empty.sh')
};
}
const [
ELECTRON_RUN_AS_NODE,
GIT_ASKPASS,
THEIA_GIT_ASKPASS_NODE,
THEIA_GIT_ASKPASS_MAIN,
THEIA_GIT_ASKPASS_HANDLE
] = await Promise.all([
this.ELECTRON_RUN_AS_NODE(),
this.GIT_ASKPASS(),
this.THEIA_GIT_ASKPASS_NODE(),
this.THEIA_GIT_ASKPASS_MAIN(),
this.THEIA_GIT_ASKPASS_HANDLE()
]);
return {
ELECTRON_RUN_AS_NODE,
GIT_ASKPASS,
THEIA_GIT_ASKPASS_NODE,
THEIA_GIT_ASKPASS_MAIN,
THEIA_GIT_ASKPASS_HANDLE
};
}
dispose(): void {
this.server.close();
}
protected hostname(): string {
return 'localhost';
}
protected GIT_ASKPASS(): MaybePromise<string> {
return path.join(__dirname, '..', '..', '..', 'src', 'electron-node', 'askpass', 'askpass.sh');
}
protected ELECTRON_RUN_AS_NODE(): MaybePromise<string | undefined> {
return '1';
}
protected THEIA_GIT_ASKPASS_NODE(): MaybePromise<string | undefined> {
return process.execPath;
}
protected THEIA_GIT_ASKPASS_MAIN(): MaybePromise<string | undefined> {
return path.join(__dirname, 'askpass-main.js');
}
protected THEIA_GIT_ASKPASS_HANDLE(): MaybePromise<string | undefined> {
if (this.serverAddress) {
return `http://${this.hostname()}:${this.serverAddress.port}`;
}
return undefined;
}
}

View File

@@ -0,0 +1,26 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { ContainerModule } from '@theia/core/shared/inversify';
import { GitEnvProvider } from '../../node/env/git-env-provider';
import { Askpass } from '../askpass/askpass';
import { ElectronGitEnvProvider } from './electron-git-env-provider';
export default new ContainerModule(bind => {
bind(ElectronGitEnvProvider).toSelf().inSingletonScope();
bind(Askpass).toSelf();
bind(GitEnvProvider).toService(ElectronGitEnvProvider);
});

View File

@@ -0,0 +1,47 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { DefaultGitEnvProvider } from '../../node/env/git-env-provider';
import { Askpass } from '../askpass/askpass';
/**
* Git environment provider for Electron.
*
* This Git environment provider is customized for the Electron-based application. It sets the `GIT_ASKPASS` environment variable, to run
* a custom script for the authentication.
*/
@injectable()
export class ElectronGitEnvProvider extends DefaultGitEnvProvider {
@inject(Askpass)
protected readonly askpass: Askpass;
protected _env: Object | undefined;
@postConstruct()
protected override init(): void {
super.init();
this.toDispose.push(this.askpass);
}
override async getEnv(): Promise<Object> {
if (!this._env) {
this._env = this.askpass.getEnv();
}
return this._env;
}
}

View File

@@ -0,0 +1,39 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable } from '@theia/core/shared/inversify';
import { GitPromptServer, GitPromptClient, GitPrompt } from '../common/git-prompt';
@injectable()
export class DugiteGitPromptServer implements GitPromptServer, GitPromptClient {
protected client: GitPromptClient | undefined;
dispose(): void {
}
setClient(client: GitPromptClient | undefined): void {
this.client = client;
}
async ask(question: GitPrompt.Question): Promise<GitPrompt.Answer> {
if (this.client) {
return this.client.ask(question);
}
return GitPrompt.Failure.create('Not yet available.');
}
}

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 * as fs from '@theia/core/shared/fs-extra';
import * as temp from 'temp';
import * as path from 'path';
import { expect } from 'chai';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { Git } from '../common/git';
import { DugiteGit } from './dugite-git';
import { Repository } from '../common';
import { initializeBindings } from './test/binding-helper';
import { DugiteGitWatcherServer } from './dugite-git-watcher';
import { bindGit, bindRepositoryWatcher } from './git-backend-module';
import { GitWatcherServer, GitStatusChangeEvent } from '../common/git-watcher';
const track = temp.track();
describe('git-watcher-slow', () => {
let git: Git | undefined;
let repository: Repository | undefined;
let watcher: GitWatcherServer | undefined;
beforeEach(async function (): Promise<void> {
this.timeout(40000);
const root = track.mkdirSync('git-watcher-slow');
const localUri = FileUri.create(root).toString();
const { container, bind } = initializeBindings();
bindGit(bind);
bindRepositoryWatcher(bind);
git = container.get(DugiteGit);
watcher = container.get(DugiteGitWatcherServer);
repository = { localUri };
await git!.clone('https://github.com/TypeFox/find-git-exec.git', { localUri });
});
after(function (): void {
this.timeout(40000);
track.cleanupSync();
});
it('watching the same repository multiple times should not duplicate the events', async function (): Promise<void> {
this.timeout(40000);
let ignoredEvents = 1;
const events: GitStatusChangeEvent[] = [];
const watchers: number[] = [];
const client = {
async onGitChanged(event: GitStatusChangeEvent): Promise<void> {
// Ignore that event which is fired when one subscribes to the repository changes via #watchGitChanges(repository).
if (ignoredEvents > 0) {
expect(event.status.changes).to.be.empty;
ignoredEvents--;
if (ignoredEvents === 0) {
// Once we consumed all the events we wanted to ignore, make the FS change.
await fs.createFile(path.join(FileUri.fsPath(repository!.localUri), 'A.txt'));
await sleep(6000);
}
} else {
events.push(event);
}
}
};
watcher!.setClient(client);
watchers.push(await watcher!.watchGitChanges(repository!));
watchers.push(await watcher!.watchGitChanges(repository!));
await sleep(6000);
watchers.forEach(async watcherId => watcher!.unwatchGitChanges(watcherId));
expect(events.length).to.be.equal(1, JSON.stringify(events));
expect(events[0].status.changes.length).to.be.equal(1, JSON.stringify(events));
expect(events[0].status.changes[0].uri.toString().endsWith('A.txt')).to.be.true;
events.length = 0;
// Revert the change we've made, and check for the notifications. Zero should be received.
await fs.unlink(path.join(FileUri.fsPath(repository!.localUri), 'A.txt'));
await sleep(6000);
expect(events).to.be.empty;
});
});
function sleep(time: number): Promise<unknown> {
return new Promise(resolve => setTimeout(resolve, time));
}

View File

@@ -0,0 +1,85 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { DisposableCollection, Disposable } from '@theia/core';
import { Repository } from '../common';
import { GitWatcherServer, GitWatcherClient } from '../common/git-watcher';
import { GitRepositoryManager } from './git-repository-manager';
@injectable()
export class DugiteGitWatcherServer implements GitWatcherServer {
protected client: GitWatcherClient | undefined;
protected watcherSequence = 1;
protected readonly watchers = new Map<number, Disposable>();
protected readonly subscriptions = new Map<string, DisposableCollection>();
constructor(
@inject(GitRepositoryManager) protected readonly manager: GitRepositoryManager
) { }
dispose(): void {
for (const watcher of this.watchers.values()) {
watcher.dispose();
}
this.watchers.clear();
this.subscriptions.clear();
}
async watchGitChanges(repository: Repository): Promise<number> {
const reference = await this.manager.getWatcher(repository);
const watcher = reference.object;
const repositoryUri = repository.localUri;
let subscriptions = this.subscriptions.get(repositoryUri);
if (subscriptions === undefined) {
const unsubscribe = watcher.onGitStatusChanged(e => {
if (this.client) {
this.client.onGitChanged(e);
}
});
subscriptions = new DisposableCollection();
subscriptions.onDispose(() => {
unsubscribe.dispose();
this.subscriptions.delete(repositoryUri);
});
this.subscriptions.set(repositoryUri, subscriptions);
}
watcher.watch();
subscriptions.push(reference);
const watcherId = this.watcherSequence++;
this.watchers.set(watcherId, reference);
return watcherId;
}
async unwatchGitChanges(watcher: number): Promise<void> {
const disposable = this.watchers.get(watcher);
if (disposable) {
disposable.dispose();
this.watchers.delete(watcher);
} else {
throw new Error(`No Git watchers were registered with ID: ${watcher}.`);
}
}
setClient(client?: GitWatcherClient): void {
this.client = client;
}
}

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 * as temp from 'temp';
import { expect } from 'chai';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { GitFileStatus } from '../common';
import { createGit } from './test/binding-helper';
const track = temp.track();
describe('git-slow', async function (): Promise<void> {
after(async () => {
track.cleanupSync();
});
describe('diff-slow', async () => {
it('diff with rename/move', async function (): Promise<void> {
this.timeout(50000);
const root = track.mkdirSync('diff-slow-rename');
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const git = await createGit();
await git.clone('https://github.com/kittaakos/eclipse.jdt.ls.git', { localUri });
await git.checkout(repository, { branch: 'Java9' });
await git.checkout(repository, { branch: 'docker' });
const result = await git.diff(repository, { range: { fromRevision: 'docker', toRevision: 'Java9' } });
const renamedItem = result.find(change => change.uri.toString().endsWith('org.eclipse.jdt.ls.core/.classpath'));
expect(renamedItem).to.be.not.undefined;
expect(renamedItem!.oldUri).to.be.not.undefined;
expect(renamedItem!.oldUri!.toString().endsWith('org.jboss.tools.vscode.java/.classpath')).to.be.true;
expect(renamedItem!.status).to.be.equal(GitFileStatus.Renamed);
});
});
});

View File

@@ -0,0 +1,823 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 * as upath from 'upath';
import * as path from 'path';
import * as temp from 'temp';
import * as fs from '@theia/core/shared/fs-extra';
import { expect } from 'chai';
import { Git } from '../common/git';
import { git as gitExec } from 'dugite-extra/lib/core/git';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { WorkingDirectoryStatus, Repository, GitUtils, GitFileStatus, GitFileChange } from '../common';
import { initRepository, createTestRepository } from 'dugite-extra/lib/command/test-helper';
import { createGit } from './test/binding-helper';
import { isWindows } from '@theia/core/lib/common/os';
/* eslint-disable max-len */
const track = temp.track();
describe('git', async function (): Promise<void> {
this.timeout(10000);
after(async () => {
track.cleanupSync();
});
describe('repositories', async () => {
it('should discover only first repository', async () => {
const root = track.mkdirSync('discovery-test-1');
fs.mkdirSync(path.join(root, 'A'));
fs.mkdirSync(path.join(root, 'B'));
fs.mkdirSync(path.join(root, 'C'));
const git = await createGit();
await initRepository(path.join(root, 'A'));
await initRepository(path.join(root, 'B'));
await initRepository(path.join(root, 'C'));
const workspaceRootUri = FileUri.create(root).toString();
const repositories = await git.repositories(workspaceRootUri, { maxCount: 1 });
expect(repositories.length).to.deep.equal(1);
});
it('should discover all nested repositories', async () => {
const root = track.mkdirSync('discovery-test-2');
fs.mkdirSync(path.join(root, 'A'));
fs.mkdirSync(path.join(root, 'B'));
fs.mkdirSync(path.join(root, 'C'));
const git = await createGit();
await initRepository(path.join(root, 'A'));
await initRepository(path.join(root, 'B'));
await initRepository(path.join(root, 'C'));
const workspaceRootUri = FileUri.create(root).toString();
const repositories = await git.repositories(workspaceRootUri, {});
expect(repositories.map(r => path.basename(FileUri.fsPath(r.localUri))).sort()).to.deep.equal(['A', 'B', 'C']);
});
it('should discover all nested repositories and the root repository which is at the workspace root', async function (): Promise<void> {
if (isWindows) {
// https://github.com/eclipse-theia/theia/issues/933
this.skip();
return;
}
const root = track.mkdirSync('discovery-test-3');
fs.mkdirSync(path.join(root, 'BASE'));
fs.mkdirSync(path.join(root, 'BASE', 'A'));
fs.mkdirSync(path.join(root, 'BASE', 'B'));
fs.mkdirSync(path.join(root, 'BASE', 'C'));
const git = await createGit();
await initRepository(path.join(root, 'BASE'));
await initRepository(path.join(root, 'BASE', 'A'));
await initRepository(path.join(root, 'BASE', 'B'));
await initRepository(path.join(root, 'BASE', 'C'));
const workspaceRootUri = FileUri.create(path.join(root, 'BASE')).toString();
const repositories = await git.repositories(workspaceRootUri, {});
expect(repositories.map(r => path.basename(FileUri.fsPath(r.localUri))).sort()).to.deep.equal(['A', 'B', 'BASE', 'C']);
});
it('should discover all nested repositories and the container repository', async () => {
const root = track.mkdirSync('discovery-test-4');
fs.mkdirSync(path.join(root, 'BASE'));
fs.mkdirSync(path.join(root, 'BASE', 'WS_ROOT'));
fs.mkdirSync(path.join(root, 'BASE', 'WS_ROOT', 'A'));
fs.mkdirSync(path.join(root, 'BASE', 'WS_ROOT', 'B'));
fs.mkdirSync(path.join(root, 'BASE', 'WS_ROOT', 'C'));
const git = await createGit();
await initRepository(path.join(root, 'BASE'));
await initRepository(path.join(root, 'BASE', 'WS_ROOT', 'A'));
await initRepository(path.join(root, 'BASE', 'WS_ROOT', 'B'));
await initRepository(path.join(root, 'BASE', 'WS_ROOT', 'C'));
const workspaceRootUri = FileUri.create(path.join(root, 'BASE', 'WS_ROOT')).toString();
const repositories = await git.repositories(workspaceRootUri, {});
const repositoryNames = repositories.map(r => path.basename(FileUri.fsPath(r.localUri)));
expect(repositoryNames.shift()).to.equal('BASE'); // The first must be the container repository.
expect(repositoryNames.sort()).to.deep.equal(['A', 'B', 'C']);
});
});
describe('status', async () => {
it('modifying a staged file should result in two changes', async () => {
// Init repository.
const root = await createTestRepository(track.mkdirSync('status-test'));
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const git = await createGit();
// // Check status. Expect empty.
let status = await git.status(repository);
expect(status.changes).to.be.empty;
// Modify a file.
const filePath = path.join(root, 'A.txt');
const fileUri = FileUri.create(filePath).toString();
fs.writeFileSync(filePath, 'new content');
expect(fs.readFileSync(filePath, { encoding: 'utf8' })).to.be.equal('new content');
await git.add(repository, fileUri);
// Check the status again. Expect one single change.
status = await git.status(repository);
expect(status.changes).to.be.have.lengthOf(1);
expect(status.changes[0].uri).to.be.equal(fileUri);
expect(status.changes[0].staged).to.be.true;
// Change the same file again.
fs.writeFileSync(filePath, 'yet another new content');
expect(fs.readFileSync(filePath, { encoding: 'utf8' })).to.be.equal('yet another new content');
// We expect two changes; one is staged, the other is in the working directory.
status = await git.status(repository);
expect(status.changes).to.be.have.lengthOf(2);
expect(status.changes.map(f => f.uri)).to.be.deep.equal([fileUri, fileUri]);
expect(status.changes.map(f => f.staged).sort()).to.be.deep.equal([false, true]);
});
});
describe('WorkingDirectoryStatus#equals', async () => {
it('staged change should matter', async () => {
const left: WorkingDirectoryStatus = JSON.parse(`
{
"exists":true,
"branch":"GH-165",
"upstreamBranch":"origin/GH-165",
"aheadBehind":{
"ahead":0,
"behind":0
},
"changes":[
{
"uri":"bar.foo",
"status":0,
"staged":false
}
],
"currentHead":"a274d43dbfba5d1ff9d52db42dc90c6f03071656"
}
`);
const right: WorkingDirectoryStatus = JSON.parse(`
{
"exists":true,
"branch":"GH-165",
"upstreamBranch":"origin/GH-165",
"aheadBehind":{
"ahead":0,
"behind":0
},
"changes":[
{
"uri":"bar.foo",
"status":0,
"staged":true
}
],
"currentHead":"a274d43dbfba5d1ff9d52db42dc90c6f03071656"
}
`);
expect(WorkingDirectoryStatus.equals(left, right)).to.be.false;
});
});
describe('show', async () => {
let repository: Repository | undefined;
let git: Git | undefined;
beforeEach(async () => {
const root = await createTestRepository(track.mkdirSync('status-test'));
const localUri = FileUri.create(root).toString();
repository = { localUri };
git = await createGit();
});
it('modified in working directory', async () => {
const repositoryPath = FileUri.fsPath(repository!.localUri);
fs.writeFileSync(path.join(repositoryPath, 'A.txt'), 'new content');
expect(fs.readFileSync(path.join(repositoryPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal('new content');
const content = await git!.show(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString(), { commitish: 'HEAD' });
expect(content).to.be.equal('A');
});
it('modified in working directory (nested)', async () => {
const repositoryPath = FileUri.fsPath(repository!.localUri);
fs.writeFileSync(path.join(repositoryPath, 'folder', 'C.txt'), 'new content');
expect(fs.readFileSync(path.join(repositoryPath, 'folder', 'C.txt'), { encoding: 'utf8' })).to.be.equal('new content');
const content = await git!.show(repository!, FileUri.create(path.join(repositoryPath, 'folder', 'C.txt')).toString(), { commitish: 'HEAD' });
expect(content).to.be.equal('C');
});
it('modified in index', async () => {
const repositoryPath = FileUri.fsPath(repository!.localUri);
fs.writeFileSync(path.join(repositoryPath, 'A.txt'), 'new content');
expect(fs.readFileSync(path.join(repositoryPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal('new content');
await git!.add(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString());
const content = await git!.show(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString(), { commitish: 'index' });
expect(content).to.be.equal('new content');
});
it('modified in index and in working directory', async () => {
const repositoryPath = FileUri.fsPath(repository!.localUri);
fs.writeFileSync(path.join(repositoryPath, 'A.txt'), 'new content');
expect(fs.readFileSync(path.join(repositoryPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal('new content');
await git!.add(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString());
expect(await git!.show(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString(), { commitish: 'index' })).to.be.equal('new content');
expect(await git!.show(repository!, FileUri.create(path.join(repositoryPath, 'A.txt')).toString(), { commitish: 'HEAD' })).to.be.equal('A');
});
});
describe('remote', async () => {
it('remotes are not set by default', async () => {
const root = track.mkdirSync('remote-with-init');
const localUri = FileUri.create(root).toString();
await initRepository(root);
const git = await createGit();
const remotes = await git.remote({ localUri });
expect(remotes).to.be.empty;
});
it('origin is the default after a fresh clone', async () => {
const git = await createGit();
const remoteUrl = 'https://github.com/TypeFox/find-git-exec.git';
const localUri = FileUri.create(track.mkdirSync('remote-with-clone')).toString();
const options = { localUri };
await git.clone(remoteUrl, options);
const remotes = await git.remote({ localUri });
expect(remotes).to.be.lengthOf(1);
expect(remotes.shift()).to.be.equal('origin');
});
it('remotes can be added and queried', async () => {
const root = track.mkdirSync('remote-with-init');
const localUri = FileUri.create(root).toString();
await initRepository(root);
await gitExec(['remote', 'add', 'first', 'some/location'], root, 'addRemote');
await gitExec(['remote', 'add', 'second', 'some/location'], root, 'addRemote');
const git = await createGit();
const remotes = await git.remote({ localUri });
expect(remotes).to.be.deep.equal(['first', 'second']);
});
});
describe('exec', async () => {
it('version', async () => {
const root = track.mkdirSync('exec-version');
const localUri = FileUri.create(root).toString();
await initRepository(root);
const git = await createGit();
const result = await git.exec({ localUri }, ['--version']);
expect(result.stdout.trim().replace(/^git version /, '').startsWith('2')).to.be.true;
expect(result.stderr.trim()).to.be.empty;
expect(result.exitCode).to.be.equal(0);
});
it('config', async () => {
const root = track.mkdirSync('exec-config');
const localUri = FileUri.create(root).toString();
await initRepository(root);
const git = await createGit();
const result = await git.exec({ localUri }, ['config', '-l']);
expect(result.stdout.trim()).to.be.not.empty;
expect(result.stderr.trim()).to.be.empty;
expect(result.exitCode).to.be.equal(0);
});
});
describe('map-status', async () => {
it('deleted', () => {
expect(GitUtils.mapStatus('D')).to.be.equal(GitFileStatus.Deleted);
});
it('added with leading whitespace', () => {
expect(GitUtils.mapStatus(' A')).to.be.equal(GitFileStatus.New);
});
it('modified with trailing whitespace', () => {
expect(GitUtils.mapStatus('M ')).to.be.equal(GitFileStatus.Modified);
});
it('copied with percentage', () => {
expect(GitUtils.mapStatus('C100')).to.be.equal(GitFileStatus.Copied);
});
it('renamed with percentage', () => {
expect(GitUtils.mapStatus('R10')).to.be.equal(GitFileStatus.Renamed);
});
});
describe('similarity-status', async () => {
it('copied (2)', () => {
expect(GitUtils.isSimilarityStatus('C2')).to.be.false;
});
it('copied (20)', () => {
expect(GitUtils.isSimilarityStatus('C20')).to.be.false;
});
it('copied (020)', () => {
expect(GitUtils.isSimilarityStatus('C020')).to.be.true;
});
it('renamed (2)', () => {
expect(GitUtils.isSimilarityStatus('R2')).to.be.false;
});
it('renamed (20)', () => {
expect(GitUtils.isSimilarityStatus('R20')).to.be.false;
});
it('renamed (020)', () => {
expect(GitUtils.isSimilarityStatus('R020')).to.be.true;
});
it('invalid', () => {
expect(GitUtils.isSimilarityStatus('invalid')).to.be.false;
});
});
describe('blame', async () => {
const init = async (git: Git, repository: Repository) => {
await git.exec(repository, ['init']);
if ((await git.exec(repository, ['config', 'user.name'], { successExitCodes: [0, 1] })).exitCode !== 0) {
await git.exec(repository, ['config', 'user.name', 'User Name']);
}
if ((await git.exec(repository, ['config', 'user.email'], { successExitCodes: [0, 1] })).exitCode !== 0) {
await git.exec(repository, ['config', 'user.email', 'user.name@domain.com']);
}
};
it('blame file with dirty content', async () => {
const fileName = 'blame.me.not';
const root = track.mkdirSync('blame-dirty-file');
const filePath = path.join(root, fileName);
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const writeContentLines = async (lines: string[]) => fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8' });
const addAndCommit = async (message: string) => {
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', `${message}`]);
};
const expectBlame = async (content: string, expected: [number, string][]) => {
const uri = FileUri.create(path.join(root, fileName)).toString();
const actual = await git.blame(repository, uri, { content });
expect(actual).to.be.not.undefined;
const messages = new Map(actual!.commits.map<[string, string]>(c => [c.sha, c.summary]));
const lineMessages = actual!.lines.map(l => [l.line, messages.get(l.sha)]);
expect(lineMessages).to.be.deep.equal(expected);
};
const git = await createGit();
await init(git, repository);
await fs.createFile(filePath);
await writeContentLines(['🍏', '🍏', '🍏', '🍏', '🍏', '🍏']);
await addAndCommit('six 🍏');
await expectBlame(['🍏', '🍐', '🍐', '🍏', '🍏', '🍏'].join('\n'),
[
[0, 'six 🍏'],
[1, 'uncommitted'],
[2, 'uncommitted'],
[3, 'six 🍏'],
[4, 'six 🍏'],
[5, 'six 🍏'],
]);
});
it('uncommitted file', async () => {
const fileName = 'uncommitted.file';
const root = track.mkdirSync('try-blame');
const filePath = path.join(root, fileName);
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const writeContentLines = async (lines: string[]) => fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8' });
const add = async () => {
await git.exec(repository, ['add', '.']);
};
const expectUndefinedBlame = async () => {
const uri = FileUri.create(path.join(root, fileName)).toString();
const actual = await git.blame(repository, uri);
expect(actual).to.be.undefined;
};
const git = await createGit();
await init(git, repository);
await fs.createFile(filePath);
await writeContentLines(['🍏', '🍏', '🍏', '🍏', '🍏', '🍏']);
await expectUndefinedBlame();
await add();
await expectUndefinedBlame();
await writeContentLines(['🍏', '🍐', '🍐', '🍏', '🍏', '🍏']);
await expectUndefinedBlame();
});
it('blame file', async () => {
const fileName = 'blame.me';
const root = track.mkdirSync('blame-file');
const filePath = path.join(root, fileName);
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const writeContentLines = async (lines: string[]) => fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8' });
const addAndCommit = async (message: string) => {
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', `${message}`]);
};
const expectBlame = async (expected: [number, string][]) => {
const uri = FileUri.create(path.join(root, fileName)).toString();
const actual = await git.blame(repository, uri);
expect(actual).to.be.not.undefined;
const messages = new Map(actual!.commits.map<[string, string]>(c => [c.sha, c.summary]));
const lineMessages = actual!.lines.map(l => [l.line, messages.get(l.sha)]);
expect(lineMessages).to.be.deep.equal(expected);
};
const git = await createGit();
await init(git, repository);
await fs.createFile(filePath);
await writeContentLines(['🍏', '🍏', '🍏', '🍏', '🍏', '🍏']);
await addAndCommit('six 🍏');
await writeContentLines(['🍏', '🍐', '🍐', '🍏', '🍏', '🍏']);
await addAndCommit('replace two with 🍐');
await writeContentLines(['🍏', '🍐', '🍋', '🍋', '🍏', '🍏']);
await addAndCommit('replace two with 🍋');
await writeContentLines(['🍏', '🍐', '🍋', '🍌', '🍌', '🍏']);
await expectBlame([
[0, 'six 🍏'],
[1, 'replace two with 🍐'],
[2, 'replace two with 🍋'],
[3, 'uncommitted'],
[4, 'uncommitted'],
[5, 'six 🍏'],
]);
});
it('commit summary and body', async () => {
const fileName = 'blame.me';
const root = track.mkdirSync('blame-with-commit-body');
const filePath = path.join(root, fileName);
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const writeContentLines = async (lines: string[]) => fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8' });
const addAndCommit = async (message: string) => {
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', `${message}`]);
};
const expectBlame = async (expected: [number, string, string][]) => {
const uri = FileUri.create(path.join(root, fileName)).toString();
const actual = await git.blame(repository, uri);
expect(actual).to.be.not.undefined;
const messages = new Map(actual!.commits.map<[string, string[]]>(c => [c.sha, [c.summary, c.body!]]));
const lineMessages = actual!.lines.map(l => [l.line, ...messages.get(l.sha)!]);
expect(lineMessages).to.be.deep.equal(expected);
};
const git = await createGit();
await init(git, repository);
await fs.createFile(filePath);
await writeContentLines(['🍏']);
await addAndCommit('add 🍏\n* green\n* red');
await expectBlame([
[0, 'add 🍏', '* green\n* red']
]);
});
});
describe('diff', async () => {
const init = async (git: Git, repository: Repository) => {
await git.exec(repository, ['init']);
if ((await git.exec(repository, ['config', 'user.name'], { successExitCodes: [0, 1] })).exitCode !== 0) {
await git.exec(repository, ['config', 'user.name', 'User Name']);
}
if ((await git.exec(repository, ['config', 'user.email'], { successExitCodes: [0, 1] })).exitCode !== 0) {
await git.exec(repository, ['config', 'user.email', 'user.name@domain.com']);
}
};
it('diff without ranges / unstaged', async () => {
const root = track.mkdirSync('diff-without-ranges');
const localUri = FileUri.create(root).toString();
const repository = { localUri };
await fs.createFile(path.join(root, 'A.txt'));
await fs.writeFile(path.join(root, 'A.txt'), 'A content', { encoding: 'utf8' });
const git = await createGit();
await init(git, repository);
const expectDiff: (expected: ChangeDelta[]) => Promise<void> = async expected => {
const actual = (await git.diff(repository)).map(change => ChangeDelta.map(repository, change)).sort(ChangeDelta.compare);
expect(actual).to.be.deep.equal(expected);
};
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Initialized."']); // HEAD
await fs.createFile(path.join(root, 'B.txt'));
await fs.writeFile(path.join(root, 'B.txt'), 'B content', { encoding: 'utf8' });
await expectDiff([]); // Unstaged (new)
await fs.writeFile(path.join(root, 'A.txt'), 'updated A content', { encoding: 'utf8' });
await expectDiff([{ pathSegment: 'A.txt', status: GitFileStatus.Modified }]); // Unstaged (modified)
await fs.unlink(path.join(root, 'A.txt'));
await expectDiff([{ pathSegment: 'A.txt', status: GitFileStatus.Deleted }]); // Unstaged (deleted)
});
it('diff without ranges / staged', async () => {
const root = track.mkdirSync('diff-without-ranges');
const localUri = FileUri.create(root).toString();
const repository = { localUri };
await fs.createFile(path.join(root, 'A.txt'));
await fs.writeFile(path.join(root, 'A.txt'), 'A content', { encoding: 'utf8' });
const git = await createGit();
await init(git, repository);
const expectDiff: (expected: ChangeDelta[]) => Promise<void> = async expected => {
const actual = (await git.diff(repository)).map(change => ChangeDelta.map(repository, change)).sort(ChangeDelta.compare);
expect(actual).to.be.deep.equal(expected);
};
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Initialized."']); // HEAD
await fs.createFile(path.join(root, 'B.txt'));
await fs.writeFile(path.join(root, 'B.txt'), 'B content', { encoding: 'utf8' });
await git.add(repository, FileUri.create(path.join(root, 'B.txt')).toString());
await expectDiff([{ pathSegment: 'B.txt', status: GitFileStatus.New }]); // Staged (new)
await fs.writeFile(path.join(root, 'A.txt'), 'updated A content', { encoding: 'utf8' });
await git.add(repository, FileUri.create(path.join(root, 'A.txt')).toString());
await expectDiff([{ pathSegment: 'A.txt', status: GitFileStatus.Modified }, { pathSegment: 'B.txt', status: GitFileStatus.New }]); // Staged (modified)
await fs.unlink(path.join(root, 'A.txt'));
await git.add(repository, FileUri.create(path.join(root, 'A.txt')).toString());
await expectDiff([{ pathSegment: 'A.txt', status: GitFileStatus.Deleted }, { pathSegment: 'B.txt', status: GitFileStatus.New }]); // Staged (deleted)
});
it('diff with ranges', async () => {
const root = track.mkdirSync('diff-with-ranges');
const localUri = FileUri.create(root).toString();
const repository = { localUri };
await fs.createFile(path.join(root, 'A.txt'));
await fs.writeFile(path.join(root, 'A.txt'), 'A content', { encoding: 'utf8' });
await fs.createFile(path.join(root, 'B.txt'));
await fs.writeFile(path.join(root, 'B.txt'), 'B content', { encoding: 'utf8' });
await fs.mkdir(path.join(root, 'folder'));
await fs.createFile(path.join(root, 'folder', 'F1.txt'));
await fs.writeFile(path.join(root, 'folder', 'F1.txt'), 'F1 content', { encoding: 'utf8' });
await fs.createFile(path.join(root, 'folder', 'F2.txt'));
await fs.writeFile(path.join(root, 'folder', 'F2.txt'), 'F2 content', { encoding: 'utf8' });
const git = await createGit();
await init(git, repository);
const expectDiff: (fromRevision: string, toRevision: string, expected: ChangeDelta[], filePath?: string) => Promise<void> = async (fromRevision, toRevision, expected, filePath) => {
const range = { fromRevision, toRevision };
let uri: string | undefined;
if (filePath) {
uri = FileUri.create(path.join(root, filePath)).toString();
}
const options: Git.Options.Diff = { range, uri };
const actual = (await git.diff(repository, options)).map(change => ChangeDelta.map(repository, change)).sort(ChangeDelta.compare);
expect(actual).to.be.deep.equal(expected, `Between ${fromRevision}..${toRevision}`);
};
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 1 on master."']); // HEAD~4
await git.exec(repository, ['checkout', '-b', 'new-branch']);
await fs.writeFile(path.join(root, 'A.txt'), 'updated A content', { encoding: 'utf8' });
await fs.unlink(path.join(root, 'B.txt'));
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 1 on new-branch."']); // new-branch~2
await fs.createFile(path.join(root, 'C.txt'));
await fs.writeFile(path.join(root, 'C.txt'), 'C content', { encoding: 'utf8' });
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 2 on new-branch."']); // new-branch~1
await fs.createFile(path.join(root, 'B.txt'));
await fs.writeFile(path.join(root, 'B.txt'), 'B content', { encoding: 'utf8' });
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 3 on new-branch."']); // new-branch
await git.exec(repository, ['checkout', 'master']);
await fs.createFile(path.join(root, 'C.txt'));
await fs.writeFile(path.join(root, 'C.txt'), 'C content', { encoding: 'utf8' });
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 2 on master."']); // HEAD~3
await fs.createFile(path.join(root, 'D.txt'));
await fs.writeFile(path.join(root, 'D.txt'), 'D content', { encoding: 'utf8' });
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 3 on master."']); // HEAD~2
await fs.unlink(path.join(root, 'B.txt'));
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 4 on master."']); // HEAD~1
await fs.unlink(path.join(root, 'folder', 'F1.txt'));
await fs.writeFile(path.join(root, 'folder', 'F2.txt'), 'updated F2 content', { encoding: 'utf8' });
await fs.createFile(path.join(root, 'folder', 'F3 with space.txt'));
await fs.writeFile(path.join(root, 'folder', 'F3 with space.txt'), 'F3 content', { encoding: 'utf8' });
await git.exec(repository, ['add', '.']);
await git.exec(repository, ['commit', '-m', '"Commit 5 on master."']); // HEAD
await expectDiff('HEAD~4', 'HEAD~3', [{ pathSegment: 'C.txt', status: GitFileStatus.New }]);
await expectDiff('HEAD~4', 'HEAD~2', [{ pathSegment: 'C.txt', status: GitFileStatus.New }, { pathSegment: 'D.txt', status: GitFileStatus.New }]);
await expectDiff('HEAD~4', 'HEAD~1', [{ pathSegment: 'B.txt', status: GitFileStatus.Deleted }, { pathSegment: 'C.txt', status: GitFileStatus.New }, { pathSegment: 'D.txt', status: GitFileStatus.New }]);
await expectDiff('HEAD~3', 'HEAD~2', [{ pathSegment: 'D.txt', status: GitFileStatus.New }]);
await expectDiff('HEAD~3', 'HEAD~1', [{ pathSegment: 'B.txt', status: GitFileStatus.Deleted }, { pathSegment: 'D.txt', status: GitFileStatus.New }]);
await expectDiff('HEAD~2', 'HEAD~1', [{ pathSegment: 'B.txt', status: GitFileStatus.Deleted }]);
await expectDiff('new-branch~2', 'new-branch~1', [{ pathSegment: 'C.txt', status: GitFileStatus.New }]);
await expectDiff('new-branch~2', 'new-branch', [{ pathSegment: 'B.txt', status: GitFileStatus.New }, { pathSegment: 'C.txt', status: GitFileStatus.New }]);
await expectDiff('new-branch~1', 'new-branch', [{ pathSegment: 'B.txt', status: GitFileStatus.New }]);
// Filter for a whole folder and its descendants.
await expectDiff('HEAD~4', 'HEAD~3', [], 'folder');
await expectDiff('HEAD~4', 'HEAD', [
{ pathSegment: 'folder/F1.txt', status: GitFileStatus.Deleted },
{ pathSegment: 'folder/F2.txt', status: GitFileStatus.Modified },
{ pathSegment: 'folder/F3 with space.txt', status: GitFileStatus.New },
], 'folder');
// Filter for a single file.
await expectDiff('HEAD~4', 'HEAD~3', [], 'folder/F1.txt');
await expectDiff('HEAD~4', 'HEAD', [
{ pathSegment: 'folder/F1.txt', status: GitFileStatus.Deleted },
], 'folder/F1.txt');
// Filter for a non-existing file.
await expectDiff('HEAD~4', 'HEAD~3', [], 'does not exist');
await expectDiff('HEAD~4', 'HEAD', [], 'does not exist');
});
});
describe('branch', () => {
// Skip the test case as it is dependent on the git version.
it.skip('should list the branch in chronological order', async function (): Promise<void> {
if (isWindows) {
this.skip(); // https://github.com/eclipse-theia/theia/issues/8023
return;
}
const root = track.mkdirSync('branch-order');
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const git = await createGit();
await createTestRepository(root);
await git.exec(repository, ['checkout', '-b', 'a']);
await git.exec(repository, ['checkout', 'master']);
await git.exec(repository, ['checkout', '-b', 'b']);
await git.exec(repository, ['checkout', 'master']);
await git.exec(repository, ['checkout', '-b', 'c']);
await git.exec(repository, ['checkout', 'master']);
expect((await git.branch(repository, { type: 'local' })).map(b => b.nameWithoutRemote)).to.be.deep.equal(['master', 'c', 'b', 'a']);
});
});
describe('ls-files', () => {
let git: Git;
let root: string;
let localUri: string;
before(async () => {
root = track.mkdirSync('ls-files');
localUri = FileUri.create(root).toString();
git = await createGit();
await createTestRepository(root);
});
([
['A.txt', true],
['missing.txt', false],
['../outside.txt', false],
] as [string, boolean][]).forEach(test => {
const [relativePath, expectation] = test;
const message = `${expectation ? '' : 'not '}exist`;
it(`errorUnmatched - ${relativePath} should ${message}`, async () => {
const uri = relativePath.startsWith('.') ? relativePath : FileUri.create(path.join(root, relativePath)).toString();
const testMe = async () => git.lsFiles({ localUri }, uri, { errorUnmatch: true });
expect(await testMe()).to.be.equal(expectation);
});
});
});
});
describe('log', function (): void {
// See https://github.com/eclipse-theia/theia/issues/2143
it('should not fail when executed from the repository root', async () => {
const git = await createGit();
const root = await createTestRepository(track.mkdirSync('log-test'));
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const result = await git.log(repository, { uri: localUri });
expect(result.length).to.be.equal(1);
expect(result[0].author.email).to.be.equal('jon@doe.com');
});
it('should not fail when executed against an empty repository', async () => {
const git = await createGit();
const root = await initRepository(track.mkdirSync('empty-log-test'));
const localUri = FileUri.create(root).toString();
const repository = { localUri };
const result = await git.log(repository, { uri: localUri });
expect(result.length).to.be.equal(0);
});
});
function toPathSegment(repository: Repository, uri: string): string {
return upath.relative(FileUri.fsPath(repository.localUri), FileUri.fsPath(uri));
}
interface ChangeDelta {
readonly pathSegment: string;
readonly status: GitFileStatus;
}
namespace ChangeDelta {
export function compare(left: ChangeDelta, right: ChangeDelta): number {
const result = left.pathSegment.localeCompare(right.pathSegment);
if (result === 0) {
return left.status - right.status;
}
return result;
}
export function map(repository: Repository, fileChange: GitFileChange): ChangeDelta {
return {
pathSegment: toPathSegment(repository, fileChange.uri),
status: fileChange.status
};
}
}

View File

@@ -0,0 +1,951 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 * as fs from '@theia/core/shared/fs-extra';
import * as Path from 'path';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { git } from 'dugite-extra/lib/core/git';
import { push } from 'dugite-extra/lib/command/push';
import { pull } from 'dugite-extra/lib/command/pull';
import { clone } from 'dugite-extra/lib/command/clone';
import { fetch } from 'dugite-extra/lib/command/fetch';
import { stash } from 'dugite-extra/lib/command/stash';
import { merge } from 'dugite-extra/lib/command/merge';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { getStatus } from 'dugite-extra/lib/command/status';
import { createCommit } from 'dugite-extra/lib/command/commit';
import { stage, unstage } from 'dugite-extra/lib/command/stage';
import { reset, GitResetMode } from 'dugite-extra/lib/command/reset';
import { getTextContents, getBlobContents } from 'dugite-extra/lib/command/show';
import { checkoutBranch, checkoutPaths } from 'dugite-extra/lib/command/checkout';
import { createBranch, deleteBranch, renameBranch, listBranch } from 'dugite-extra/lib/command/branch';
import { IStatusResult, IAheadBehind, AppFileStatus, WorkingDirectoryStatus as DugiteStatus, FileChange as DugiteFileChange } from 'dugite-extra/lib/model/status';
import { Branch as DugiteBranch } from 'dugite-extra/lib/model/branch';
import { Commit as DugiteCommit, CommitIdentity as DugiteCommitIdentity } from 'dugite-extra/lib/model/commit';
import { ILogger } from '@theia/core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import * as strings from '@theia/core/lib/common/strings';
import {
Git, GitUtils, Repository, WorkingDirectoryStatus, GitFileChange, GitFileStatus, Branch, Commit,
CommitIdentity, GitResult, CommitWithChanges, GitFileBlame, CommitLine, GitError, Remote, StashEntry
} from '../common';
import { GitRepositoryManager } from './git-repository-manager';
import { GitLocator } from './git-locator/git-locator-protocol';
import { GitExecProvider } from './git-exec-provider';
import { GitEnvProvider } from './env/git-env-provider';
import { GitInit } from './init/git-init';
import upath = require('upath');
/**
* Parsing and converting raw Git output into Git model instances.
*/
@injectable()
export abstract class OutputParser<T> {
/** This is the `NUL` delimiter. Equals `%x00`. */
static readonly LINE_DELIMITER = '\0';
abstract parse(repositoryUri: string, raw: string, delimiter?: string): T[];
abstract parse(repositoryUri: string, items: string[]): T[];
abstract parse(repositoryUri: string, input: string | string[], delimiter?: string): T[];
protected toUri(repositoryUri: string, pathSegment: string): string {
return FileUri.create(Path.join(FileUri.fsPath(repositoryUri), pathSegment)).toString();
}
protected split(input: string | string[], delimiter: string): string[] {
return (Array.isArray(input) ? input : input.split(delimiter)).filter(item => item && item.length > 0);
}
}
/**
* Status parser for converting raw Git `--name-status` output into file change objects.
*/
@injectable()
export class NameStatusParser extends OutputParser<GitFileChange> {
parse(repositoryUri: string, input: string | string[], delimiter: string = OutputParser.LINE_DELIMITER): GitFileChange[] {
const items = this.split(input, delimiter);
const changes: GitFileChange[] = [];
let index = 0;
while (index < items.length) {
const rawStatus = items[index];
const status = GitUtils.mapStatus(rawStatus);
if (GitUtils.isSimilarityStatus(rawStatus)) {
const uri = this.toUri(repositoryUri, items[index + 2]);
const oldUri = this.toUri(repositoryUri, items[index + 1]);
changes.push({
status,
uri,
oldUri,
staged: true
});
index = index + 3;
} else {
const uri = this.toUri(repositoryUri, items[index + 1]);
changes.push({
status,
uri,
staged: true
});
index = index + 2;
}
}
return changes;
}
}
/**
* Built-in Git placeholders for tuning the `--format` option for `git diff` or `git log`.
*/
export enum CommitPlaceholders {
HASH = '%H',
SHORT_HASH = '%h',
AUTHOR_EMAIL = '%aE',
AUTHOR_NAME = '%aN',
AUTHOR_DATE = '%aI',
AUTHOR_RELATIVE_DATE = '%ar',
SUBJECT = '%s',
BODY = '%b'
}
/**
* Parser for converting raw, Git commit details into `CommitWithChanges` instances.
*/
@injectable()
export class CommitDetailsParser extends OutputParser<CommitWithChanges> {
static readonly ENTRY_DELIMITER = '\x01';
static readonly COMMIT_CHUNK_DELIMITER = '\x02';
static readonly DEFAULT_PLACEHOLDERS = [
CommitPlaceholders.HASH,
CommitPlaceholders.AUTHOR_EMAIL,
CommitPlaceholders.AUTHOR_NAME,
CommitPlaceholders.AUTHOR_DATE,
CommitPlaceholders.AUTHOR_RELATIVE_DATE,
CommitPlaceholders.SUBJECT,
CommitPlaceholders.BODY];
@inject(NameStatusParser)
protected readonly nameStatusParser: NameStatusParser;
parse(repositoryUri: string, input: string | string[], delimiter: string = CommitDetailsParser.COMMIT_CHUNK_DELIMITER): CommitWithChanges[] {
const chunks = this.split(input, delimiter);
const changes: CommitWithChanges[] = [];
for (const chunk of chunks) {
const [sha, email, name, timestamp, authorDateRelative, summary, body, rawChanges] = chunk.trim().split(CommitDetailsParser.ENTRY_DELIMITER);
const fileChanges = this.nameStatusParser.parse(repositoryUri, (rawChanges || '').trim());
changes.push({
sha,
author: { timestamp, email, name },
authorDateRelative,
summary,
body,
fileChanges
});
}
return changes;
}
getFormat(...placeholders: CommitPlaceholders[]): string {
return '%x02' + placeholders.join('%x01') + '%x01';
}
}
@injectable()
export class GitBlameParser {
async parse(fileUri: string, gitBlameOutput: string, commitBody: (sha: string) => Promise<string>): Promise<GitFileBlame | undefined> {
if (!gitBlameOutput) {
return undefined;
}
const parsedEntries = this.parseEntries(gitBlameOutput);
return this.createFileBlame(fileUri, parsedEntries, commitBody);
}
protected parseEntries(rawOutput: string): GitBlameParser.Entry[] {
const result: GitBlameParser.Entry[] = [];
let current: GitBlameParser.Entry | undefined;
for (const line of strings.split(rawOutput, '\n')) {
if (current === undefined) {
current = {};
}
if (GitBlameParser.pumpEntry(current, line)) {
result.push(current);
current = undefined;
}
}
return result;
}
protected async createFileBlame(uri: string, blameEntries: GitBlameParser.Entry[], commitBody: (sha: string) => Promise<string>): Promise<GitFileBlame> {
const commits = new Map<string, Commit>();
const lines: CommitLine[] = [];
for (const entry of blameEntries) {
const sha = entry.sha!;
let commit = commits.get(sha);
if (!commit) {
commit = <Commit>{
sha,
author: {
name: entry.author,
email: entry.authorMail,
timestamp: entry.authorTime ? new Date(entry.authorTime * 1000).toISOString() : '',
},
summary: entry.summary,
body: await commitBody(sha)
};
commits.set(sha, commit);
}
const lineCount = entry.lineCount!;
for (let lineOffset = 0; lineOffset < lineCount; lineOffset++) {
const line = <CommitLine>{
sha,
line: entry.line! + lineOffset
};
lines[line.line] = line;
}
}
const fileBlame = <GitFileBlame>{ uri, commits: Array.from(commits.values()), lines };
return fileBlame;
}
}
export namespace GitBlameParser {
export interface Entry {
fileName?: string,
sha?: string,
previousSha?: string,
line?: number,
lineCount?: number,
author?: string,
authorMail?: string,
authorTime?: number,
summary?: string,
}
export function isUncommittedSha(sha: string | undefined): boolean {
return (sha || '').startsWith('0000000');
}
export function pumpEntry(entry: Entry, outputLine: string): boolean {
const parts = outputLine.split(' ');
if (parts.length < 2) {
return false;
}
const uncommitted = isUncommittedSha(entry.sha);
const firstPart = parts[0];
if (entry.sha === undefined) {
entry.sha = firstPart;
entry.line = parseInt(parts[2], 10) - 1; // to zero based
entry.lineCount = parseInt(parts[3], 10);
} else if (firstPart === 'author') {
entry.author = uncommitted ? 'You' : parts.slice(1).join(' ');
} else if (firstPart === 'author-mail') {
const rest = parts.slice(1).join(' ');
const matches = rest.match(/(<(.*)>)/);
entry.authorMail = matches ? matches[2] : rest;
} else if (firstPart === 'author-time') {
entry.authorTime = parseInt(parts[1], 10);
} else if (firstPart === 'summary') {
let summary = parts.slice(1).join(' ');
if (summary.startsWith('"') && summary.endsWith('"')) {
summary = summary.substring(1, summary.length - 1);
}
entry.summary = uncommitted ? 'uncommitted' : summary;
} else if (firstPart === 'previous') {
entry.previousSha = parts[1];
} else if (firstPart === 'filename') {
entry.fileName = parts.slice(1).join(' ');
return true;
}
return false;
}
}
/**
* `dugite-extra` based Git implementation.
*/
@injectable()
export class DugiteGit implements Git {
protected readonly limit = 1000;
@inject(ILogger)
protected readonly logger: ILogger;
@inject(GitLocator)
protected readonly locator: GitLocator;
@inject(GitRepositoryManager)
protected readonly manager: GitRepositoryManager;
@inject(NameStatusParser)
protected readonly nameStatusParser: NameStatusParser;
@inject(CommitDetailsParser)
protected readonly commitDetailsParser: CommitDetailsParser;
@inject(GitBlameParser)
protected readonly blameParser: GitBlameParser;
@inject(GitExecProvider)
protected readonly execProvider: GitExecProvider;
@inject(GitEnvProvider)
protected readonly envProvider: GitEnvProvider;
@inject(GitInit)
protected readonly gitInit: GitInit;
protected ready: Deferred<void> = new Deferred();
protected gitEnv: Deferred<Object> = new Deferred();
@postConstruct()
protected init(): void {
this.envProvider.getEnv().then(env => this.gitEnv.resolve(env));
this.gitInit.init()
.catch(err => {
this.logger.error('An error occurred during the Git initialization.', err);
this.ready.resolve();
})
.then(() => this.ready.resolve());
}
dispose(): void {
this.locator.dispose();
this.execProvider.dispose();
this.gitInit.dispose();
}
async clone(remoteUrl: string, options: Git.Options.Clone): Promise<Repository> {
await this.ready.promise;
const { localUri, branch } = options;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
await clone(remoteUrl, this.getFsPath(localUri), { branch }, { exec, env });
return { localUri };
}
async repositories(workspaceRootUri: string, options: Git.Options.Repositories): Promise<Repository[]> {
await this.ready.promise;
const workspaceRootPath = this.getFsPath(workspaceRootUri);
const repositories: Repository[] = [];
const containingPath = await this.resolveContainingPath(workspaceRootPath);
if (containingPath) {
repositories.push({
localUri: this.getUri(containingPath)
});
}
const maxCount = typeof options.maxCount === 'number' ? options.maxCount - repositories.length : undefined;
if (typeof maxCount === 'number' && maxCount <= 0) {
return repositories;
}
for (const repositoryPath of await this.locator.locate(workspaceRootPath, {
maxCount
})) {
if (containingPath !== repositoryPath) {
repositories.push({
localUri: this.getUri(repositoryPath)
});
}
}
return repositories;
}
async status(repository: Repository): Promise<WorkingDirectoryStatus> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const dugiteStatus = await getStatus(repositoryPath, true, this.limit, { exec, env });
return this.mapStatus(dugiteStatus, repository);
}
async add(repository: Repository, uri: string | string[]): Promise<void> {
await this.ready.promise;
const paths = (Array.isArray(uri) ? uri : [uri]).map(FileUri.fsPath);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
stage(this.getFsPath(repository), paths, { exec, env })
);
}
async unstage(repository: Repository, uri: string | string[], options?: Git.Options.Unstage): Promise<void> {
await this.ready.promise;
const paths = (Array.isArray(uri) ? uri : [uri]).map(FileUri.fsPath);
const treeish = options && options.treeish ? options.treeish : undefined;
const where = options && options.reset ? options.reset : undefined;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
unstage(this.getFsPath(repository), paths, treeish, where, { exec, env })
);
}
async branch(repository: Repository, options: { type: 'current' }): Promise<Branch | undefined>;
async branch(repository: Repository, options: { type: 'local' | 'remote' | 'all' }): Promise<Branch[]>;
async branch(repository: Repository, options: Git.Options.BranchCommand.Create | Git.Options.BranchCommand.Rename | Git.Options.BranchCommand.Delete): Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async branch(repository: any, options: any): Promise<void | undefined | Branch | Branch[]> {
await this.ready.promise;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const repositoryPath = this.getFsPath(repository);
if (GitUtils.isBranchList(options)) {
if (options.type === 'current') {
const currentBranch = await listBranch(repositoryPath, options.type, { exec, env });
return currentBranch ? this.mapBranch(currentBranch) : undefined;
}
const branches = await listBranch(repositoryPath, options.type, { exec, env });
return Promise.all(branches.map(branch => this.mapBranch(branch)));
}
return this.manager.run(repository, () => {
if (GitUtils.isBranchCreate(options)) {
return createBranch(repositoryPath, options.toCreate, { startPoint: options.startPoint }, { exec, env });
}
if (GitUtils.isBranchRename(options)) {
return renameBranch(repositoryPath, options.newName, options.newName, { force: !!options.force }, { exec, env });
}
if (GitUtils.isBranchDelete(options)) {
return deleteBranch(repositoryPath, options.toDelete, { force: !!options.force, remote: !!options.remote }, { exec, env });
}
return this.fail(repository, `Unexpected git branch options: ${options}.`);
});
}
async checkout(repository: Repository, options: Git.Options.Checkout.CheckoutBranch | Git.Options.Checkout.WorkingTreeFile): Promise<void> {
await this.ready.promise;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () => {
const repositoryPath = this.getFsPath(repository);
if (GitUtils.isBranchCheckout(options)) {
return checkoutBranch(repositoryPath, options.branch, { exec, env });
}
if (GitUtils.isWorkingTreeFileCheckout(options)) {
const paths = (Array.isArray(options.paths) ? options.paths : [options.paths]).map(FileUri.fsPath);
return checkoutPaths(repositoryPath, paths, { exec, env });
}
return this.fail(repository, `Unexpected git checkout options: ${options}.`);
});
}
async commit(repository: Repository, message?: string, options?: Git.Options.Commit): Promise<void> {
await this.ready.promise;
const signOff = options && options.signOff;
const amend = options && options.amend;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
createCommit(this.getFsPath(repository), message || '', signOff, amend, { exec, env })
);
}
async fetch(repository: Repository, options?: Git.Options.Fetch): Promise<void> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const r = await this.getDefaultRemote(repositoryPath, options ? options.remote : undefined);
if (r) {
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
fetch(repositoryPath, r!, { exec, env })
);
}
this.fail(repository, 'No remote repository specified. Please, specify either a URL or a remote name from which new revisions should be fetched.');
}
async push(repository: Repository, { remote, localBranch, remoteBranch, setUpstream, force }: Git.Options.Push = {}): Promise<void> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const currentRemote = await this.getDefaultRemote(repositoryPath, remote);
if (currentRemote === undefined) {
this.fail(repository, 'No configured push destination.');
}
const branch = await this.getCurrentBranch(repositoryPath, localBranch);
const branchName = typeof branch === 'string' ? branch : branch.name;
if (setUpstream || force) {
const args = ['push'];
if (force) {
args.push('--force');
}
if (setUpstream) {
args.push('--set-upstream');
}
if (currentRemote) {
args.push(currentRemote);
}
args.push(branchName + (remoteBranch ? `:${remoteBranch}` : ''));
await this.exec(repository, args);
} else {
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
push(repositoryPath, currentRemote!, branchName, remoteBranch, { exec, env })
);
}
}
async pull(repository: Repository, { remote, branch, rebase }: Git.Options.Pull = {}): Promise<void> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const currentRemote = await this.getDefaultRemote(repositoryPath, remote);
if (currentRemote === undefined) {
this.fail(repository, 'No remote repository specified. Please, specify either a URL or a remote name from which new revisions should be fetched.');
}
if (rebase) {
const args = ['pull'];
if (rebase) {
args.push('-r');
}
if (currentRemote) {
args.push(currentRemote);
}
if (branch) {
args.push(branch);
}
await this.exec(repository, args);
} else {
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () => pull(repositoryPath, currentRemote!, branch, { exec, env }));
}
}
async reset(repository: Repository, options: Git.Options.Reset): Promise<void> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const mode = this.getResetMode(options.mode);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
reset(repositoryPath, mode, options.ref ? options.ref : 'HEAD', { exec, env })
);
}
async merge(repository: Repository, options: Git.Options.Merge): Promise<void> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
return this.manager.run(repository, () =>
merge(repositoryPath, options.branch, { exec, env })
);
}
async show(repository: Repository, uri: string, options?: Git.Options.Show): Promise<string> {
await this.ready.promise;
const encoding = options ? options.encoding || 'utf8' : 'utf8';
const commitish = this.getCommitish(options);
const repositoryPath = this.getFsPath(repository);
const path = this.getFsPath(uri);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
if (encoding === 'binary') {
// note: contrary to what its jsdoc says, getBlobContents expects a (normalized) relative path
const relativePath = upath.normalizeSafe(Path.relative(repositoryPath, path));
return (await getBlobContents(repositoryPath, commitish, relativePath, { exec, env })).toString('binary');
}
return (await getTextContents(repositoryPath, commitish, path, { exec, env })).toString();
}
async stash(repository: Repository, options?: Readonly<{ action?: 'push', message?: string }>): Promise<void>;
async stash(repository: Repository, options: Readonly<{ action: 'list' }>): Promise<StashEntry[]>;
async stash(repository: Repository, options: Readonly<{ action: 'clear' }>): Promise<void>;
async stash(repository: Repository, options: Readonly<{ action: 'apply' | 'pop' | 'drop', id?: string }>): Promise<void>;
async stash(repository: Repository, options?: Git.Options.Stash): Promise<StashEntry[] | void> {
const repositoryPath: string = this.getFsPath(repository);
try {
if (!options || (options && !options.action)) {
await stash.push(repositoryPath, options ? options.message : undefined);
return;
}
switch (options.action) {
case 'push':
await stash.push(repositoryPath, options.message);
break;
case 'apply':
await stash.apply(repositoryPath, options.id);
break;
case 'pop':
await stash.pop(repositoryPath, options.id);
break;
case 'list':
const stashList = await stash.list(repositoryPath);
const stashes: StashEntry[] = [];
stashList.forEach(stashItem => {
const splitIndex = stashItem.indexOf(':');
stashes.push({
id: stashItem.substring(0, splitIndex),
message: stashItem.substring(splitIndex + 1)
});
});
return stashes;
case 'drop':
await stash.drop(repositoryPath, options.id);
break;
}
} catch (err) {
this.fail(repository, err);
}
}
async remote(repository: Repository): Promise<string[]>;
async remote(repository: Repository, options: { verbose: true }): Promise<Remote[]>;
async remote(repository: Repository, options?: Git.Options.Remote): Promise<string[] | Remote[]> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
const remotes = await this.getRemotes(repositoryPath);
const names = remotes.map(a => a.name);
return (options && options.verbose === true) ? remotes : names;
}
async exec(repository: Repository, args: string[], options?: Git.Options.Execution): Promise<GitResult> {
await this.ready.promise;
const repositoryPath = this.getFsPath(repository);
return this.manager.run(repository, async () => {
const name = options && options.name ? options.name : '';
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
let opts = {};
if (options) {
opts = {
...options
};
if (options.successExitCodes) {
opts = { ...opts, successExitCodes: new Set(options.successExitCodes) };
}
if (options.expectedErrors) {
opts = { ...opts, expectedErrors: new Set(options.expectedErrors) };
}
}
opts = {
...opts,
exec,
env
};
return git(args, repositoryPath, name, opts);
});
}
async diff(repository: Repository, options?: Git.Options.Diff): Promise<GitFileChange[]> {
await this.ready.promise;
const args = ['diff', '--name-status', '-C', '-M', '-z'];
args.push(this.mapRange((options || {}).range));
if (options && options.uri) {
const relativePath = Path.relative(this.getFsPath(repository), this.getFsPath(options.uri));
args.push(...['--', relativePath !== '' ? relativePath : '.']);
}
const result = await this.exec(repository, args);
return this.nameStatusParser.parse(repository.localUri, result.stdout.trim());
}
async log(repository: Repository, options?: Git.Options.Log): Promise<CommitWithChanges[]> {
await this.ready.promise;
// If remaining commits should be calculated by the backend, then run `git rev-list --count ${fromRevision | HEAD~fromRevision}`.
// How to use `mailmap` to map authors: https://www.kernel.org/pub/software/scm/git/docs/git-shortlog.html.
const args = ['log'];
if (options && options.branch) {
args.push(options.branch);
}
const range = this.mapRange((options || {}).range);
args.push(...[range, '-C', '-M', '-m', '--first-parent']);
const maxCount = options && options.maxCount ? options.maxCount : 0;
if (Number.isInteger(maxCount) && maxCount > 0) {
args.push(...['-n', `${maxCount}`]);
}
const placeholders: CommitPlaceholders[] =
options && options.shortSha ?
[CommitPlaceholders.SHORT_HASH, ...CommitDetailsParser.DEFAULT_PLACEHOLDERS.slice(1)] : CommitDetailsParser.DEFAULT_PLACEHOLDERS;
args.push(...['--name-status', '--date=unix', `--format=${this.commitDetailsParser.getFormat(...placeholders)}`, '-z', '--']);
if (options && options.uri) {
const file = Path.relative(this.getFsPath(repository), this.getFsPath(options.uri)) || '.';
args.push(...[file]);
}
const successExitCodes = [0, 128];
let result = await this.exec(repository, args, { successExitCodes });
if (result.exitCode !== 0) {
// Note that if no range specified then the 'to revision' defaults to HEAD
const rangeInvolvesHead = !options || !options.range || options.range.toRevision === 'HEAD';
const repositoryHasNoHead = !await this.revParse(repository, { ref: 'HEAD' });
// The 'log' command could potentially be valid when no HEAD if the revision range does not involve HEAD */
if (rangeInvolvesHead && repositoryHasNoHead) {
// The range involves HEAD but there is no HEAD. 'no head' most likely means a newly created repository with
// no commits, but could potentially have commits with no HEAD. This is effectively an empty repository.
return [];
}
// Either the range did not involve HEAD or HEAD exists. The error must be something else,
// so re-run but this time we don't ignore the error.
result = await this.exec(repository, args);
}
return this.commitDetailsParser.parse(
repository.localUri, result.stdout.trim()
.split(CommitDetailsParser.COMMIT_CHUNK_DELIMITER)
.filter(item => item && item.length > 0));
}
async revParse(repository: Repository, options: Git.Options.RevParse): Promise<string | undefined> {
const ref = options.ref;
const successExitCodes = [0, 128];
const result = await this.exec(repository, ['rev-parse', ref], { successExitCodes });
if (result.exitCode === 0) {
return result.stdout.trim(); // sha
}
}
async blame(repository: Repository, uri: string, options?: Git.Options.Blame): Promise<GitFileBlame | undefined> {
await this.ready.promise;
const args = ['blame', '--root', '--incremental'];
const file = Path.relative(this.getFsPath(repository), this.getFsPath(uri));
const repositoryPath = this.getFsPath(repository);
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const status = await getStatus(repositoryPath, true, this.limit, { exec, env });
const isUncommitted = (change: DugiteFileChange) => change.status === AppFileStatus.New && change.path === file;
const changes = status.workingDirectory.files;
if (changes.some(isUncommitted)) {
return undefined;
}
const stdin = options ? options.content : undefined;
if (stdin) {
args.push('--contents', '-');
}
const gitResult = await this.exec(repository, [...args, '--', file], { stdin });
const output = gitResult.stdout.trim();
const commitBodyReader = async (sha: string) => {
if (GitBlameParser.isUncommittedSha(sha)) {
return '';
}
const revResult = await this.exec(repository, ['rev-list', '--format=%B', '--max-count=1', sha]);
const revOutput = revResult.stdout;
let nl = revOutput.indexOf('\n');
if (nl > 0) {
nl = revOutput.indexOf('\n', nl + 1);
}
return revOutput.substring(Math.max(0, nl)).trim();
};
const blame = await this.blameParser.parse(uri, output, commitBodyReader);
return blame;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async lsFiles(repository: Repository, uri: string, options?: Git.Options.LsFiles): Promise<any> {
await this.ready.promise;
const args = ['ls-files'];
const relativePath = Path.relative(this.getFsPath(repository), this.getFsPath(uri));
const file = (relativePath === '') ? '.' : relativePath;
if (options && options.errorUnmatch) {
args.push('--error-unmatch', file);
const successExitCodes = [0, 1];
const expectedErrors = [GitError.OutsideRepository];
const result = await this.exec(repository, args, { successExitCodes, expectedErrors });
const { exitCode } = result;
return exitCode === 0;
}
}
private getCommitish(options?: Git.Options.Show): string {
if (options && options.commitish) {
return 'index' === options.commitish ? '' : options.commitish;
}
return '';
}
// TODO: akitta what about symlinks? What if the workspace root is a symlink?
// Maybe, we should use `--show-cdup` here instead of `--show-toplevel` because `show-toplevel` dereferences symlinks.
private async resolveContainingPath(repositoryPath: string): Promise<string | undefined> {
await this.ready.promise;
// Do not log an error if we are not contained in a Git repository. Treat exit code 128 as a success too.
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const options = { successExitCodes: new Set([0, 128]), exec, env };
const result = await git(['rev-parse', '--show-toplevel'], repositoryPath, 'rev-parse', options);
const out = result.stdout;
if (out && out.length !== 0) {
try {
const realpath = await fs.realpath(out.trim());
return realpath;
} catch (e) {
this.logger.error(e);
return undefined;
}
}
return undefined;
}
private async getRemotes(repositoryPath: string): Promise<Remote[]> {
await this.ready.promise;
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const result = await git(['remote', '-v'], repositoryPath, 'remote', { exec, env });
const out = result.stdout || '';
const results = out.trim().match(/\S+/g);
if (results) {
const values: Remote[] = [];
for (let i = 0; i < results.length; i += 6) {
values.push({ name: results[i], fetch: results[i + 1], push: results[i + 4] });
}
return values;
} else {
return [];
}
}
private async getDefaultRemote(repositoryPath: string, remote?: string): Promise<string | undefined> {
if (remote === undefined) {
const remotes = await this.getRemotes(repositoryPath);
const name = remotes.map(a => a.name);
return name.shift();
}
return remote;
}
private async getCurrentBranch(repositoryPath: string, localBranch?: string): Promise<Branch | string> {
await this.ready.promise;
if (localBranch !== undefined) {
return localBranch;
}
const [exec, env] = await Promise.all([this.execProvider.exec(), this.gitEnv.promise]);
const branch = await listBranch(repositoryPath, 'current', { exec, env });
if (branch === undefined) {
return this.fail(repositoryPath, 'No current branch.');
}
if (Array.isArray(branch)) {
return this.fail(repositoryPath, `Implementation error. Listing branch with the 'current' flag must return with single value. Was: ${branch}`);
}
return this.mapBranch(branch);
}
private getResetMode(mode: 'hard' | 'soft' | 'mixed'): GitResetMode {
switch (mode) {
case 'hard': return GitResetMode.Hard;
case 'soft': return GitResetMode.Soft;
case 'mixed': return GitResetMode.Mixed;
default: throw new Error(`Unexpected Git reset mode: ${mode}.`);
}
}
private async mapBranch(toMap: DugiteBranch): Promise<Branch> {
const tip = await this.mapTip(toMap.tip);
return {
name: toMap.name,
nameWithoutRemote: toMap.nameWithoutRemote,
remote: toMap.remote,
type: toMap.type,
upstream: toMap.upstream,
upstreamWithoutRemote: toMap.upstreamWithoutRemote,
tip
};
}
private async mapTip(toMap: DugiteCommit): Promise<Commit> {
const author = await this.mapCommitIdentity(toMap.author);
return {
author,
body: toMap.body,
parentSHAs: [...toMap.parentSHAs],
sha: toMap.sha,
summary: toMap.summary
};
}
private async mapCommitIdentity(toMap: DugiteCommitIdentity): Promise<CommitIdentity> {
return {
timestamp: toMap.date.toISOString(),
email: toMap.email,
name: toMap.name,
};
}
private async mapStatus(toMap: IStatusResult, repository: Repository): Promise<WorkingDirectoryStatus> {
const repositoryPath = this.getFsPath(repository);
const [aheadBehind, changes] = await Promise.all([this.mapAheadBehind(toMap.branchAheadBehind), this.mapFileChanges(toMap.workingDirectory, repositoryPath)]);
return {
exists: toMap.exists,
branch: toMap.currentBranch,
upstreamBranch: toMap.currentUpstreamBranch,
aheadBehind,
changes,
currentHead: toMap.currentTip,
incomplete: toMap.incomplete
};
}
private async mapAheadBehind(toMap: IAheadBehind | undefined): Promise<{ ahead: number, behind: number } | undefined> {
return toMap ? { ...toMap } : undefined;
}
private async mapFileChanges(toMap: DugiteStatus, repositoryPath: string): Promise<GitFileChange[]> {
return Promise.all(toMap.files
.filter(file => !this.isNestedGitRepository(file))
.map(file => this.mapFileChange(file, repositoryPath))
);
}
private isNestedGitRepository(fileChange: DugiteFileChange): boolean {
return fileChange.path.endsWith('/');
}
private async mapFileChange(toMap: DugiteFileChange, repositoryPath: string): Promise<GitFileChange> {
const [uri, status, oldUri] = await Promise.all([
this.getUri(Path.join(repositoryPath, toMap.path)),
this.mapFileStatus(toMap.status),
toMap.oldPath ? this.getUri(Path.join(repositoryPath, toMap.oldPath)) : undefined
]);
return {
uri,
status,
oldUri,
staged: toMap.staged
};
}
private mapFileStatus(toMap: AppFileStatus): GitFileStatus {
switch (toMap) {
case AppFileStatus.Conflicted: return GitFileStatus.Conflicted;
case AppFileStatus.Copied: return GitFileStatus.Copied;
case AppFileStatus.Deleted: return GitFileStatus.Deleted;
case AppFileStatus.Modified: return GitFileStatus.Modified;
case AppFileStatus.New: return GitFileStatus.New;
case AppFileStatus.Renamed: return GitFileStatus.Renamed;
default: throw new Error(`Unexpected application file status: ${toMap}`);
}
}
private mapRange(toMap: Git.Options.Range | undefined): string {
let range = 'HEAD';
if (toMap) {
if (typeof toMap.fromRevision === 'number') {
const toRevision = toMap.toRevision || 'HEAD';
range = `${toRevision}~${toMap.fromRevision}..${toRevision}`;
} else if (typeof toMap.fromRevision === 'string') {
range = `${toMap.fromRevision}${toMap.toRevision ? '..' + toMap.toRevision : ''}`;
} else if (toMap.toRevision) {
range = toMap.toRevision;
}
}
return range;
}
private getFsPath(repository: Repository | string): string {
const uri = typeof repository === 'string' ? repository : repository.localUri;
return FileUri.fsPath(uri);
}
private getUri(path: string): string {
return FileUri.create(path).toString();
}
private fail(repository: Repository | string, message?: string): never {
const p = typeof repository === 'string' ? repository : repository.localUri;
const m = message ? `${message} ` : '';
throw new Error(`${m}[${p}]`);
}
}

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { ContainerModule } from '@theia/core/shared/inversify';
import { GitEnvProvider, DefaultGitEnvProvider } from './git-env-provider';
export default new ContainerModule(bind => {
bind(DefaultGitEnvProvider).toSelf().inSingletonScope();
bind(GitEnvProvider).toService(DefaultGitEnvProvider);
});

View File

@@ -0,0 +1,56 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, postConstruct } from '@theia/core/shared/inversify';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
/**
* Provides an additional environment object when executing every single Git command.
*/
export const GitEnvProvider = Symbol('GitEnvProvider');
export interface GitEnvProvider extends Disposable {
/**
* The additional environment object that will be set before executing every single Git command.
*/
getEnv(): Promise<Object>;
}
/**
* The default Git environment provider. Does nothing.
*/
@injectable()
export class DefaultGitEnvProvider implements GitEnvProvider {
protected toDispose = new DisposableCollection();
@postConstruct()
protected init(): void {
// NOOP
}
async getEnv(): Promise<Object> {
return {};
}
dispose(): void {
if (!this.toDispose.disposed) {
this.toDispose.dispose();
}
}
}

View File

@@ -0,0 +1,123 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { ContainerModule, Container, interfaces } from '@theia/core/shared/inversify';
import { Git, GitPath } from '../common/git';
import { GitWatcherPath, GitWatcherClient, GitWatcherServer } from '../common/git-watcher';
import { DugiteGit, OutputParser, NameStatusParser, CommitDetailsParser, GitBlameParser } from './dugite-git';
import { DugiteGitWatcherServer } from './dugite-git-watcher';
import { ConnectionHandler, RpcConnectionHandler, ILogger } from '@theia/core/lib/common';
import { GitRepositoryManager } from './git-repository-manager';
import { GitRepositoryWatcherFactory, GitRepositoryWatcherOptions, GitRepositoryWatcher } from './git-repository-watcher';
import { GitLocator } from './git-locator/git-locator-protocol';
import { GitLocatorClient } from './git-locator/git-locator-client';
import { GitLocatorImpl } from './git-locator/git-locator-impl';
import { GitExecProvider } from './git-exec-provider';
import { GitPromptServer, GitPromptClient, GitPrompt } from '../common/git-prompt';
import { DugiteGitPromptServer } from './dugite-git-prompt';
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
import { DefaultGitInit, GitInit } from './init/git-init';
import { bindGitPreferences } from '../common/git-preferences';
const SINGLE_THREADED = process.argv.indexOf('--no-cluster') !== -1;
export interface GitBindingOptions {
readonly bindManager: (binding: interfaces.BindingToSyntax<{}>) => interfaces.BindingWhenOnSyntax<{}>;
}
export namespace GitBindingOptions {
export const Default: GitBindingOptions = {
bindManager(binding: interfaces.BindingToSyntax<{}>): interfaces.BindingWhenOnSyntax<{}> {
return binding.to(GitRepositoryManager).inSingletonScope();
}
};
}
export function bindGit(bind: interfaces.Bind, bindingOptions: GitBindingOptions = GitBindingOptions.Default): void {
bindingOptions.bindManager(bind(GitRepositoryManager));
bind(GitRepositoryWatcherFactory).toFactory(ctx => (options: GitRepositoryWatcherOptions) => {
// GitRepositoryWatcherFactory is injected into the singleton GitRepositoryManager only.
// GitRepositoryWatcher instances created there should be able to access the (singleton) Git.
const child = new Container({ defaultScope: 'Singleton' });
child.parent = ctx.container;
child.bind(GitRepositoryWatcher).toSelf();
child.bind(GitRepositoryWatcherOptions).toConstantValue(options);
return child.get(GitRepositoryWatcher);
});
if (SINGLE_THREADED) {
bind(GitLocator).toDynamicValue(ctx => {
const logger = ctx.container.get<ILogger>(ILogger);
return new GitLocatorImpl({
info: (message, ...args) => logger.info(message, ...args),
error: (message, ...args) => logger.error(message, ...args)
});
});
} else {
bind(GitLocator).to(GitLocatorClient);
}
bind(OutputParser).toSelf().inSingletonScope();
bind(NameStatusParser).toSelf().inSingletonScope();
bind(CommitDetailsParser).toSelf().inSingletonScope();
bind(GitBlameParser).toSelf().inSingletonScope();
bind(GitExecProvider).toSelf().inSingletonScope();
bind(DugiteGit).toSelf().inSingletonScope();
bind(Git).toService(DugiteGit);
bind(DefaultGitInit).toSelf();
bind(GitInit).toService(DefaultGitInit);
bind(ConnectionContainerModule).toConstantValue(gitConnectionModule);
bindGitPreferences(bind);
}
const gitConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
// DugiteGit is bound in singleton scope; each connection should use a proxy for that.
const GitProxy = Symbol('GitProxy');
bind(GitProxy).toDynamicValue(ctx => new Proxy(ctx.container.get(DugiteGit), {}));
bindBackendService(GitPath, GitProxy);
});
export function bindRepositoryWatcher(bind: interfaces.Bind): void {
bind(DugiteGitWatcherServer).toSelf();
bind(GitWatcherServer).toService(DugiteGitWatcherServer);
}
export function bindPrompt(bind: interfaces.Bind): void {
bind(DugiteGitPromptServer).toSelf().inSingletonScope();
bind(GitPromptServer).toDynamicValue(context => context.container.get(DugiteGitPromptServer));
}
export default new ContainerModule(bind => {
bindGit(bind);
bindRepositoryWatcher(bind);
bind(ConnectionHandler).toDynamicValue(context =>
new RpcConnectionHandler<GitWatcherClient>(GitWatcherPath, client => {
const server = context.container.get<GitWatcherServer>(GitWatcherServer);
server.setClient(client);
client.onDidCloseConnection(() => server.dispose());
return server;
})
).inSingletonScope();
bindPrompt(bind);
bind(ConnectionHandler).toDynamicValue(context =>
new RpcConnectionHandler<GitPromptClient>(GitPrompt.WS_PATH, client => {
const server = context.container.get<GitPromptServer>(GitPromptServer);
server.setClient(client);
client.onDidCloseConnection(() => server.dispose());
return server;
})
).inSingletonScope();
});

View File

@@ -0,0 +1,103 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable } from '@theia/core/shared/inversify';
import { Disposable, MaybePromise } from '@theia/core/';
import { IGitExecutionOptions } from 'dugite-extra/lib/core/git';
/**
* Provides an execution function that will be used to perform the Git commands.
* This is the default, `NOOP`, provider and always resoles to `undefined`.
*
* If you would like to use, for instance, Git over SSH, you could rebind this default provider and have something like this:
* ```typescript
* @injectable()
* export class GitSshExecProvider extends GitExecProvider {
*
* // eslint-disable-next-line @typescript-eslint/no-explicit-any
* protected deferred = new Deferred<any>();
*
* @postConstruct()
* protected init(): void {
* this.doInit();
* }
*
* protected async doInit(): Promise<void> {
* const connection = await new SSH().connect({
* host: 'your-host',
* username: 'your-username',
* password: 'your-password'
* });
* const { stdout } = await connection.execCommand('which git');
* process.env.LOCAL_GIT_PATH = stdout.trim();
* this.deferred.resolve(connection);
* }
*
* async exec(): Promise<IGitExecutionOptions.ExecFunc> {
* const connection = await this.deferred.promise;
* const gitPath = process.env.LOCAL_GIT_PATH;
* if (!gitPath) {
* throw new Error("The 'LOCAL_GIT_PATH' must be set.");
* }
* return async (
* args: string[],
* options: { cwd: string, stdin?: string },
* callback: (error: Error | null, stdout: string, stderr: string) => void) => {
*
* const command = `${gitPath} ${args.join(' ')}`;
* const { stdout, stderr, code } = await connection.execCommand(command, options);
* // eslint-disable-next-line no-null/no-null
* let error: Error | null = null;
* if (code) {
* error = new Error(stderr || `Unknown error when executing the Git command. ${args}.`);
* // eslint-disable-next-line @typescript-eslint/no-explicit-any
* (error as any).code = code;
* }
* callback(error, stdout, stderr);
* };
* }
*
* dispose(): void {
* super.dispose();
* // Dispose your connection.
* this.deferred.promise.then(connection => {
* if (connection && 'dispose' in connection && typeof connection.dispose === 'function') {
* connection.dispose();
* }
* });
* }
*
* }
* ```
*/
@injectable()
export class GitExecProvider implements Disposable {
/**
* Provides a function that will be used to execute the Git commands. If resolves to `undefined`, then
* the embedded Git executable will be used from [dugite](https://github.com/desktop/dugite).
*/
exec(): MaybePromise<IGitExecutionOptions.ExecFunc | undefined> {
return undefined;
}
dispose(): void {
// NOOP
}
}
export { IGitExecutionOptions };

View File

@@ -0,0 +1,55 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 * as paths from 'path';
import { inject, injectable } from '@theia/core/shared/inversify';
import { RpcProxyFactory, DisposableCollection } from '@theia/core';
import { IPCConnectionProvider } from '@theia/core/lib/node';
import { GitLocator, GitLocateOptions } from './git-locator-protocol';
@injectable()
export class GitLocatorClient implements GitLocator {
protected readonly toDispose = new DisposableCollection();
@inject(IPCConnectionProvider)
protected readonly ipcConnectionProvider: IPCConnectionProvider;
dispose(): void {
this.toDispose.dispose();
}
locate(path: string, options: GitLocateOptions): Promise<string[]> {
return new Promise((resolve, reject) => {
const toStop = this.ipcConnectionProvider.listen({
serverName: 'git-locator',
entryPoint: paths.join(__dirname, 'git-locator-host'),
}, async connection => {
const proxyFactory = new RpcProxyFactory<GitLocator>();
const remote = proxyFactory.createProxy();
proxyFactory.listen(connection);
try {
resolve(await remote.locate(path, options));
} catch (e) {
reject(e);
} finally {
toStop.dispose();
}
});
this.toDispose.push(toStop);
});
}
}

View File

@@ -0,0 +1,24 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 '@theia/core/shared/reflect-metadata';
import { RpcProxyFactory } from '@theia/core';
import { IPCEntryPoint } from '@theia/core/lib/node/messaging/ipc-protocol';
import { GitLocatorImpl } from './git-locator-impl';
export default <IPCEntryPoint>(connection =>
new RpcProxyFactory(new GitLocatorImpl()).listen(connection)
);

View File

@@ -0,0 +1,152 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 * as fs from '@theia/core/shared/fs-extra';
import * as path from 'path';
import { GitLocator, GitLocateOptions } from './git-locator-protocol';
export type FindGitRepositories = (path: string, progressCb: (repos: string[]) => void) => Promise<string[]>;
const findGitRepositories: FindGitRepositories = require('find-git-repositories');
export interface GitLocateContext {
maxCount: number
readonly visited: Map<string, boolean>
}
export class GitLocatorImpl implements GitLocator {
protected readonly options: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info: (message: string, ...args: any[]) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (message: string, ...args: any[]) => void
};
constructor(options?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info?: (message: string, ...args: any[]) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error?: (message: string, ...args: any[]) => void
}) {
this.options = {
info: (message, ...args) => console.info(message, ...args),
error: (message, ...args) => console.error(message, ...args),
...options
};
}
dispose(): void {
}
async locate(basePath: string, options: GitLocateOptions): Promise<string[]> {
return this.doLocate(basePath, {
maxCount: typeof options.maxCount === 'number' ? options.maxCount : -1,
visited: new Map<string, boolean>()
});
}
protected async doLocate(basePath: string, context: GitLocateContext): Promise<string[]> {
const realBasePath = await fs.realpath(basePath);
if (context.visited.has(realBasePath)) {
return [];
}
context.visited.set(realBasePath, true);
try {
const stat = await fs.stat(realBasePath);
if (!stat.isDirectory()) {
return [];
}
const progress: string[] = [];
const paths = await findGitRepositories(realBasePath, repositories => {
progress.push(...repositories);
if (context.maxCount >= 0 && progress.length >= context.maxCount) {
return progress.slice(0, context.maxCount).map(GitLocatorImpl.map);
}
});
if (context.maxCount >= 0 && paths.length >= context.maxCount) {
return await Promise.all(paths.slice(0, context.maxCount).map(GitLocatorImpl.map));
}
const repositoryPaths = await Promise.all(paths.map(GitLocatorImpl.map));
return this.locateFrom(
newContext => this.generateNested(repositoryPaths, newContext),
context,
repositoryPaths
);
} catch (e) {
return [];
}
}
protected * generateNested(repositoryPaths: string[], context: GitLocateContext): IterableIterator<Promise<string[]>> {
for (const repository of repositoryPaths) {
yield this.locateNested(repository, context);
}
}
protected locateNested(repositoryPath: string, context: GitLocateContext): Promise<string[]> {
return new Promise<string[]>(resolve => {
fs.readdir(repositoryPath, async (err, files) => {
if (err) {
this.options.error(err.message, err);
resolve([]);
} else {
resolve(this.locateFrom(
newContext => this.generateRepositories(repositoryPath, files, newContext),
context
));
}
});
});
}
protected * generateRepositories(repositoryPath: string, files: string[], context: GitLocateContext): IterableIterator<Promise<string[]>> {
for (const file of files) {
if (file !== '.git') {
yield this.doLocate(path.join(repositoryPath, file), {
...context
});
}
}
}
protected async locateFrom(
generator: (context: GitLocateContext) => IterableIterator<Promise<string[]>>, parentContext: GitLocateContext, initial?: string[]
): Promise<string[]> {
const result: string[] = [];
if (initial) {
result.push(...initial);
}
const context = {
...parentContext,
maxCount: parentContext.maxCount - result.length
};
for (const locateRepositories of generator(context)) {
const repositories = await locateRepositories;
result.push(...repositories);
if (context.maxCount >= 0) {
if (result.length >= context.maxCount) {
return result.slice(0, context.maxCount);
}
context.maxCount -= repositories.length;
}
}
return result;
}
static async map(repository: string): Promise<string> {
return fs.realpath(path.dirname(repository));
}
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { Disposable } from '@theia/core';
export interface GitLocateOptions {
readonly maxCount?: number;
}
export const GitLocator = Symbol('GitLocator');
export interface GitLocator extends Disposable {
/**
* Resolves to the repository paths under the given absolute path.
*/
locate(path: string, options: GitLocateOptions): Promise<string[]>;
}

View File

@@ -0,0 +1,49 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import { ReferenceCollection, Reference } from '@theia/core';
import { Repository } from '../common';
import { GitRepositoryWatcher, GitRepositoryWatcherFactory } from './git-repository-watcher';
@injectable()
export class GitRepositoryManager {
@inject(GitRepositoryWatcherFactory)
protected readonly watcherFactory: GitRepositoryWatcherFactory;
protected readonly watchers = new ReferenceCollection<Repository, GitRepositoryWatcher>(
repository => this.watcherFactory({ repository })
);
run<T>(repository: Repository, op: () => Promise<T>): Promise<T> {
const result = op();
result.then(() => this.sync(repository).catch(e => console.log(e)));
return result;
}
getWatcher(repository: Repository): Promise<Reference<GitRepositoryWatcher>> {
return this.watchers.acquire(repository);
}
protected async sync(repository: Repository): Promise<void> {
const reference = await this.getWatcher(repository);
const watcher = reference.object;
// dispose the reference once the next sync cycle is actually completed
watcher.sync().then(() => reference.dispose());
}
}

View File

@@ -0,0 +1,132 @@
// *****************************************************************************
// Copyright (C) 2017 TypeFox 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 { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { Disposable, Event, Emitter, ILogger } from '@theia/core';
import { Git, Repository, WorkingDirectoryStatus, GitUtils } from '../common';
import { GitStatusChangeEvent } from '../common/git-watcher';
import { Deferred } from '@theia/core/lib/common/promise-util';
export const GitRepositoryWatcherFactory = Symbol('GitRepositoryWatcherFactory');
export type GitRepositoryWatcherFactory = (options: GitRepositoryWatcherOptions) => GitRepositoryWatcher;
@injectable()
export class GitRepositoryWatcherOptions {
readonly repository: Repository;
}
@injectable()
export class GitRepositoryWatcher implements Disposable {
protected readonly onGitStatusChangedEmitter = new Emitter<GitStatusChangeEvent>();
readonly onGitStatusChanged: Event<GitStatusChangeEvent> = this.onGitStatusChangedEmitter.event;
@inject(Git)
protected readonly git: Git;
@inject(ILogger)
protected readonly logger: ILogger;
@inject(GitRepositoryWatcherOptions)
protected readonly options: GitRepositoryWatcherOptions;
@postConstruct()
protected init(): void {
this.spinTheLoop();
}
watch(): void {
if (this.watching) {
console.debug('Repository watcher is already active.');
return;
}
this.watching = true;
this.sync();
}
protected syncWorkPromises: Deferred<void>[] = [];
sync(): Promise<void> {
if (this.idle) {
if (this.interruptIdle) {
this.interruptIdle();
}
} else {
this.skipNextIdle = true;
}
const result = new Deferred<void>();
this.syncWorkPromises.push(result);
return result.promise;
}
protected disposed = false;
dispose(): void {
if (!this.disposed) {
this.disposed = true;
if (this.idle) {
if (this.interruptIdle) {
this.interruptIdle();
}
}
}
}
protected watching = false;
protected idle = true;
protected interruptIdle: (() => void) | undefined;
protected skipNextIdle = false;
protected async spinTheLoop(): Promise<void> {
while (!this.disposed) {
// idle
if (this.skipNextIdle) {
this.skipNextIdle = false;
} else {
const idleTimeout = this.watching ? 5000 : /* super long */ 1000 * 60 * 60 * 24;
await new Promise<void>(resolve => {
this.idle = true;
const id = setTimeout(resolve, idleTimeout);
this.interruptIdle = () => { clearTimeout(id); resolve(); };
}).then(() => {
this.idle = false;
this.interruptIdle = undefined;
});
}
// work
await this.syncStatus();
this.syncWorkPromises.splice(0, this.syncWorkPromises.length).forEach(d => d.resolve());
}
}
protected status: WorkingDirectoryStatus | undefined;
protected async syncStatus(): Promise<void> {
try {
const source = this.options.repository;
const oldStatus = this.status;
const newStatus = await this.git.status(source);
if (!WorkingDirectoryStatus.equals(newStatus, oldStatus)) {
this.status = newStatus;
this.onGitStatusChangedEmitter.fire({ source, status: newStatus, oldStatus });
}
} catch (error) {
if (!GitUtils.isRepositoryDoesNotExistError(error)) {
const { localUri } = this.options.repository;
this.logger.error('Error occurred while synchronizing the status of the repository.', localUri, error);
}
}
}
}

View File

@@ -0,0 +1,91 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable, inject } from '@theia/core/shared/inversify';
import findGit from 'find-git-exec';
import { dirname } from 'path';
import { pathExists } from '@theia/core/shared/fs-extra';
import { ILogger } from '@theia/core/lib/common/logger';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { MessageService } from '@theia/core';
/**
* Initializer hook for Git.
*/
export const GitInit = Symbol('GitInit');
export interface GitInit extends Disposable {
/**
* Called before `Git` is ready to be used in Theia. Git operations cannot be executed before the returning promise is not resolved or rejected.
*/
init(): Promise<void>;
}
/**
* The default initializer. It is used in the browser.
*
* Configures the Git extension to use the Git executable from the `PATH`.
*/
@injectable()
export class DefaultGitInit implements GitInit {
protected readonly toDispose = new DisposableCollection();
@inject(ILogger)
protected readonly logger: ILogger;
@inject(MessageService)
protected readonly messages: MessageService;
async init(): Promise<void> {
const { env } = process;
try {
const { execPath, path, version } = await findGit();
if (!!execPath && !!path && !!version) {
// https://github.com/desktop/dugite/issues/111#issuecomment-323222834
// Instead of the executable path, we need the root directory of Git.
const dir = dirname(dirname(path));
const [execPathOk, pathOk, dirOk] = await Promise.all([pathExists(execPath), pathExists(path), pathExists(dir)]);
if (execPathOk && pathOk && dirOk) {
if (typeof env.LOCAL_GIT_DIRECTORY !== 'undefined' && env.LOCAL_GIT_DIRECTORY !== dir) {
this.logger.error(`Misconfigured env.LOCAL_GIT_DIRECTORY: ${env.LOCAL_GIT_DIRECTORY}. dir was: ${dir}`);
this.messages.error('The LOCAL_GIT_DIRECTORY env variable was already set to a different value.', { timeout: 0 });
return;
}
if (typeof env.GIT_EXEC_PATH !== 'undefined' && env.GIT_EXEC_PATH !== execPath) {
this.logger.error(`Misconfigured env.GIT_EXEC_PATH: ${env.GIT_EXEC_PATH}. execPath was: ${execPath}`);
this.messages.error('The GIT_EXEC_PATH env variable was already set to a different value.', { timeout: 0 });
return;
}
process.env.LOCAL_GIT_DIRECTORY = dir;
process.env.GIT_EXEC_PATH = execPath;
this.logger.info(`Using Git [${version}] from the PATH. (${path})`);
return;
}
}
this.messages.error('Could not find Git on the PATH.', { timeout: 0 });
} catch (err) {
this.logger.error(err);
this.messages.error('An unexpected error occurred when locating the Git executable.', { timeout: 0 });
}
}
dispose(): void {
this.toDispose.dispose();
}
}

View File

@@ -0,0 +1,54 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { Container, interfaces } from '@theia/core/shared/inversify';
import { Git } from '../../common/git';
import { DugiteGit } from '../dugite-git';
import { bindGit, GitBindingOptions } from '../git-backend-module';
import { bindLogger } from '@theia/core/lib/node/logger-backend-module';
import { NoSyncRepositoryManager } from '.././test/no-sync-repository-manager';
import { GitEnvProvider, DefaultGitEnvProvider } from '../env/git-env-provider';
import { MessageService, LogLevel } from '@theia/core/lib/common';
import { MessageClient } from '@theia/core';
import { ILogger } from '@theia/core/lib/common/logger';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function initializeBindings(): { container: Container, bind: interfaces.Bind } {
const container = new Container();
const bind = container.bind.bind(container);
bind(DefaultGitEnvProvider).toSelf().inRequestScope();
bind(GitEnvProvider).toService(DefaultGitEnvProvider);
bind(MessageService).toSelf();
bind(MessageClient).toSelf();
bindLogger(bind);
return { container, bind };
}
/**
* For testing only.
*/
export async function createGit(bindingOptions: GitBindingOptions = GitBindingOptions.Default): Promise<Git> {
const { container, bind } = initializeBindings();
bindGit(bind, {
bindManager(binding: interfaces.BindingToSyntax<{}>): interfaces.BindingWhenOnSyntax<{}> {
return binding.to(NoSyncRepositoryManager).inSingletonScope();
}
});
(container.get(ILogger) as ILogger).setLogLevel(LogLevel.ERROR);
const git = container.get(DugiteGit);
await git.exec({ localUri: '' }, ['--version']); // Enforces eager Git initialization by setting the `LOCAL_GIT_DIRECTORY` and `GIT_EXEC_PATH` env variables.
return git;
}

View File

@@ -0,0 +1,31 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 { injectable } from '@theia/core/shared/inversify';
import { Repository } from '../../common/git-model';
import { GitRepositoryManager } from '../git-repository-manager';
/**
* Repository manager that does not synchronizes the status. For testing purposes.
*/
@injectable()
export class NoSyncRepositoryManager extends GitRepositoryManager {
protected override sync(repository: Repository): Promise<void> {
return Promise.resolve();
}
}