Skip to content

Commit f024b80

Browse files
committed
Add Dirty Diff Peek View
Closes #4544.
1 parent c992452 commit f024b80

21 files changed

+1293
-84
lines changed

Diff for: packages/git/src/browser/dirty-diff/dirty-diff-contribution.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { inject, injectable } from '@theia/core/shared/inversify';
1818
import { DirtyDiffDecorator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-decorator';
19+
import { DirtyDiffNavigator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-navigator';
1920
import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser';
2021
import { DirtyDiffManager } from './dirty-diff-manager';
2122

@@ -25,10 +26,13 @@ export class DirtyDiffContribution implements FrontendApplicationContribution {
2526
constructor(
2627
@inject(DirtyDiffManager) protected readonly dirtyDiffManager: DirtyDiffManager,
2728
@inject(DirtyDiffDecorator) protected readonly dirtyDiffDecorator: DirtyDiffDecorator,
29+
@inject(DirtyDiffNavigator) protected readonly dirtyDiffNavigator: DirtyDiffNavigator,
2830
) { }
2931

3032
onStart(app: FrontendApplication): void {
31-
this.dirtyDiffManager.onDirtyDiffUpdate(update => this.dirtyDiffDecorator.applyDecorations(update));
33+
this.dirtyDiffManager.onDirtyDiffUpdate(update => {
34+
this.dirtyDiffDecorator.applyDecorations(update);
35+
this.dirtyDiffNavigator.handleDirtyDiffUpdate(update);
36+
});
3237
}
33-
3438
}

Diff for: packages/git/src/browser/dirty-diff/dirty-diff-manager.ts

+24-14
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,14 @@ export class DirtyDiffManager {
101101
}
102102

103103
protected createPreviousFileRevision(fileUri: URI): DirtyDiffModel.PreviousFileRevision {
104+
const getOriginalUri = (staged: boolean): URI => {
105+
const query = staged ? '' : 'HEAD';
106+
return fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
107+
};
104108
return <DirtyDiffModel.PreviousFileRevision>{
105109
fileUri,
106110
getContents: async (staged: boolean) => {
107-
const query = staged ? '' : 'HEAD';
108-
const uri = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
111+
const uri = getOriginalUri(staged);
109112
const gitResource = await this.gitResourceResolver.getResource(uri);
110113
return gitResource.readContents();
111114
},
@@ -115,7 +118,8 @@ export class DirtyDiffManager {
115118
return this.git.lsFiles(repository, fileUri.toString(), { errorUnmatch: true });
116119
}
117120
return false;
118-
}
121+
},
122+
getOriginalUri
119123
};
120124
}
121125

@@ -128,7 +132,6 @@ export class DirtyDiffManager {
128132
await model.handleGitStatusUpdate(repository, changes);
129133
}
130134
}
131-
132135
}
133136

134137
export class DirtyDiffModel implements Disposable {
@@ -137,7 +140,7 @@ export class DirtyDiffModel implements Disposable {
137140

138141
protected enabled = true;
139142
protected staged: boolean;
140-
protected previousContent: ContentLines | undefined;
143+
protected previousContent: DirtyDiffModel.PreviousRevisionContent | undefined;
141144
protected currentContent: ContentLines | undefined;
142145

143146
protected readonly onDirtyDiffUpdateEmitter = new Emitter<DirtyDiffUpdate>();
@@ -200,7 +203,7 @@ export class DirtyDiffModel implements Disposable {
200203
// a new update task should be scheduled anyway.
201204
return;
202205
}
203-
const dirtyDiffUpdate = <DirtyDiffUpdate>{ editor, ...dirtyDiff };
206+
const dirtyDiffUpdate = <DirtyDiffUpdate>{ editor, previousRevisionUri: previous.uri, ...dirtyDiff };
204207
this.onDirtyDiffUpdateEmitter.fire(dirtyDiffUpdate);
205208
}, 100);
206209
}
@@ -251,9 +254,13 @@ export class DirtyDiffModel implements Disposable {
251254
return modelUri.startsWith(repoUri) && this.previousRevision.isVersionControlled();
252255
}
253256

254-
protected async getPreviousRevisionContent(): Promise<ContentLines | undefined> {
255-
const contents = await this.previousRevision.getContents(this.staged);
256-
return contents ? ContentLines.fromString(contents) : undefined;
257+
protected async getPreviousRevisionContent(): Promise<DirtyDiffModel.PreviousRevisionContent | undefined> {
258+
const { previousRevision, staged } = this;
259+
const contents = await previousRevision.getContents(staged);
260+
if (contents) {
261+
const uri = previousRevision.getOriginalUri?.(staged);
262+
return { ...ContentLines.fromString(contents), uri };
263+
}
257264
}
258265

259266
dispose(): void {
@@ -275,23 +282,26 @@ export namespace DirtyDiffModel {
275282
*/
276283
export function computeDirtyDiff(previous: ContentLines, current: ContentLines): DirtyDiff | undefined {
277284
try {
278-
return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current));
285+
return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current),
286+
{ rangeMappings: true });
279287
} catch {
280288
return undefined;
281289
}
282290
}
283291

284292
export function documentContentLines(document: TextEditorDocument): ContentLines {
285-
return {
286-
length: document.lineCount,
287-
getLineContent: line => document.getLineContent(line + 1),
288-
};
293+
return ContentLines.fromTextEditorDocument(document);
289294
}
290295

291296
export interface PreviousFileRevision {
292297
readonly fileUri: URI;
293298
getContents(staged: boolean): Promise<string>;
294299
isVersionControlled(): Promise<boolean>;
300+
getOriginalUri?(staged: boolean): URI;
301+
}
302+
303+
export interface PreviousRevisionContent extends ContentLines {
304+
readonly uri?: URI;
295305
}
296306

297307
}

Diff for: packages/git/src/browser/git-contribution.ts

+89-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
TabBarToolbarRegistry
3333
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
3434
import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser';
35-
import { Git, GitFileChange, GitFileStatus } from '../common';
35+
import { Git, GitFileChange, GitFileStatus, GitWatcher, Repository } from '../common';
3636
import { GitRepositoryTracker } from './git-repository-tracker';
3737
import { GitAction, GitQuickOpenService } from './git-quick-open-service';
3838
import { GitSyncService } from './git-sync-service';
@@ -42,6 +42,8 @@ import { GitErrorHandler } from '../browser/git-error-handler';
4242
import { ScmWidget } from '@theia/scm/lib/browser/scm-widget';
4343
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
4444
import { ScmCommand, ScmResource } from '@theia/scm/lib/browser/scm-provider';
45+
import { LineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
46+
import { DirtyDiffWidget, SCM_CHANGE_TITLE_MENU } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
4547
import { ProgressService } from '@theia/core/lib/common/progress-service';
4648
import { GitPreferences } from './git-preferences';
4749
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
@@ -166,6 +168,18 @@ export namespace GIT_COMMANDS {
166168
label: 'Stage All Changes',
167169
iconClass: codicon('add')
168170
}, 'vscode.git/package/command.stageAll', GIT_CATEGORY_KEY);
171+
export const STAGE_CHANGE = Command.toLocalizedCommand({
172+
id: 'git.stage.change',
173+
category: GIT_CATEGORY,
174+
label: 'Stage Change',
175+
iconClass: codicon('add')
176+
}, 'vscode.git/package/command.stageChange', GIT_CATEGORY_KEY);
177+
export const REVERT_CHANGE = Command.toLocalizedCommand({
178+
id: 'git.revert.change',
179+
category: GIT_CATEGORY,
180+
label: 'Revert Change',
181+
iconClass: codicon('discard')
182+
}, 'vscode.git/package/command.revertChange', GIT_CATEGORY_KEY);
169183
export const UNSTAGE = Command.toLocalizedCommand({
170184
id: 'git.unstage',
171185
category: GIT_CATEGORY,
@@ -280,6 +294,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T
280294
@inject(GitPreferences) protected readonly gitPreferences: GitPreferences;
281295
@inject(DecorationsService) protected readonly decorationsService: DecorationsService;
282296
@inject(GitDecorationProvider) protected readonly gitDecorationProvider: GitDecorationProvider;
297+
@inject(GitWatcher) protected readonly gitWatcher: GitWatcher;
283298

284299
onStart(): void {
285300
this.updateStatusBar();
@@ -385,6 +400,15 @@ export class GitContribution implements CommandContribution, MenuContribution, T
385400
commandId: GIT_COMMANDS.DISCARD_ALL.id,
386401
when: 'scmProvider == git && scmResourceGroup == workingTree || scmProvider == git && scmResourceGroup == untrackedChanges',
387402
});
403+
404+
menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
405+
commandId: GIT_COMMANDS.STAGE_CHANGE.id,
406+
when: 'scmProvider == git'
407+
});
408+
menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
409+
commandId: GIT_COMMANDS.REVERT_CHANGE.id,
410+
when: 'scmProvider == git'
411+
});
388412
}
389413

390414
registerCommands(registry: CommandRegistry): void {
@@ -573,6 +597,14 @@ export class GitContribution implements CommandContribution, MenuContribution, T
573597
isEnabled: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository,
574598
isVisible: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository
575599
});
600+
registry.registerCommand(GIT_COMMANDS.STAGE_CHANGE, {
601+
execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.stageChange(widget)),
602+
isEnabled: widget => widget instanceof DirtyDiffWidget
603+
});
604+
registry.registerCommand(GIT_COMMANDS.REVERT_CHANGE, {
605+
execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.revertChange(widget)),
606+
isEnabled: widget => widget instanceof DirtyDiffWidget
607+
});
576608
}
577609
async amend(): Promise<void> {
578610
{
@@ -922,6 +954,62 @@ export class GitContribution implements CommandContribution, MenuContribution, T
922954

923955
}
924956

957+
async stageChange(widget: DirtyDiffWidget): Promise<void> {
958+
const scmRepository = this.repositoryProvider.selectedScmRepository;
959+
if (!scmRepository) {
960+
return;
961+
}
962+
963+
const repository = scmRepository.provider.repository;
964+
965+
const path = Repository.relativePath(repository, widget.uri)?.toString();
966+
if (!path) {
967+
return;
968+
}
969+
970+
const { currentChange } = widget;
971+
if (!currentChange) {
972+
return;
973+
}
974+
975+
const dataToStage = await widget.getContentWithSelectedChanges(change => change === currentChange);
976+
977+
try {
978+
const hash = (await this.git.exec(repository, ['hash-object', '--stdin', '-w', '--path', path], { stdin: dataToStage, stdinEncoding: 'utf8' })).stdout.trim();
979+
980+
let mode = (await this.git.exec(repository, ['ls-files', '--format=%(objectmode)', '--', path])).stdout.split('\n').filter(line => !!line.trim())[0];
981+
if (!mode) {
982+
mode = '100644'; // regular non-executable file
983+
}
984+
985+
await this.git.exec(repository, ['update-index', '--add', '--cacheinfo', mode, hash, path]);
986+
987+
// enforce a notification as there would be no status update if the file had been staged already
988+
this.gitWatcher.onGitChanged({ source: repository, status: await this.git.status(repository) });
989+
} catch (error) {
990+
this.gitErrorHandler.handleError(error);
991+
}
992+
993+
widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
994+
}
995+
996+
async revertChange(widget: DirtyDiffWidget): Promise<void> {
997+
const { currentChange } = widget;
998+
if (!currentChange) {
999+
return;
1000+
}
1001+
1002+
const editor = widget.editor.getControl();
1003+
editor.pushUndoStop();
1004+
editor.executeEdits('Revert Change', [{
1005+
range: editor.getModel()!.getFullModelRange(),
1006+
text: await widget.getContentWithSelectedChanges(change => change !== currentChange)
1007+
}]);
1008+
editor.pushUndoStop();
1009+
1010+
widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
1011+
}
1012+
9251013
/**
9261014
* It should be aligned with https://code.visualstudio.com/api/references/theme-color#git-colors
9271015
*/

Diff for: packages/git/src/node/git-repository-watcher.ts

+2
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,11 @@ export class GitRepositoryWatcher implements Disposable {
9696
} else {
9797
const idleTimeout = this.watching ? 5000 : /* super long */ 1000 * 60 * 60 * 24;
9898
await new Promise<void>(resolve => {
99+
this.idle = true;
99100
const id = setTimeout(resolve, idleTimeout);
100101
this.interruptIdle = () => { clearTimeout(id); resolve(); };
101102
}).then(() => {
103+
this.idle = false;
102104
this.interruptIdle = undefined;
103105
});
104106
}

Diff for: packages/monaco/src/browser/style/index.css

-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
.monaco-editor .zone-widget {
2222
position: absolute;
2323
z-index: 10;
24-
background-color: var(--theia-editorWidget-background);
2524
}
2625

2726
.monaco-editor .zone-widget .zone-widget-container {

Diff for: packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts

+39
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import { URI as CodeUri } from '@theia/core/shared/vscode-uri';
2121
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
2222
import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
2323
import { ScmService } from '@theia/scm/lib/browser/scm-service';
24+
import { DirtyDiffWidget } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
25+
import { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
26+
import { IChange } from '@theia/monaco-editor-core/esm/vs/editor/common/diff/smartLinesDiffComputer';
2427
import { TimelineItem } from '@theia/timeline/lib/common/timeline-model';
2528
import { ScmCommandArg, TimelineCommandArg, TreeViewItemReference } from '../../../common';
2629
import { PluginScmProvider, PluginScmResource, PluginScmResourceGroup } from '../scm-main';
@@ -100,6 +103,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter {
100103
['scm/resourceGroup/context', toScmArgs],
101104
['scm/resourceState/context', toScmArgs],
102105
['scm/title', () => [this.toScmArg(this.scmService.selectedRepository)]],
106+
['scm/change/title', (...args) => this.toScmChangeArgs(...args)],
103107
['timeline/item/context', (...args) => this.toTimelineArgs(...args)],
104108
['view/item/context', (...args) => this.toTreeArgs(...args)],
105109
['view/title', noArgs],
@@ -220,6 +224,41 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter {
220224
}
221225
}
222226

227+
protected toScmChangeArgs(...args: any[]): any[] {
228+
const arg = args[0];
229+
if (arg instanceof DirtyDiffWidget) {
230+
const toIChange = (change: ChangeRangeMapping): IChange => {
231+
const convert = (range: LineRange | NormalizedEmptyLineRange): [number, number] => {
232+
let startLineNumber;
233+
let endLineNumber;
234+
if (!LineRange.isEmpty(range)) {
235+
startLineNumber = range.start + 1;
236+
endLineNumber = range.end + 1;
237+
} else {
238+
startLineNumber = range.start === 0 ? 0 : range.end + 1;
239+
endLineNumber = 0;
240+
}
241+
return [startLineNumber, endLineNumber];
242+
};
243+
const { previousRange, currentRange } = change;
244+
const [originalStartLineNumber, originalEndLineNumber] = convert(previousRange);
245+
const [modifiedStartLineNumber, modifiedEndLineNumber] = convert(currentRange);
246+
return {
247+
originalStartLineNumber,
248+
originalEndLineNumber,
249+
modifiedStartLineNumber,
250+
modifiedEndLineNumber
251+
};
252+
};
253+
return [
254+
arg.uri['codeUri'],
255+
arg.changes.map(toIChange),
256+
arg.currentChangeIndex
257+
];
258+
}
259+
return [];
260+
}
261+
223262
protected toTimelineArgs(...args: any[]): any[] {
224263
const timelineArgs: any[] = [];
225264
const arg = args[0];

Diff for: packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts

+3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DebugVariablesWidget } from '@theia/debug/lib/browser/view/debug-variab
2626
import { EditorWidget, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser';
2727
import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution';
2828
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
29+
import { PLUGIN_SCM_CHANGE_TITLE_MENU } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
2930
import { TIMELINE_ITEM_CONTEXT_MENU } from '@theia/timeline/lib/browser/timeline-tree-widget';
3031
import { COMMENT_CONTEXT, COMMENT_THREAD_CONTEXT, COMMENT_TITLE } from '../comments/comment-thread-widget';
3132
import { VIEW_ITEM_CONTEXT_MENU } from '../view/tree-view-widget';
@@ -51,6 +52,7 @@ export const implementedVSCodeContributionPoints = [
5152
'editor/title/run',
5253
'editor/lineNumber/context',
5354
'explorer/context',
55+
'scm/change/title',
5456
'scm/resourceFolder/context',
5557
'scm/resourceGroup/context',
5658
'scm/resourceState/context',
@@ -77,6 +79,7 @@ export const codeToTheiaMappings = new Map<ContributionPoint, MenuPath[]>([
7779
['editor/title/run', [PLUGIN_EDITOR_TITLE_RUN_MENU]],
7880
['editor/lineNumber/context', [EDITOR_LINENUMBER_CONTEXT_MENU]],
7981
['explorer/context', [NAVIGATOR_CONTEXT_MENU]],
82+
['scm/change/title', [PLUGIN_SCM_CHANGE_TITLE_MENU]],
8083
['scm/resourceFolder/context', [ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU]],
8184
['scm/resourceGroup/context', [ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU]],
8285
['scm/resourceState/context', [ScmTreeWidget.RESOURCE_CONTEXT_MENU]],

Diff for: packages/scm/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"@theia/core": "1.43.0",
77
"@theia/editor": "1.43.0",
88
"@theia/filesystem": "1.43.0",
9+
"@theia/monaco": "1.43.0",
10+
"@theia/monaco-editor-core": "1.72.3",
911
"@types/diff": "^3.2.2",
1012
"diff": "^3.4.0",
1113
"p-debounce": "^2.1.0",

0 commit comments

Comments
 (0)