@theia/scm 1.75.0-next.35 → 1.75.0-next.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/browser/scm-history-graph-contribution.d.ts +5 -0
- package/lib/browser/scm-history-graph-contribution.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-contribution.js +54 -0
- package/lib/browser/scm-history-graph-contribution.js.map +1 -1
- package/lib/browser/scm-history-graph-contribution.spec.js +58 -5
- package/lib/browser/scm-history-graph-contribution.spec.js.map +1 -1
- package/lib/browser/scm-history-graph-helpers.d.ts +25 -0
- package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-helpers.js +58 -0
- package/lib/browser/scm-history-graph-helpers.js.map +1 -1
- package/lib/browser/scm-history-graph-helpers.spec.js +58 -0
- package/lib/browser/scm-history-graph-helpers.spec.js.map +1 -1
- package/lib/browser/scm-history-graph-model.d.ts +24 -2
- package/lib/browser/scm-history-graph-model.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-model.js +47 -0
- package/lib/browser/scm-history-graph-model.js.map +1 -1
- package/lib/browser/scm-history-graph-model.spec.js +102 -0
- package/lib/browser/scm-history-graph-model.spec.js.map +1 -1
- package/lib/browser/scm-history-graph-widget.d.ts +13 -1
- package/lib/browser/scm-history-graph-widget.d.ts.map +1 -1
- package/lib/browser/scm-history-graph-widget.js +62 -4
- package/lib/browser/scm-history-graph-widget.js.map +1 -1
- package/lib/browser/scm-history-graph-widget.spec.js +162 -0
- package/lib/browser/scm-history-graph-widget.spec.js.map +1 -1
- package/package.json +6 -6
- package/src/browser/scm-history-graph-contribution.spec.ts +71 -6
- package/src/browser/scm-history-graph-contribution.ts +51 -0
- package/src/browser/scm-history-graph-helpers.spec.ts +77 -2
- package/src/browser/scm-history-graph-helpers.ts +99 -0
- package/src/browser/scm-history-graph-model.spec.ts +131 -0
- package/src/browser/scm-history-graph-model.ts +56 -1
- package/src/browser/scm-history-graph-widget.spec.ts +199 -1
- package/src/browser/scm-history-graph-widget.tsx +96 -5
- package/src/browser/style/scm-history-graph.css +10 -0
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
buildChangeTreeRows, ChangeTreeFileRow, ChangeTreeRow, filterRefsForBadges, getFileName, getRefBadgePresentation, getRefColorIndex
|
|
20
|
+
} from './scm-history-graph-helpers';
|
|
21
|
+
import { ScmHistoryItemChange, ScmHistoryItemRef, ScmHistoryProvider } from './scm-provider';
|
|
20
22
|
|
|
21
23
|
describe('getRefColorIndex', () => {
|
|
22
24
|
|
|
@@ -116,3 +118,76 @@ describe('filterRefsForBadges', () => {
|
|
|
116
118
|
expect(filterRefsForBadges(refs, provider, 'all', ['refs/heads/feature'])).to.deep.equal(refs);
|
|
117
119
|
});
|
|
118
120
|
});
|
|
121
|
+
|
|
122
|
+
describe('buildChangeTreeRows', () => {
|
|
123
|
+
|
|
124
|
+
const rootUri = 'file:///repo';
|
|
125
|
+
|
|
126
|
+
function change(path: string): ScmHistoryItemChange {
|
|
127
|
+
return { uri: `${rootUri}/${path}`, modifiedUri: `${rootUri}/${path}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Renders the rows as indented lines, so the expectations read like a tree. */
|
|
131
|
+
function describeRows(rows: readonly ChangeTreeRow[]): string[] {
|
|
132
|
+
return rows.map(row => `${' '.repeat(row.depth)}${row.type === 'folder' ? row.label + '/' : getFileName(row.path)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it('keeps files at the root at depth 0', () => {
|
|
136
|
+
const rows = buildChangeTreeRows([change('a.ts'), change('b.ts')], rootUri, new Set());
|
|
137
|
+
expect(describeRows(rows)).to.deep.equal(['a.ts', 'b.ts']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('groups files under their folder', () => {
|
|
141
|
+
const rows = buildChangeTreeRows([change('src/a.ts'), change('src/b.ts')], rootUri, new Set());
|
|
142
|
+
expect(describeRows(rows)).to.deep.equal(['src/', ' a.ts', ' b.ts']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('compacts folder chains that hold a single child folder', () => {
|
|
146
|
+
const rows = buildChangeTreeRows([change('src/browser/a.ts')], rootUri, new Set());
|
|
147
|
+
expect(describeRows(rows)).to.deep.equal(['src/browser/', ' a.ts']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('stops compacting where a folder chain branches', () => {
|
|
151
|
+
const rows = buildChangeTreeRows([change('src/browser/a.ts'), change('src/node/b.ts')], rootUri, new Set());
|
|
152
|
+
expect(describeRows(rows)).to.deep.equal(['src/', ' browser/', ' a.ts', ' node/', ' b.ts']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does not compact a folder that also holds files', () => {
|
|
156
|
+
const rows = buildChangeTreeRows([change('src/a.ts'), change('src/browser/b.ts')], rootUri, new Set());
|
|
157
|
+
expect(describeRows(rows)).to.deep.equal(['src/', ' browser/', ' b.ts', ' a.ts']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('sorts folders before files, each alphabetically', () => {
|
|
161
|
+
const rows = buildChangeTreeRows([change('z.ts'), change('a.ts'), change('src/b.ts')], rootUri, new Set());
|
|
162
|
+
expect(describeRows(rows)).to.deep.equal(['src/', ' b.ts', 'a.ts', 'z.ts']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('hides the descendants of a collapsed folder', () => {
|
|
166
|
+
const rows = buildChangeTreeRows([change('src/a.ts'), change('b.ts')], rootUri, new Set(['src']));
|
|
167
|
+
expect(describeRows(rows)).to.deep.equal(['src/', 'b.ts']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('addresses a compacted folder by its full path', () => {
|
|
171
|
+
const rows = buildChangeTreeRows([change('src/browser/a.ts')], rootUri, new Set(['src/browser']));
|
|
172
|
+
expect(describeRows(rows)).to.deep.equal(['src/browser/']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('carries the original change on each file row', () => {
|
|
176
|
+
const first = change('src/a.ts');
|
|
177
|
+
const rows = buildChangeTreeRows([first], rootUri, new Set());
|
|
178
|
+
expect(rows[1].type).to.equal('file');
|
|
179
|
+
expect((rows[1] as ChangeTreeFileRow).change).to.equal(first);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('keeps the index of the change in the original list, so row keys stay stable', () => {
|
|
183
|
+
const rows = buildChangeTreeRows([change('z.ts'), change('src/a.ts')], rootUri, new Set());
|
|
184
|
+
// Sorted as: src/, src/a.ts (index 1), z.ts (index 0).
|
|
185
|
+
expect(rows.filter(row => row.type === 'file').map(row => (row as ChangeTreeFileRow).index)).to.deep.equal([1, 0]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('falls back to the full path when the change is outside the repository root', () => {
|
|
189
|
+
const outside: ScmHistoryItemChange = { uri: 'file:///elsewhere/x.ts', modifiedUri: 'file:///elsewhere/x.ts' };
|
|
190
|
+
const rows = buildChangeTreeRows([outside], rootUri, new Set());
|
|
191
|
+
expect(describeRows(rows)).to.deep.equal(['elsewhere/', ' x.ts']);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -83,6 +83,105 @@ export function getRepoRelativePath(uri: string, rootUri: string | undefined): s
|
|
|
83
83
|
return fullPath;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export interface ChangeTreeRowBase {
|
|
87
|
+
/** Repo-relative path of the row; the expansion key for folders. */
|
|
88
|
+
readonly path: string;
|
|
89
|
+
/** Nesting level, used for indentation. */
|
|
90
|
+
readonly depth: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ChangeTreeFolderRow extends ChangeTreeRowBase {
|
|
94
|
+
readonly type: 'folder';
|
|
95
|
+
/** Segments rendered for this row; a compacted chain shows `browser/model`. */
|
|
96
|
+
readonly label: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ChangeTreeFileRow extends ChangeTreeRowBase {
|
|
100
|
+
readonly type: 'file';
|
|
101
|
+
readonly change: ScmHistoryItemChange;
|
|
102
|
+
/** Position of the change in the list it came from, so row keys stay stable. */
|
|
103
|
+
readonly index: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ChangeTreeRow = ChangeTreeFolderRow | ChangeTreeFileRow;
|
|
107
|
+
|
|
108
|
+
interface ChangeTreeFolder {
|
|
109
|
+
readonly folders: Map<string, ChangeTreeFolder>;
|
|
110
|
+
readonly files: { change: ScmHistoryItemChange; index: number; name: string }[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Arranges history item changes into the rows of a file tree: folders before
|
|
115
|
+
* files, each sorted by name, with single-child folder chains compacted into
|
|
116
|
+
* one row (`src/browser`) the way VS Code's SCM tree renders them. Folders
|
|
117
|
+
* listed in `collapsedFolders` (by their full path) hide their descendants.
|
|
118
|
+
*/
|
|
119
|
+
export function buildChangeTreeRows(
|
|
120
|
+
changes: readonly ScmHistoryItemChange[],
|
|
121
|
+
rootUri: string | undefined,
|
|
122
|
+
collapsedFolders: ReadonlySet<string>
|
|
123
|
+
): ChangeTreeRow[] {
|
|
124
|
+
const root: ChangeTreeFolder = { folders: new Map(), files: [] };
|
|
125
|
+
changes.forEach((change, index) => {
|
|
126
|
+
const relativePath = getRepoRelativePath(change.modifiedUri ?? change.originalUri ?? change.uri, rootUri);
|
|
127
|
+
const segments = relativePath.split('/').filter(segment => segment.length > 0);
|
|
128
|
+
const name = segments.pop() ?? relativePath;
|
|
129
|
+
let folder = root;
|
|
130
|
+
for (const segment of segments) {
|
|
131
|
+
let child = folder.folders.get(segment);
|
|
132
|
+
if (!child) {
|
|
133
|
+
child = { folders: new Map(), files: [] };
|
|
134
|
+
folder.folders.set(segment, child);
|
|
135
|
+
}
|
|
136
|
+
folder = child;
|
|
137
|
+
}
|
|
138
|
+
folder.files.push({ change, index, name });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const rows: ChangeTreeRow[] = [];
|
|
142
|
+
collectChangeTreeRows(root, '', '', 0, collapsedFolders, rows);
|
|
143
|
+
return rows;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function collectChangeTreeRows(
|
|
147
|
+
folder: ChangeTreeFolder,
|
|
148
|
+
path: string,
|
|
149
|
+
label: string,
|
|
150
|
+
depth: number,
|
|
151
|
+
collapsedFolders: ReadonlySet<string>,
|
|
152
|
+
rows: ChangeTreeRow[]
|
|
153
|
+
): void {
|
|
154
|
+
for (const name of [...folder.folders.keys()].sort(compareNames)) {
|
|
155
|
+
let child = folder.folders.get(name)!;
|
|
156
|
+
let childPath = path ? `${path}/${name}` : name;
|
|
157
|
+
let childLabel = label ? `${label}/${name}` : name;
|
|
158
|
+
// Compact a chain of folders that each hold nothing but one folder.
|
|
159
|
+
while (child.files.length === 0 && child.folders.size === 1) {
|
|
160
|
+
const [onlyName, onlyChild] = [...child.folders][0];
|
|
161
|
+
child = onlyChild;
|
|
162
|
+
childPath = `${childPath}/${onlyName}`;
|
|
163
|
+
childLabel = `${childLabel}/${onlyName}`;
|
|
164
|
+
}
|
|
165
|
+
rows.push({ type: 'folder', path: childPath, label: childLabel, depth });
|
|
166
|
+
if (!collapsedFolders.has(childPath)) {
|
|
167
|
+
collectChangeTreeRows(child, childPath, '', depth + 1, collapsedFolders, rows);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const file of [...folder.files].sort((left, right) => compareNames(left.name, right.name))) {
|
|
171
|
+
rows.push({
|
|
172
|
+
type: 'file',
|
|
173
|
+
path: path ? `${path}/${file.name}` : file.name,
|
|
174
|
+
depth,
|
|
175
|
+
change: file.change,
|
|
176
|
+
index: file.index
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function compareNames(left: string, right: string): number {
|
|
182
|
+
return left.localeCompare(right);
|
|
183
|
+
}
|
|
184
|
+
|
|
86
185
|
/**
|
|
87
186
|
* Returns the ref-based color index of the given ref (0 = current ref,
|
|
88
187
|
* 1 = remote ref, 2 = base ref), or `undefined` when the ref is none of
|
|
@@ -404,3 +404,134 @@ describe('ScmHistoryGraphModel - ref colors and current item', () => {
|
|
|
404
404
|
expect(model.entries.every(e => !e.isCurrent)).to.be.true;
|
|
405
405
|
});
|
|
406
406
|
});
|
|
407
|
+
|
|
408
|
+
describe('ScmHistoryGraphModel - view mode', () => {
|
|
409
|
+
|
|
410
|
+
it('shows the changes of a history item as a list by default', () => {
|
|
411
|
+
const { model } = createModel(new StubHistoryProvider());
|
|
412
|
+
expect(model.viewMode).to.equal('list');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('switches the view mode', () => {
|
|
416
|
+
const { model } = createModel(new StubHistoryProvider());
|
|
417
|
+
model.setViewMode('tree');
|
|
418
|
+
expect(model.viewMode).to.equal('tree');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('notifies listeners when the view mode changes', () => {
|
|
422
|
+
const { model } = createModel(new StubHistoryProvider());
|
|
423
|
+
let changes = 0;
|
|
424
|
+
model.onDidChange(() => changes++);
|
|
425
|
+
|
|
426
|
+
model.setViewMode('tree');
|
|
427
|
+
|
|
428
|
+
expect(changes).to.equal(1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('does not notify listeners when the view mode is unchanged', () => {
|
|
432
|
+
const { model } = createModel(new StubHistoryProvider());
|
|
433
|
+
model.setViewMode('tree');
|
|
434
|
+
let changes = 0;
|
|
435
|
+
model.onDidChange(() => changes++);
|
|
436
|
+
|
|
437
|
+
model.setViewMode('tree');
|
|
438
|
+
|
|
439
|
+
expect(changes).to.equal(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('does not reload history when the view mode changes', async () => {
|
|
443
|
+
const provider = new StubHistoryProvider();
|
|
444
|
+
provider.pages = [makeItems(1, 10)];
|
|
445
|
+
const { model, loadPage } = createModel(provider);
|
|
446
|
+
await loadPage();
|
|
447
|
+
const requestsBefore = provider.receivedOptions.length;
|
|
448
|
+
|
|
449
|
+
model.setViewMode('tree');
|
|
450
|
+
await nextTick();
|
|
451
|
+
|
|
452
|
+
expect(provider.receivedOptions).to.have.length(requestsBefore);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe('ScmHistoryGraphModel - revealing the current history item', () => {
|
|
457
|
+
|
|
458
|
+
function createProviderAt(revision: string): StubHistoryProvider {
|
|
459
|
+
const provider = new StubHistoryProvider();
|
|
460
|
+
provider.currentHistoryItemRef = { id: 'refs/heads/main', name: 'main', revision };
|
|
461
|
+
return provider;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
it('requests a reveal of the index of the current history item', async () => {
|
|
465
|
+
const provider = createProviderAt('c3');
|
|
466
|
+
provider.pages = [makeItems(1, 10)];
|
|
467
|
+
const { model, loadPage } = createModel(provider);
|
|
468
|
+
await loadPage();
|
|
469
|
+
const revealed: number[] = [];
|
|
470
|
+
model.onDidRequestReveal(revealedIndex => revealed.push(revealedIndex));
|
|
471
|
+
|
|
472
|
+
const index = await model.revealCurrentHistoryItem();
|
|
473
|
+
|
|
474
|
+
expect(index).to.equal(2);
|
|
475
|
+
expect(revealed).to.deep.equal([2]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('loads further pages until the current history item is found', async () => {
|
|
479
|
+
const provider = createProviderAt('c55');
|
|
480
|
+
provider.pages = [makeItems(1, PAGE_SIZE), makeItems(PAGE_SIZE + 1, PAGE_SIZE)];
|
|
481
|
+
const { model, loadPage } = createModel(provider);
|
|
482
|
+
await loadPage();
|
|
483
|
+
expect(model.entries).to.have.length(PAGE_SIZE);
|
|
484
|
+
|
|
485
|
+
const index = await model.revealCurrentHistoryItem();
|
|
486
|
+
|
|
487
|
+
expect(index).to.equal(54);
|
|
488
|
+
expect(model.entries).to.have.length(2 * PAGE_SIZE);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('does not request a reveal when the current history item is not in the history', async () => {
|
|
492
|
+
const provider = createProviderAt('not-loaded');
|
|
493
|
+
provider.pages = [makeItems(1, 10)];
|
|
494
|
+
const { model, loadPage } = createModel(provider);
|
|
495
|
+
await loadPage();
|
|
496
|
+
const revealed: number[] = [];
|
|
497
|
+
model.onDidRequestReveal(revealedIndex => revealed.push(revealedIndex));
|
|
498
|
+
|
|
499
|
+
const index = await model.revealCurrentHistoryItem();
|
|
500
|
+
|
|
501
|
+
expect(index).to.be.undefined;
|
|
502
|
+
expect(revealed).to.be.empty;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('does not request a reveal when the provider has no current history item ref', async () => {
|
|
506
|
+
const provider = new StubHistoryProvider();
|
|
507
|
+
provider.pages = [makeItems(1, 10)];
|
|
508
|
+
const { model, loadPage } = createModel(provider);
|
|
509
|
+
await loadPage();
|
|
510
|
+
const revealed: number[] = [];
|
|
511
|
+
model.onDidRequestReveal(revealedIndex => revealed.push(revealedIndex));
|
|
512
|
+
|
|
513
|
+
const index = await model.revealCurrentHistoryItem();
|
|
514
|
+
|
|
515
|
+
expect(index).to.be.undefined;
|
|
516
|
+
expect(revealed).to.be.empty;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('stops paging when further pages add no new items', async () => {
|
|
520
|
+
const provider = createProviderAt('never-returned');
|
|
521
|
+
const page = makeItems(1, PAGE_SIZE);
|
|
522
|
+
// A provider that keeps returning the same full page: `hasMore` stays true
|
|
523
|
+
// forever, so only a no-progress guard can end the search.
|
|
524
|
+
provider.provideHistoryItems = async options => {
|
|
525
|
+
provider.receivedOptions.push(options);
|
|
526
|
+
return page;
|
|
527
|
+
};
|
|
528
|
+
const { model, loadPage } = createModel(provider);
|
|
529
|
+
await loadPage();
|
|
530
|
+
expect(model.hasMore).to.be.true;
|
|
531
|
+
|
|
532
|
+
const index = await model.revealCurrentHistoryItem();
|
|
533
|
+
|
|
534
|
+
expect(index).to.be.undefined;
|
|
535
|
+
expect(model.entries).to.have.length(PAGE_SIZE);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { injectable, inject, postConstruct, named } from '@theia/core/shared/inversify';
|
|
18
18
|
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
|
|
19
|
-
import { Emitter } from '@theia/core/lib/common/event';
|
|
19
|
+
import { Emitter, Event } from '@theia/core/lib/common/event';
|
|
20
20
|
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
|
|
21
21
|
import { ScmService } from './scm-service';
|
|
22
22
|
import { ScmHistoryItem, ScmHistoryItemRef, ScmHistoryProvider, ScmHistoryOptions } from './scm-provider';
|
|
@@ -27,6 +27,9 @@ import { ScmPreferences } from '../common/scm-preferences';
|
|
|
27
27
|
|
|
28
28
|
export const PAGE_SIZE = 50;
|
|
29
29
|
|
|
30
|
+
/** How the changes of an expanded history item are laid out. */
|
|
31
|
+
export type ScmHistoryViewMode = 'list' | 'tree';
|
|
32
|
+
|
|
30
33
|
export const ScmHistoryGraphModelProvider = Symbol('ScmHistoryGraphModelProvider');
|
|
31
34
|
/**
|
|
32
35
|
* Resolves the {@link ScmHistoryGraphModel} singleton on first use. The model
|
|
@@ -63,10 +66,15 @@ export class ScmHistoryGraphModel {
|
|
|
63
66
|
protected _provider: ScmHistoryProvider | undefined;
|
|
64
67
|
/** Explicitly picked ref ids to filter the graph by; `undefined` = auto (current/remote/base). */
|
|
65
68
|
protected _historyItemRefFilter: string[] | undefined;
|
|
69
|
+
protected _viewMode: ScmHistoryViewMode = 'list';
|
|
66
70
|
|
|
67
71
|
protected readonly onDidChangeEmitter = new Emitter<void>();
|
|
68
72
|
readonly onDidChange = this.onDidChangeEmitter.event;
|
|
69
73
|
|
|
74
|
+
protected readonly onDidRequestRevealEmitter = new Emitter<number>();
|
|
75
|
+
/** Fired with the index of an entry the view should scroll to and select. */
|
|
76
|
+
readonly onDidRequestReveal: Event<number> = this.onDidRequestRevealEmitter.event;
|
|
77
|
+
|
|
70
78
|
protected cancelSource = new CancellationTokenSource();
|
|
71
79
|
|
|
72
80
|
@postConstruct()
|
|
@@ -74,6 +82,7 @@ export class ScmHistoryGraphModel {
|
|
|
74
82
|
this.toDispose.pushAll([
|
|
75
83
|
Disposable.create(() => this.toDisposeOnProviderChange.dispose()),
|
|
76
84
|
this.onDidChangeEmitter,
|
|
85
|
+
this.onDidRequestRevealEmitter,
|
|
77
86
|
this.scmService.onDidChangeSelectedRepository(() => this.refresh()),
|
|
78
87
|
this.scmPreferences.onPreferenceChanged(e => {
|
|
79
88
|
if (e.preferenceName === 'scm.graph.pageSize') {
|
|
@@ -98,6 +107,23 @@ export class ScmHistoryGraphModel {
|
|
|
98
107
|
return this._historyItemRefFilter;
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/** How the changes of an expanded history item are laid out. */
|
|
111
|
+
get viewMode(): ScmHistoryViewMode {
|
|
112
|
+
return this._viewMode;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Switches how the changes of an expanded history item are laid out. This
|
|
117
|
+
* only affects rendering, so the loaded history is kept as is.
|
|
118
|
+
*/
|
|
119
|
+
setViewMode(viewMode: ScmHistoryViewMode): void {
|
|
120
|
+
if (this._viewMode === viewMode) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this._viewMode = viewMode;
|
|
124
|
+
this.onDidChangeEmitter.fire();
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
/**
|
|
102
128
|
* Sets the ref ids to filter the graph by (`undefined` returns to auto
|
|
103
129
|
* mode) and reloads the graph.
|
|
@@ -200,6 +226,35 @@ export class ScmHistoryGraphModel {
|
|
|
200
226
|
this.loadPage();
|
|
201
227
|
}
|
|
202
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Locates the commit the current history item ref points at, loading
|
|
231
|
+
* further pages while it is outside the loaded range, and asks the view to
|
|
232
|
+
* reveal it. Returns its index, or `undefined` when there is no current
|
|
233
|
+
* history item ref or the commit is not part of the graph (e.g. the ref is
|
|
234
|
+
* excluded by the filter).
|
|
235
|
+
*/
|
|
236
|
+
async revealCurrentHistoryItem(): Promise<number | undefined> {
|
|
237
|
+
let index = this.indexOfCurrentHistoryItem();
|
|
238
|
+
while (index === undefined && this._hasMore) {
|
|
239
|
+
const loadedBefore = this._entries.length;
|
|
240
|
+
await this.loadMore();
|
|
241
|
+
if (this._entries.length === loadedBefore) {
|
|
242
|
+
// The page added nothing new; paging further cannot find the item.
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
index = this.indexOfCurrentHistoryItem();
|
|
246
|
+
}
|
|
247
|
+
if (index !== undefined) {
|
|
248
|
+
this.onDidRequestRevealEmitter.fire(index);
|
|
249
|
+
}
|
|
250
|
+
return index;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
protected indexOfCurrentHistoryItem(): number | undefined {
|
|
254
|
+
const index = this._entries.findIndex(entry => entry.isCurrent);
|
|
255
|
+
return index === -1 ? undefined : index;
|
|
256
|
+
}
|
|
257
|
+
|
|
203
258
|
async loadMore(): Promise<void> {
|
|
204
259
|
if (this._loading || !this._hasMore) {
|
|
205
260
|
return;
|
|
@@ -18,10 +18,13 @@ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
|
18
18
|
const disableJSDOM = enableJSDOM();
|
|
19
19
|
|
|
20
20
|
import { expect } from 'chai';
|
|
21
|
+
import * as React from '@theia/core/shared/react';
|
|
22
|
+
import { Emitter } from '@theia/core/lib/common/event';
|
|
21
23
|
import { ContextKey, ContextKeyServiceDummyImpl, ContextKeyValue } from '@theia/core/lib/browser/context-key-service';
|
|
22
24
|
import { ScmContextKeyService } from './scm-context-key-service';
|
|
23
25
|
import { ScmHistoryGraphWidget } from './scm-history-graph-widget';
|
|
24
|
-
import {
|
|
26
|
+
import { GraphRow } from './scm-history-graph-lanes';
|
|
27
|
+
import { ScmHistoryItemChange, ScmHistoryProvider } from './scm-provider';
|
|
25
28
|
|
|
26
29
|
disableJSDOM();
|
|
27
30
|
|
|
@@ -110,3 +113,198 @@ describe('ScmHistoryGraphWidget context keys', () => {
|
|
|
110
113
|
expect(scmContextKeys.scmCurrentHistoryItemRefHasRemote.get()).to.equal(true);
|
|
111
114
|
});
|
|
112
115
|
});
|
|
116
|
+
|
|
117
|
+
describe('ScmHistoryGraphWidget reveal', () => {
|
|
118
|
+
let restoreJSDOM: () => void;
|
|
119
|
+
let widget: ScmHistoryGraphWidget;
|
|
120
|
+
let requestReveal: Emitter<number>;
|
|
121
|
+
let scrolledTo: { index: number; align?: string }[];
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
restoreJSDOM = enableJSDOM();
|
|
125
|
+
requestReveal = new Emitter<number>();
|
|
126
|
+
scrolledTo = [];
|
|
127
|
+
|
|
128
|
+
widget = new ScmHistoryGraphWidget();
|
|
129
|
+
const raw = widget as unknown as Record<string, unknown>;
|
|
130
|
+
raw.scmContextKeys = createScmContextKeyService();
|
|
131
|
+
raw.scmPreferences = { onPreferenceChanged: new Emitter<{ preferenceName: string }>().event };
|
|
132
|
+
raw.model = {
|
|
133
|
+
provider: undefined,
|
|
134
|
+
entries: [],
|
|
135
|
+
hasMore: false,
|
|
136
|
+
loading: false,
|
|
137
|
+
hasAttemptedLoad: true,
|
|
138
|
+
isCurrentRefInFilter: () => true,
|
|
139
|
+
onDidChange: new Emitter<void>().event,
|
|
140
|
+
onDidRequestReveal: requestReveal.event
|
|
141
|
+
};
|
|
142
|
+
raw.listRef = { current: { scrollToIndex: (location: { index: number; align?: string }) => scrolledTo.push(location) } };
|
|
143
|
+
// Rendering is out of scope here and would otherwise run asynchronously,
|
|
144
|
+
// after JSDOM has been torn down again.
|
|
145
|
+
raw.update = () => { };
|
|
146
|
+
(widget as unknown as { init(): void }).init();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
widget.dispose();
|
|
151
|
+
restoreJSDOM();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function selectedIndex(): number {
|
|
155
|
+
return (widget as unknown as { selectedIndex: number }).selectedIndex;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
it('should scroll to the entry the model asks to reveal', () => {
|
|
159
|
+
requestReveal.fire(4);
|
|
160
|
+
expect(scrolledTo).to.deep.equal([{ index: 4, align: 'center' }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should select the entry the model asks to reveal', () => {
|
|
164
|
+
requestReveal.fire(4);
|
|
165
|
+
expect(selectedIndex()).to.equal(4);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should stop revealing once disposed', () => {
|
|
169
|
+
widget.dispose();
|
|
170
|
+
requestReveal.fire(4);
|
|
171
|
+
expect(scrolledTo).to.be.empty;
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const ROOT_URI = 'file:///repo';
|
|
176
|
+
|
|
177
|
+
/** Builds the change rows of `commit-1` in the given view mode. */
|
|
178
|
+
function renderChanges(widget: ScmHistoryGraphWidget, viewMode: 'list' | 'tree', paths: string[]): React.ReactElement {
|
|
179
|
+
const raw = widget as unknown as Record<string, unknown>;
|
|
180
|
+
raw.model = { viewMode };
|
|
181
|
+
raw.scmService = { selectedRepository: { provider: { rootUri: ROOT_URI } } };
|
|
182
|
+
raw.labelProvider = { getIcon: () => 'file-icon' };
|
|
183
|
+
const changes: ScmHistoryItemChange[] = paths.map(path => ({
|
|
184
|
+
uri: `${ROOT_URI}/${path}`,
|
|
185
|
+
modifiedUri: `${ROOT_URI}/${path}`,
|
|
186
|
+
originalUri: `${ROOT_URI}/${path}`
|
|
187
|
+
}));
|
|
188
|
+
const graphRow: GraphRow = { lane: 0, color: 0, topColor: 0, edges: [], hasContinuation: false, hasTopLine: false };
|
|
189
|
+
const render = widget as unknown as {
|
|
190
|
+
renderChangesRows(itemId: string, svgWidth: number, graphRow: GraphRow, changes: ScmHistoryItemChange[]): React.ReactElement;
|
|
191
|
+
};
|
|
192
|
+
return render.renderChangesRows('commit-1', 22, graphRow, changes);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function allElements(node: React.ReactNode): React.ReactElement[] {
|
|
196
|
+
const result: React.ReactElement[] = [];
|
|
197
|
+
const visit = (candidate: React.ReactNode): void => {
|
|
198
|
+
if (Array.isArray(candidate)) {
|
|
199
|
+
candidate.forEach(visit);
|
|
200
|
+
} else if (React.isValidElement(candidate)) {
|
|
201
|
+
result.push(candidate);
|
|
202
|
+
visit((candidate.props as { children?: React.ReactNode }).children);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
visit(node);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function elementsWithClass(node: React.ReactNode, className: string): React.ReactElement[] {
|
|
210
|
+
return allElements(node).filter(element => {
|
|
211
|
+
const elementClass = (element.props as { className?: unknown }).className;
|
|
212
|
+
return typeof elementClass === 'string' && elementClass.includes(className);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The rendered name of a row — the `name scm-history-change-name` span it holds. */
|
|
217
|
+
function nameOf(row: React.ReactElement): string {
|
|
218
|
+
const name = allElements(row).find(element => (element.props as { className?: unknown }).className === 'name scm-history-change-name');
|
|
219
|
+
return String((name?.props as { children?: unknown })?.children ?? '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function folderLabels(rows: React.ReactElement): string[] {
|
|
223
|
+
return elementsWithClass(rows, 'scm-history-change-folder-row').map(nameOf);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function fileNames(rows: React.ReactElement): string[] {
|
|
227
|
+
return elementsWithClass(rows, 'scm-history-change-row')
|
|
228
|
+
.filter(row => !((row.props as { className: string }).className.includes('scm-history-change-folder-row')))
|
|
229
|
+
.map(nameOf);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
describe('ScmHistoryGraphWidget change tree folders', () => {
|
|
233
|
+
let restoreJSDOM: () => void;
|
|
234
|
+
let widget: ScmHistoryGraphWidget;
|
|
235
|
+
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
restoreJSDOM = enableJSDOM();
|
|
238
|
+
widget = new ScmHistoryGraphWidget();
|
|
239
|
+
const raw = widget as unknown as Record<string, unknown>;
|
|
240
|
+
raw.update = () => { };
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterEach(() => {
|
|
244
|
+
widget.dispose();
|
|
245
|
+
restoreJSDOM();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function internals(): {
|
|
249
|
+
isFolderCollapsed(itemId: string, path: string): boolean;
|
|
250
|
+
toggleFolder(itemId: string, path: string): void;
|
|
251
|
+
collapsedFolders: Map<string, Set<string>>;
|
|
252
|
+
} {
|
|
253
|
+
return widget as unknown as ReturnType<typeof internals>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('should show folders expanded by default', () => {
|
|
257
|
+
expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.false;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should collapse a folder on toggle', () => {
|
|
261
|
+
internals().toggleFolder('commit-1', 'src');
|
|
262
|
+
expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.true;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should expand a collapsed folder on toggle', () => {
|
|
266
|
+
internals().toggleFolder('commit-1', 'src');
|
|
267
|
+
internals().toggleFolder('commit-1', 'src');
|
|
268
|
+
expect(internals().isFolderCollapsed('commit-1', 'src')).to.be.false;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should keep folder state separate per history item', () => {
|
|
272
|
+
internals().toggleFolder('commit-1', 'src');
|
|
273
|
+
expect(internals().isFolderCollapsed('commit-2', 'src')).to.be.false;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should render a folder row per folder of the change tree', () => {
|
|
277
|
+
const rows = renderChanges(widget, 'tree', ['src/browser/a.ts', 'src/node/b.ts']);
|
|
278
|
+
expect(folderLabels(rows)).to.deep.equal(['src', 'browser', 'node']);
|
|
279
|
+
expect(fileNames(rows)).to.deep.equal(['a.ts', 'b.ts']);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should render expanded folders with a down twistie', () => {
|
|
283
|
+
const rows = renderChanges(widget, 'tree', ['src/a.ts']);
|
|
284
|
+
expect(elementsWithClass(rows, 'codicon-chevron-down')).to.have.length(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should render a flat list of files without folder rows in list mode', () => {
|
|
288
|
+
const rows = renderChanges(widget, 'list', ['src/browser/a.ts', 'src/node/b.ts']);
|
|
289
|
+
expect(folderLabels(rows)).to.be.empty;
|
|
290
|
+
expect(fileNames(rows)).to.deep.equal(['a.ts', 'b.ts']);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should drop the files of a collapsed folder and turn its twistie', () => {
|
|
294
|
+
internals().toggleFolder('commit-1', 'src');
|
|
295
|
+
const rows = renderChanges(widget, 'tree', ['src/browser/a.ts', 'src/node/b.ts']);
|
|
296
|
+
expect(folderLabels(rows)).to.deep.equal(['src']);
|
|
297
|
+
expect(fileNames(rows)).to.be.empty;
|
|
298
|
+
expect(elementsWithClass(rows, 'codicon-chevron-right')).to.have.length(1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should forget folder state when the history item is collapsed again', () => {
|
|
302
|
+
const raw = widget as unknown as { expandedIds: Set<string>; handleRowClick(idx: number, entry: unknown): void };
|
|
303
|
+
raw.expandedIds.add('commit-1');
|
|
304
|
+
internals().toggleFolder('commit-1', 'src');
|
|
305
|
+
|
|
306
|
+
raw.handleRowClick(0, { item: { id: 'commit-1' } });
|
|
307
|
+
|
|
308
|
+
expect(internals().collapsedFolders.has('commit-1')).to.be.false;
|
|
309
|
+
});
|
|
310
|
+
});
|