deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
187
packages/git/src/browser/blame/blame-contribution.ts
Normal file
187
packages/git/src/browser/blame/blame-contribution.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
250
packages/git/src/browser/blame/blame-decorator.ts
Normal file
250
packages/git/src/browser/blame/blame-decorator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
packages/git/src/browser/blame/blame-manager.ts
Normal file
43
packages/git/src/browser/blame/blame-manager.ts
Normal 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 });
|
||||
}
|
||||
|
||||
}
|
||||
31
packages/git/src/browser/blame/blame-module.ts
Normal file
31
packages/git/src/browser/blame/blame-module.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
254
packages/git/src/browser/diff/git-diff-contribution.ts
Normal file
254
packages/git/src/browser/diff/git-diff-contribution.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
packages/git/src/browser/diff/git-diff-frontend-module.ts
Normal file
53
packages/git/src/browser/diff/git-diff-frontend-module.ts
Normal 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;
|
||||
}
|
||||
159
packages/git/src/browser/diff/git-diff-header-widget.tsx
Normal file
159
packages/git/src/browser/diff/git-diff-header-widget.tsx
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
131
packages/git/src/browser/diff/git-diff-tree-model.tsx
Normal file
131
packages/git/src/browser/diff/git-diff-tree-model.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
151
packages/git/src/browser/diff/git-diff-widget.tsx
Normal file
151
packages/git/src/browser/diff/git-diff-widget.tsx
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
30
packages/git/src/browser/diff/git-opener-in-primary-area.ts
Normal file
30
packages/git/src/browser/diff/git-opener-in-primary-area.ts
Normal 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' });
|
||||
|
||||
}
|
||||
}
|
||||
22
packages/git/src/browser/diff/git-resource-opener.ts
Normal file
22
packages/git/src/browser/diff/git-resource-opener.ts
Normal 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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
312
packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
Normal file
312
packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
26
packages/git/src/browser/dirty-diff/dirty-diff-module.ts
Normal file
26
packages/git/src/browser/dirty-diff/dirty-diff-module.ts
Normal 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);
|
||||
}
|
||||
87
packages/git/src/browser/git-commit-message-validator.ts
Normal file
87
packages/git/src/browser/git-commit-message-validator.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
1220
packages/git/src/browser/git-contribution.ts
Normal file
1220
packages/git/src/browser/git-contribution.ts
Normal file
File diff suppressed because it is too large
Load Diff
120
packages/git/src/browser/git-decoration-provider.ts
Normal file
120
packages/git/src/browser/git-decoration-provider.ts
Normal 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)));
|
||||
}
|
||||
|
||||
}
|
||||
33
packages/git/src/browser/git-error-handler.ts
Normal file
33
packages/git/src/browser/git-error-handler.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
33
packages/git/src/browser/git-file-service-contribution.ts
Normal file
33
packages/git/src/browser/git-file-service-contribution.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
94
packages/git/src/browser/git-file-system-provider.ts
Normal file
94
packages/git/src/browser/git-file-system-provider.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
99
packages/git/src/browser/git-frontend-module.ts
Normal file
99
packages/git/src/browser/git-frontend-module.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
602
packages/git/src/browser/git-quick-open-service.ts
Normal file
602
packages/git/src/browser/git-quick-open-service.ts
Normal 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); };
|
||||
}
|
||||
244
packages/git/src/browser/git-repository-provider.spec.ts
Normal file
244
packages/git/src/browser/git-repository-provider.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
214
packages/git/src/browser/git-repository-provider.ts
Normal file
214
packages/git/src/browser/git-repository-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
133
packages/git/src/browser/git-repository-tracker.ts
Normal file
133
packages/git/src/browser/git-repository-tracker.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
67
packages/git/src/browser/git-resource-resolver.ts
Normal file
67
packages/git/src/browser/git-resource-resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
65
packages/git/src/browser/git-resource.ts
Normal file
65
packages/git/src/browser/git-resource.ts
Normal 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 { }
|
||||
}
|
||||
141
packages/git/src/browser/git-scm-provider.spec.ts
Normal file
141
packages/git/src/browser/git-scm-provider.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
689
packages/git/src/browser/git-scm-provider.ts
Normal file
689
packages/git/src/browser/git-scm-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
186
packages/git/src/browser/git-sync-service.ts
Normal file
186
packages/git/src/browser/git-sync-service.ts
Normal 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';
|
||||
}
|
||||
63
packages/git/src/browser/git-uri-label-contribution.ts
Normal file
63
packages/git/src/browser/git-uri-label-contribution.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
136
packages/git/src/browser/history/git-commit-detail-widget.tsx
Normal file
136
packages/git/src/browser/history/git-commit-detail-widget.tsx
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
82
packages/git/src/browser/history/git-history-support.ts
Normal file
82
packages/git/src/browser/history/git-history-support.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
29
packages/git/src/browser/prompt/git-prompt-module.ts
Normal file
29
packages/git/src/browser/prompt/git-prompt-module.ts
Normal 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();
|
||||
}
|
||||
108
packages/git/src/browser/style/diff.css
Normal file
108
packages/git/src/browser/style/diff.css
Normal 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;
|
||||
}
|
||||
6
packages/git/src/browser/style/git-diff.svg
Normal file
6
packages/git/src/browser/style/git-diff.svg
Normal 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 |
24
packages/git/src/browser/style/git-icons.css
Normal file
24
packages/git/src/browser/style/git-icons.css
Normal 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");
|
||||
}
|
||||
4
packages/git/src/browser/style/git.svg
Normal file
4
packages/git/src/browser/style/git.svg
Normal 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 |
56
packages/git/src/browser/style/index.css
Normal file
56
packages/git/src/browser/style/index.css
Normal 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);
|
||||
}
|
||||
497
packages/git/src/common/git-model.ts
Normal file
497
packages/git/src/common/git-model.ts
Normal 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;
|
||||
}
|
||||
94
packages/git/src/common/git-preferences.ts
Normal file
94
packages/git/src/common/git-preferences.ts
Normal 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);
|
||||
}
|
||||
173
packages/git/src/common/git-prompt.ts
Normal file
173
packages/git/src/common/git-prompt.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
184
packages/git/src/common/git-watcher.ts
Normal file
184
packages/git/src/common/git-watcher.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
955
packages/git/src/common/git.ts
Normal file
955
packages/git/src/common/git.ts
Normal 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]/);
|
||||
}
|
||||
|
||||
}
|
||||
19
packages/git/src/common/index.ts
Normal file
19
packages/git/src/common/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
4
packages/git/src/electron-node/askpass/askpass-empty.sh
Executable file
4
packages/git/src/electron-node/askpass/askpass-empty.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Based on: https://github.com/Microsoft/vscode/blob/b1d403f8665603d1db44d3dc013f7ebd06bc526e/extensions/git/src/askpass-empty.sh
|
||||
|
||||
echo ''
|
||||
80
packages/git/src/electron-node/askpass/askpass-main.ts
Normal file
80
packages/git/src/electron-node/askpass/askpass-main.ts
Normal 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);
|
||||
4
packages/git/src/electron-node/askpass/askpass.sh
Executable file
4
packages/git/src/electron-node/askpass/askpass.sh
Executable 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" $*
|
||||
203
packages/git/src/electron-node/askpass/askpass.ts
Normal file
203
packages/git/src/electron-node/askpass/askpass.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
26
packages/git/src/electron-node/env/electron-git-env-module.ts
vendored
Normal file
26
packages/git/src/electron-node/env/electron-git-env-module.ts
vendored
Normal 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);
|
||||
});
|
||||
47
packages/git/src/electron-node/env/electron-git-env-provider.ts
vendored
Normal file
47
packages/git/src/electron-node/env/electron-git-env-provider.ts
vendored
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
39
packages/git/src/node/dugite-git-prompt.ts
Normal file
39
packages/git/src/node/dugite-git-prompt.ts
Normal 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.');
|
||||
}
|
||||
|
||||
}
|
||||
102
packages/git/src/node/dugite-git-watcher.slow-spec.ts
Normal file
102
packages/git/src/node/dugite-git-watcher.slow-spec.ts
Normal 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));
|
||||
}
|
||||
85
packages/git/src/node/dugite-git-watcher.ts
Normal file
85
packages/git/src/node/dugite-git-watcher.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
56
packages/git/src/node/dugite-git.slow-spec.ts
Normal file
56
packages/git/src/node/dugite-git.slow-spec.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
823
packages/git/src/node/dugite-git.spec.ts
Normal file
823
packages/git/src/node/dugite-git.spec.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
951
packages/git/src/node/dugite-git.ts
Normal file
951
packages/git/src/node/dugite-git.ts
Normal 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}]`);
|
||||
}
|
||||
|
||||
}
|
||||
23
packages/git/src/node/env/git-env-module.ts
vendored
Normal file
23
packages/git/src/node/env/git-env-module.ts
vendored
Normal 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);
|
||||
});
|
||||
56
packages/git/src/node/env/git-env-provider.ts
vendored
Normal file
56
packages/git/src/node/env/git-env-provider.ts
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
123
packages/git/src/node/git-backend-module.ts
Normal file
123
packages/git/src/node/git-backend-module.ts
Normal 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();
|
||||
});
|
||||
103
packages/git/src/node/git-exec-provider.ts
Normal file
103
packages/git/src/node/git-exec-provider.ts
Normal 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 };
|
||||
55
packages/git/src/node/git-locator/git-locator-client.ts
Normal file
55
packages/git/src/node/git-locator/git-locator-client.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
24
packages/git/src/node/git-locator/git-locator-host.ts
Normal file
24
packages/git/src/node/git-locator/git-locator-host.ts
Normal 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)
|
||||
);
|
||||
152
packages/git/src/node/git-locator/git-locator-impl.ts
Normal file
152
packages/git/src/node/git-locator/git-locator-impl.ts
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
31
packages/git/src/node/git-locator/git-locator-protocol.ts
Normal file
31
packages/git/src/node/git-locator/git-locator-protocol.ts
Normal 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[]>;
|
||||
|
||||
}
|
||||
49
packages/git/src/node/git-repository-manager.ts
Normal file
49
packages/git/src/node/git-repository-manager.ts
Normal 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());
|
||||
}
|
||||
|
||||
}
|
||||
132
packages/git/src/node/git-repository-watcher.ts
Normal file
132
packages/git/src/node/git-repository-watcher.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
91
packages/git/src/node/init/git-init.ts
Normal file
91
packages/git/src/node/init/git-init.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
54
packages/git/src/node/test/binding-helper.ts
Normal file
54
packages/git/src/node/test/binding-helper.ts
Normal 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;
|
||||
}
|
||||
31
packages/git/src/node/test/no-sync-repository-manager.ts
Normal file
31
packages/git/src/node/test/no-sync-repository-manager.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user