Skip to content

Commit

Permalink
Add Dirty Diff Peek View (eclipse-theia#13104)
Browse files Browse the repository at this point in the history
  • Loading branch information
pisv authored Apr 23, 2024
1 parent 47ec23b commit 746f8ff
Show file tree
Hide file tree
Showing 28 changed files with 1,649 additions and 181 deletions.
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@

- [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/)

<!-- ## not yet released
## not yet released

<a name="breaking_changes_not_yet_released">[Breaking Changes:](#breaking_changes_not_yet_released)</a> -->
- [scm] added support for dirty diff peek view [#13104](https://github.com/eclipse-theia/theia/pull/13104)

<a name="breaking_changes_not_yet_released">[Breaking Changes:](#breaking_changes_not_yet_released)</a>
- [scm] revised some of the dirty diff related types [#13104](https://github.com/eclipse-theia/theia/pull/13104)
- replaced `DirtyDiff.added/removed/modified` with `changes`, which provides more detailed information about the changes
- changed the semantics of `LineRange` to represent a range that spans up to but not including the `end` line (previously, it included the `end` line)
- changed the signature of `DirtyDiffDecorator.toDeltaDecoration(LineRange | number, EditorDecorationOptions)` to `toDeltaDecoration(Change)`

## v1.48.0 - 03/28/2024

Expand Down Expand Up @@ -95,7 +101,7 @@
- Moved `ThemaIcon` and `ThemeColor` to the common folder
- Minor typing adjustments in QuickPickService: in parti
- FileUploadService: moved id field from data transfer item to the corresponding file info
- The way we instantiate monaco services has changed completely: if you touch monaco services in your code, please read the description in the
- The way we instantiate monaco services has changed completely: if you touch monaco services in your code, please read the description in the
file comment in `monaco-init.ts`.

## v1.46.0 - 01/25/2024
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/browser/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable
setEncoding(encoding: string, mode: EncodingMode): void;

readonly onEncodingChanged: Event<string>;

shouldDisplayDirtyDiff(): boolean;
}

export interface Dimension {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

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';

Expand All @@ -25,10 +26,13 @@ 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.dirtyDiffManager.onDirtyDiffUpdate(update => {
this.dirtyDiffDecorator.applyDecorations(update);
this.dirtyDiffNavigator.handleDirtyDiffUpdate(update);
});
}

}
45 changes: 29 additions & 16 deletions packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ export class DirtyDiffManager {

protected async handleEditorCreated(editorWidget: EditorWidget): Promise<void> {
const editor = editorWidget.editor;
const uri = editor.uri.toString();
if (editor.uri.scheme !== 'file') {
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)));
editorWidget.disposed.connect(() => {
Expand All @@ -93,6 +93,10 @@ export class DirtyDiffManager {
model.handleDocumentChanged(editor.document);
}

protected supportsDirtyDiff(editor: TextEditor): boolean {
return editor.uri.scheme === 'file' && editor.shouldDisplayDirtyDiff();
}

protected createNewModel(editor: TextEditor): DirtyDiffModel {
const previousRevision = this.createPreviousFileRevision(editor.uri);
const model = new DirtyDiffModel(editor, this.preferences, previousRevision);
Expand All @@ -101,11 +105,14 @@ export class DirtyDiffManager {
}

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 query = staged ? '' : 'HEAD';
const uri = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
const uri = getOriginalUri(staged);
const gitResource = await this.gitResourceResolver.getResource(uri);
return gitResource.readContents();
},
Expand All @@ -115,7 +122,8 @@ export class DirtyDiffManager {
return this.git.lsFiles(repository, fileUri.toString(), { errorUnmatch: true });
}
return false;
}
},
getOriginalUri
};
}

Expand All @@ -128,7 +136,6 @@ export class DirtyDiffManager {
await model.handleGitStatusUpdate(repository, changes);
}
}

}

export class DirtyDiffModel implements Disposable {
Expand All @@ -137,7 +144,7 @@ export class DirtyDiffModel implements Disposable {

protected enabled = true;
protected staged: boolean;
protected previousContent: ContentLines | undefined;
protected previousContent: DirtyDiffModel.PreviousRevisionContent | undefined;
protected currentContent: ContentLines | undefined;

protected readonly onDirtyDiffUpdateEmitter = new Emitter<DirtyDiffUpdate>();
Expand Down Expand Up @@ -181,7 +188,7 @@ export class DirtyDiffModel implements Disposable {
update(): void {
const editor = this.editor;
if (!this.shouldRender()) {
this.onDirtyDiffUpdateEmitter.fire({ editor, added: [], removed: [], modified: [] });
this.onDirtyDiffUpdateEmitter.fire({ editor, changes: [] });
return;
}
if (this.updateTimeout) {
Expand All @@ -200,7 +207,7 @@ export class DirtyDiffModel implements Disposable {
// a new update task should be scheduled anyway.
return;
}
const dirtyDiffUpdate = <DirtyDiffUpdate>{ editor, ...dirtyDiff };
const dirtyDiffUpdate = <DirtyDiffUpdate>{ editor, previousRevisionUri: previous.uri, ...dirtyDiff };
this.onDirtyDiffUpdateEmitter.fire(dirtyDiffUpdate);
}, 100);
}
Expand Down Expand Up @@ -251,9 +258,13 @@ export class DirtyDiffModel implements Disposable {
return modelUri.startsWith(repoUri) && this.previousRevision.isVersionControlled();
}

protected async getPreviousRevisionContent(): Promise<ContentLines | undefined> {
const contents = await this.previousRevision.getContents(this.staged);
return contents ? ContentLines.fromString(contents) : undefined;
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 {
Expand Down Expand Up @@ -282,16 +293,18 @@ export namespace DirtyDiffModel {
}

export function documentContentLines(document: TextEditorDocument): ContentLines {
return {
length: document.lineCount,
getLineContent: line => document.getLineContent(line + 1),
};
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;
}

}
90 changes: 89 additions & 1 deletion packages/git/src/browser/git-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
TabBarToolbarRegistry
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser';
import { Git, GitFileChange, GitFileStatus } from '../common';
import { Git, GitFileChange, GitFileStatus, GitWatcher, Repository } from '../common';
import { GitRepositoryTracker } from './git-repository-tracker';
import { GitAction, GitQuickOpenService } from './git-quick-open-service';
import { GitSyncService } from './git-sync-service';
Expand All @@ -42,6 +42,8 @@ import { GitErrorHandler } from '../browser/git-error-handler';
import { ScmWidget } from '@theia/scm/lib/browser/scm-widget';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmCommand, ScmResource } from '@theia/scm/lib/browser/scm-provider';
import { LineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
import { DirtyDiffWidget, SCM_CHANGE_TITLE_MENU } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
import { ProgressService } from '@theia/core/lib/common/progress-service';
import { GitPreferences } from './git-preferences';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
Expand Down Expand Up @@ -166,6 +168,18 @@ export namespace GIT_COMMANDS {
label: 'Stage All Changes',
iconClass: codicon('add')
}, 'vscode.git/package/command.stageAll', GIT_CATEGORY_KEY);
export const STAGE_CHANGE = Command.toLocalizedCommand({
id: 'git.stage.change',
category: GIT_CATEGORY,
label: 'Stage Change',
iconClass: codicon('add')
}, 'vscode.git/package/command.stageChange', GIT_CATEGORY_KEY);
export const REVERT_CHANGE = Command.toLocalizedCommand({
id: 'git.revert.change',
category: GIT_CATEGORY,
label: 'Revert Change',
iconClass: codicon('discard')
}, 'vscode.git/package/command.revertChange', GIT_CATEGORY_KEY);
export const UNSTAGE = Command.toLocalizedCommand({
id: 'git.unstage',
category: GIT_CATEGORY,
Expand Down Expand Up @@ -280,6 +294,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T
@inject(GitPreferences) protected readonly gitPreferences: GitPreferences;
@inject(DecorationsService) protected readonly decorationsService: DecorationsService;
@inject(GitDecorationProvider) protected readonly gitDecorationProvider: GitDecorationProvider;
@inject(GitWatcher) protected readonly gitWatcher: GitWatcher;

onStart(): void {
this.updateStatusBar();
Expand Down Expand Up @@ -385,6 +400,15 @@ export class GitContribution implements CommandContribution, MenuContribution, T
commandId: GIT_COMMANDS.DISCARD_ALL.id,
when: 'scmProvider == git && scmResourceGroup == workingTree || scmProvider == git && scmResourceGroup == untrackedChanges',
});

menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
commandId: GIT_COMMANDS.STAGE_CHANGE.id,
when: 'scmProvider == git'
});
menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
commandId: GIT_COMMANDS.REVERT_CHANGE.id,
when: 'scmProvider == git'
});
}

registerCommands(registry: CommandRegistry): void {
Expand Down Expand Up @@ -573,6 +597,14 @@ export class GitContribution implements CommandContribution, MenuContribution, T
isEnabled: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository,
isVisible: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository
});
registry.registerCommand(GIT_COMMANDS.STAGE_CHANGE, {
execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.stageChange(widget)),
isEnabled: widget => widget instanceof DirtyDiffWidget
});
registry.registerCommand(GIT_COMMANDS.REVERT_CHANGE, {
execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.revertChange(widget)),
isEnabled: widget => widget instanceof DirtyDiffWidget
});
}
async amend(): Promise<void> {
{
Expand Down Expand Up @@ -922,6 +954,62 @@ export class GitContribution implements CommandContribution, MenuContribution, T

}

async stageChange(widget: DirtyDiffWidget): Promise<void> {
const scmRepository = this.repositoryProvider.selectedScmRepository;
if (!scmRepository) {
return;
}

const repository = scmRepository.provider.repository;

const path = Repository.relativePath(repository, widget.uri)?.toString();
if (!path) {
return;
}

const { currentChange } = widget;
if (!currentChange) {
return;
}

const dataToStage = await widget.getContentWithSelectedChanges(change => change === currentChange);

try {
const hash = (await this.git.exec(repository, ['hash-object', '--stdin', '-w', '--path', path], { stdin: dataToStage, stdinEncoding: 'utf8' })).stdout.trim();

let mode = (await this.git.exec(repository, ['ls-files', '--format=%(objectmode)', '--', path])).stdout.split('\n').filter(line => !!line.trim())[0];
if (!mode) {
mode = '100644'; // regular non-executable file
}

await this.git.exec(repository, ['update-index', '--add', '--cacheinfo', mode, hash, path]);

// enforce a notification as there would be no status update if the file had been staged already
this.gitWatcher.onGitChanged({ source: repository, status: await this.git.status(repository) });
} catch (error) {
this.gitErrorHandler.handleError(error);
}

widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
}

async revertChange(widget: DirtyDiffWidget): Promise<void> {
const { currentChange } = widget;
if (!currentChange) {
return;
}

const editor = widget.editor.getControl();
editor.pushUndoStop();
editor.executeEdits('Revert Change', [{
range: editor.getModel()!.getFullModelRange(),
text: await widget.getContentWithSelectedChanges(change => change !== currentChange)
}]);
editor.pushUndoStop();

widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
}

/**
* It should be aligned with https://code.visualstudio.com/api/references/theme-color#git-colors
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/git/src/node/git-repository-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,11 @@ export class GitRepositoryWatcher implements Disposable {
} 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;
});
}
Expand Down
Loading

0 comments on commit 746f8ff

Please sign in to comment.