SCM - Add List/Tree view mode to the SCM Sync view (#193696)

* Add actions

* Saving my work

* Moved the actions into the '...' menu

* Automatically expand history item

* Remove unused import statement
This commit is contained in:
Ladislau Szomoru
2023-09-21 20:02:26 +02:00
committed by GitHub
parent 029ba9ec98
commit fdad923393

View File

@@ -14,23 +14,23 @@ import { ThemeIcon } from 'vs/base/common/themables';
import { URI } from 'vs/base/common/uri';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { IOpenEvent, WorkbenchAsyncDataTree } from 'vs/platform/list/browser/listService';
import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from 'vs/platform/list/browser/listService';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { defaultCountBadgeStyles } from 'vs/platform/theme/browser/defaultStyles';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels';
import { API_OPEN_DIFF_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands';
import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane';
import { IViewPaneOptions, ViewAction, ViewPane } from 'vs/workbench/browser/parts/views/viewPane';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import { RepositoryRenderer } from 'vs/workbench/contrib/scm/browser/scmRepositoryRenderer';
import { ActionButtonRenderer } from 'vs/workbench/contrib/scm/browser/scmViewPane';
import { getActionViewItemProvider, isSCMActionButton, isSCMRepository, isSCMRepositoryArray } from 'vs/workbench/contrib/scm/browser/util';
import { ISCMActionButton, ISCMRepository, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent } from 'vs/workbench/contrib/scm/common/scm';
import { ISCMActionButton, ISCMRepository, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, SYNC_VIEW_PANE_ID } from 'vs/workbench/contrib/scm/common/scm';
import { comparePaths } from 'vs/base/common/comparers';
import { ISCMHistoryItem, ISCMHistoryItemChange, ISCMHistoryItemGroup } from 'vs/workbench/contrib/scm/common/history';
import { localize } from 'vs/nls';
@@ -41,8 +41,18 @@ import { basename, dirname } from 'vs/base/common/resources';
import { ILabelService } from 'vs/platform/label/common/label';
import { stripIcons } from 'vs/base/common/iconLabels';
import { FileKind } from 'vs/platform/files/common/files';
import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { Codicon } from 'vs/base/common/codicons';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { Emitter } from 'vs/base/common/event';
import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree';
import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree';
import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel';
import { IResourceNode, ResourceTree } from 'vs/base/common/resourceTree';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
type TreeElement = ISCMRepository[] | ISCMRepository | ISCMActionButton | SCMHistoryItemGroupTreeElement | SCMHistoryItemTreeElement | SCMHistoryItemChangeTreeElement;
type SCMHistoryItemChangeResourceTreeNode = IResourceNode<SCMHistoryItemChangeTreeElement, SCMHistoryItemTreeElement>;
type TreeElement = ISCMRepository[] | ISCMRepository | ISCMActionButton | SCMHistoryItemGroupTreeElement | SCMHistoryItemTreeElement | SCMHistoryItemChangeTreeElement | SCMHistoryItemChangeResourceTreeNode;
function isSCMHistoryItemGroupTreeElement(obj: any): obj is SCMHistoryItemGroupTreeElement {
return (obj as SCMHistoryItemGroupTreeElement).type === 'historyItemGroup';
@@ -86,11 +96,25 @@ function getSCMResourceId(element: TreeElement): string {
const historyItemGroup = historyItem.historyItemGroup;
const provider = historyItemGroup.repository.provider;
return `historyItemChange:${provider.id}/${historyItemGroup.id}/${historyItem.id}/${element.uri.toString()}`;
} else if (ResourceTree.isResourceNode(element)) {
const historyItem = element.context;
const historyItemGroup = historyItem.historyItemGroup;
const provider = historyItemGroup.repository.provider;
return `folder:${provider.id}/${historyItemGroup.id}/${historyItem.id}/$FOLDER/${element.uri.toString()}`;
} else {
throw new Error('Invalid tree element');
}
}
const enum ViewMode {
List = 'list',
Tree = 'tree'
}
const ContextKeys = {
ViewMode: new RawContextKey<ViewMode>('scmSyncViewMode', ViewMode.List),
};
interface SCMHistoryItemGroupTreeElement extends ISCMHistoryItemGroup {
readonly description?: string;
readonly ancestor?: string;
@@ -130,12 +154,26 @@ class ListDelegate implements IListVirtualDelegate<any> {
return HistoryItemRenderer.TEMPLATE_ID;
} else if (isSCMHistoryItemChangeTreeElement(element)) {
return HistoryItemChangeRenderer.TEMPLATE_ID;
} else if (ResourceTree.isResourceNode(element)) {
return HistoryItemChangeRenderer.TEMPLATE_ID;
} else {
throw new Error('Invalid tree element');
}
}
}
class CompressionDelegate implements ITreeCompressionDelegate<TreeElement> {
isIncompressible(element: TreeElement): boolean {
if (ResourceTree.isResourceNode(element)) {
return element.childrenCount === 0 || !element.parent || !element.parent.parent;
}
return true;
}
}
interface HistoryItemGroupTemplate {
readonly label: IconLabel;
readonly count: CountBadge;
@@ -236,12 +274,15 @@ interface HistoryItemChangeTemplate {
readonly disposables: IDisposable;
}
class HistoryItemChangeRenderer implements ITreeRenderer<SCMHistoryItemChangeTreeElement, void, HistoryItemChangeTemplate> {
class HistoryItemChangeRenderer implements ICompressibleTreeRenderer<SCMHistoryItemChangeTreeElement | SCMHistoryItemChangeResourceTreeNode, void, HistoryItemChangeTemplate> {
static readonly TEMPLATE_ID = 'historyItemChange';
get templateId(): string { return HistoryItemChangeRenderer.TEMPLATE_ID; }
constructor(private labels: ResourceLabels) { }
constructor(
private readonly viewMode: () => ViewMode,
private readonly labels: ResourceLabels,
@ILabelService private labelService: ILabelService) { }
renderTemplate(container: HTMLElement): HistoryItemChangeTemplate {
const element = append(container, $('.change'));
@@ -252,11 +293,27 @@ class HistoryItemChangeRenderer implements ITreeRenderer<SCMHistoryItemChangeTre
return { element, name, fileLabel, decorationIcon, disposables: new DisposableStore() };
}
renderElement(node: ITreeNode<SCMHistoryItemChangeTreeElement, void>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void {
renderElement(node: ITreeNode<SCMHistoryItemChangeTreeElement | SCMHistoryItemChangeResourceTreeNode, void>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void {
const historyItemChangeOrFolder = node.element;
const fileKind = ResourceTree.isResourceNode(historyItemChangeOrFolder) && historyItemChangeOrFolder.childrenCount > 0 ? FileKind.FOLDER : FileKind.FILE;
templateData.fileLabel.setFile(node.element.uri, {
fileDecorations: { colors: false, badges: true },
fileKind: FileKind.FILE,
hidePath: false,
fileKind,
hidePath: this.viewMode() === ViewMode.Tree,
});
}
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<SCMHistoryItemChangeTreeElement | SCMHistoryItemChangeResourceTreeNode>, void>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void {
const compressed = node.element as ICompressedTreeNode<SCMHistoryItemChangeResourceTreeNode>;
const folder = compressed.elements[compressed.elements.length - 1];
const label = compressed.elements.map(e => e.name);
templateData.fileLabel.setResource({ resource: folder.uri, name: label }, {
fileDecorations: { colors: false, badges: true },
fileKind: FileKind.FOLDER,
separator: this.labelService.getSeparator(folder.uri.scheme)
});
}
@@ -292,15 +349,7 @@ class SCMSyncViewPaneAccessibilityProvider implements IListAccessibilityProvider
} else if (isSCMHistoryItemTreeElement(element)) {
return `${stripIcons(element.label).trim()}${element.description ? `, ${element.description}` : ''}`;
} else if (isSCMHistoryItemChangeTreeElement(element)) {
const result: string[] = [];
result.push(basename(element.uri));
// TODO - add decoration
// if (element.decorations.tooltip) {
// result.push(element.decorations.tooltip);
// }
const result = [basename(element.uri)];
const path = this.labelService.getUriLabel(dirname(element.uri), { relative: true, noPrefix: true });
if (path) {
@@ -375,7 +424,7 @@ export class SCMSyncViewPane extends ViewPane {
private listLabels!: ResourceLabels;
private treeContainer!: HTMLElement;
private _tree!: WorkbenchAsyncDataTree<TreeElement, TreeElement>;
private _tree!: WorkbenchCompressibleAsyncDataTree<TreeElement, TreeElement>;
private _viewModel!: SCMSyncPaneViewModel;
get viewModel(): SCMSyncPaneViewModel { return this._viewModel; }
@@ -407,24 +456,28 @@ export class SCMSyncViewPane extends ViewPane {
this._register(this.listLabels);
this._tree = this.instantiationService.createInstance(
WorkbenchAsyncDataTree,
WorkbenchCompressibleAsyncDataTree,
'SCM Sync View',
this.treeContainer,
new ListDelegate(),
new CompressionDelegate(),
[
this.instantiationService.createInstance(RepositoryRenderer, getActionViewItemProvider(this.instantiationService)),
this.instantiationService.createInstance(ActionButtonRenderer),
this.instantiationService.createInstance(HistoryItemGroupRenderer),
this.instantiationService.createInstance(HistoryItemRenderer),
this.instantiationService.createInstance(HistoryItemChangeRenderer, this.listLabels),
this.instantiationService.createInstance(HistoryItemChangeRenderer, () => this.viewModel.mode, this.listLabels),
],
this.instantiationService.createInstance(SCMSyncDataSource),
this.instantiationService.createInstance(SCMSyncDataSource, () => this.viewModel.mode),
{
compressionEnabled: true,
horizontalScrolling: false,
autoExpandSingleChildren: true,
collapseByDefault: (e) => !ResourceTree.isResourceNode(e),
accessibilityProvider: this.instantiationService.createInstance(SCMSyncViewPaneAccessibilityProvider),
identityProvider: this.instantiationService.createInstance(SCMSyncViewPaneTreeIdentityProvider),
sorter: this.instantiationService.createInstance(SCMSyncViewPaneTreeSorter),
}) as WorkbenchAsyncDataTree<TreeElement, TreeElement>;
}) as WorkbenchCompressibleAsyncDataTree<TreeElement, TreeElement>;
this._register(this._tree);
this._register(this._tree.onDidOpen(this.onDidOpen, this));
@@ -436,6 +489,7 @@ export class SCMSyncViewPane extends ViewPane {
this.updateIndentStyles(this.themeService.getFileIconTheme());
this._register(this.themeService.onDidFileIconThemeChange(this.updateIndentStyles, this));
this._register(this._viewModel.onDidChangeMode(this.onDidChangeMode, this));
}
protected override layoutBody(height: number, width: number): void {
@@ -443,18 +497,30 @@ export class SCMSyncViewPane extends ViewPane {
this._tree.layout(height, width);
}
private onDidChangeMode(): void {
this.updateIndentStyles(this.themeService.getFileIconTheme());
}
private async onDidOpen(e: IOpenEvent<TreeElement | undefined>): Promise<void> {
if (!e.element) {
return;
} else if (isSCMHistoryItemChangeTreeElement(e.element)) {
}
if (isSCMHistoryItemChangeTreeElement(e.element)) {
if (e.element.originalUri && e.element.modifiedUri) {
await this.commandService.executeCommand(API_OPEN_DIFF_EDITOR_COMMAND_ID, ...toDiffEditorArguments(e.element.uri, e.element.originalUri, e.element.modifiedUri), e);
}
} else if (ResourceTree.isResourceNode(e.element) && e.element.childrenCount === 0) {
if (e.element.element?.originalUri && e.element.element?.modifiedUri) {
await this.commandService.executeCommand(API_OPEN_DIFF_EDITOR_COMMAND_ID, ...toDiffEditorArguments(e.element.element.uri, e.element.element.originalUri, e.element.element.modifiedUri), e);
}
}
}
private updateIndentStyles(theme: any): void {
this.treeContainer.classList.toggle('align-icons-and-twisties', theme.hasFileIcons || (theme.hasFileIcons && !theme.hasFolderIcons));
this.treeContainer.classList.toggle('list-view-mode', this._viewModel.mode === ViewMode.List);
this.treeContainer.classList.toggle('tree-view-mode', this._viewModel.mode === ViewMode.Tree);
this.treeContainer.classList.toggle('align-icons-and-twisties', (this._viewModel.mode === ViewMode.List && theme.hasFileIcons) || (theme.hasFileIcons && !theme.hasFolderIcons));
}
override dispose(): void {
@@ -465,6 +531,26 @@ export class SCMSyncViewPane extends ViewPane {
class SCMSyncPaneViewModel {
private readonly _onDidChangeMode = new Emitter<ViewMode>();
readonly onDidChangeMode = this._onDidChangeMode.event;
private _mode: ViewMode;
get mode(): ViewMode { return this._mode; }
set mode(mode: ViewMode) {
if (this._mode === mode) {
return;
}
this._mode = mode;
this.refresh();
this.modeContextKey.set(mode);
this._onDidChangeMode.fire(mode);
this.storageService.store(`scm.syncViewMode`, mode, StorageScope.WORKSPACE, StorageTarget.USER);
}
private modeContextKey: IContextKey<ViewMode>;
private repositories = new Map<ISCMRepository, IDisposable>();
private historyProviders = new Map<ISCMRepository, IDisposable>();
@@ -473,19 +559,25 @@ class SCMSyncPaneViewModel {
private readonly disposables = new DisposableStore();
constructor(
private readonly tree: WorkbenchAsyncDataTree<TreeElement, TreeElement>,
private readonly tree: WorkbenchCompressibleAsyncDataTree<TreeElement, TreeElement>,
@IContextKeyService contextKeyService: IContextKeyService,
@ISCMViewService scmViewService: ISCMViewService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStorageService private readonly storageService: IStorageService,
) {
configurationService.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables);
this.onDidChangeConfiguration();
configurationService.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this.disposables);
this._onDidChangeConfiguration();
scmViewService.onDidChangeVisibleRepositories(this._onDidChangeVisibleRepositories, this, this.disposables);
this._onDidChangeVisibleRepositories({ added: scmViewService.visibleRepositories, removed: [] });
this._mode = this.getViewMode();
this.modeContextKey = ContextKeys.ViewMode.bindTo(contextKeyService);
this.modeContextKey.set(this._mode);
}
private onDidChangeConfiguration(e?: IConfigurationChangeEvent): void {
private _onDidChangeConfiguration(e?: IConfigurationChangeEvent): void {
if (!e || e.affectsConfiguration('scm.alwaysShowRepositories')) {
this.alwaysShowRepositories = this.configurationService.getValue<boolean>('scm.alwaysShowRepositories');
this.refresh();
@@ -524,6 +616,16 @@ class SCMSyncPaneViewModel {
}
}
private getViewMode(): ViewMode {
let mode = this.configurationService.getValue<'tree' | 'list'>('scm.defaultViewMode') === 'list' ? ViewMode.List : ViewMode.Tree;
const storageMode = this.storageService.get(`scm.syncViewMode`, StorageScope.WORKSPACE) as ViewMode;
if (typeof storageMode === 'string') {
mode = storageMode;
}
return mode;
}
private async refresh(repository?: ISCMRepository): Promise<void> {
if (this.repositories.size === 0) {
return;
@@ -548,6 +650,10 @@ class SCMSyncPaneViewModel {
class SCMSyncDataSource implements IAsyncDataSource<TreeElement, TreeElement> {
constructor(
private readonly viewMode: () => ViewMode,
@IUriIdentityService private uriIdentityService: IUriIdentityService) { }
hasChildren(element: TreeElement): boolean {
if (isSCMRepositoryArray(element)) {
return true;
@@ -561,6 +667,8 @@ class SCMSyncDataSource implements IAsyncDataSource<TreeElement, TreeElement> {
return true;
} else if (isSCMHistoryItemChangeTreeElement(element)) {
return false;
} else if (ResourceTree.isResourceNode(element)) {
return element.childrenCount > 0;
} else {
throw new Error('hasChildren not implemented.');
}
@@ -651,14 +759,35 @@ class SCMSyncDataSource implements IAsyncDataSource<TreeElement, TreeElement> {
// History Item Changes
const changes = await historyProvider.provideHistoryItemChanges(element.id) ?? [];
children.push(...changes.map(change => ({
uri: change.uri,
originalUri: change.originalUri,
modifiedUri: change.modifiedUri,
renameUri: change.renameUri,
historyItem: element,
type: 'historyItemChange'
} as SCMHistoryItemChangeTreeElement)));
if (this.viewMode() === ViewMode.List) {
// List
children.push(...changes.map(change => ({
uri: change.uri,
originalUri: change.originalUri,
modifiedUri: change.modifiedUri,
renameUri: change.renameUri,
historyItem: element,
type: 'historyItemChange'
} as SCMHistoryItemChangeTreeElement)));
} else {
// Tree
const tree = new ResourceTree<SCMHistoryItemChangeTreeElement, SCMHistoryItemTreeElement>(element, repository.provider.rootUri ?? URI.file('/'), this.uriIdentityService.extUri);
for (const change of changes) {
tree.add(change.uri, {
uri: change.uri,
originalUri: change.originalUri,
modifiedUri: change.modifiedUri,
renameUri: change.renameUri,
historyItem: element,
type: 'historyItemChange'
} as SCMHistoryItemChangeTreeElement);
}
children.push(...tree.root.children);
}
} else if (ResourceTree.isResourceNode(element)) {
children.push(...element.children);
} else {
throw new Error('getChildren Method not implemented.');
}
@@ -666,3 +795,50 @@ class SCMSyncDataSource implements IAsyncDataSource<TreeElement, TreeElement> {
return children;
}
}
class SetListViewModeAction extends ViewAction<SCMSyncViewPane> {
constructor() {
super({
id: 'workbench.scm.sync.action.setListViewMode',
title: localize('setListViewMode', "View as List"),
viewId: SYNC_VIEW_PANE_ID,
f1: false,
icon: Codicon.listTree,
toggled: ContextKeys.ViewMode.isEqualTo(ViewMode.List),
menu: {
id: MenuId.ViewTitle,
group: '1_viewmode'
}
});
}
async runInView(_: ServicesAccessor, view: SCMSyncViewPane): Promise<void> {
view.viewModel.mode = ViewMode.List;
}
}
class SetTreeViewModeAction extends ViewAction<SCMSyncViewPane> {
constructor() {
super({
id: 'workbench.scm.sync.action.setTreeViewMode',
title: localize('setTreeViewMode', "View as Tree"),
viewId: SYNC_VIEW_PANE_ID,
f1: false,
icon: Codicon.listFlat,
toggled: ContextKeys.ViewMode.isEqualTo(ViewMode.Tree),
menu: {
id: MenuId.ViewTitle,
group: '1_viewmode'
}
});
}
async runInView(_: ServicesAccessor, view: SCMSyncViewPane): Promise<void> {
view.viewModel.mode = ViewMode.Tree;
}
}
registerAction2(SetListViewModeAction);
registerAction2(SetTreeViewModeAction);