diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 3183f7d2b00..cb6c66431ea 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -713,6 +713,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi const onDidDisappearRepository = filterEvent(repository.onDidChangeState, state => state === RepositoryState.Disposed); const disappearListener = onDidDisappearRepository(() => dispose()); + const disposeParentListener = repository.sourceControl.onDidDisposeParent(() => dispose()); const changeListener = repository.onDidChangeRepository(uri => this._onDidChangeRepository.fire({ repository, uri })); const originalResourceChangeListener = repository.onDidChangeOriginalResource(uri => this._onDidChangeOriginalResource.fire({ repository, uri })); @@ -809,6 +810,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi const dispose = () => { disappearListener.dispose(); + disposeParentListener.dispose(); changeListener.dispose(); originalResourceChangeListener.dispose(); statusListener.dispose(); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index a5b094f3ba4..076c02c0893 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -891,8 +891,16 @@ export class Repository implements Disposable { this.disposables.push(new FileEventLogger(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange, logger)); + // Parent source control + const parentRoot = repository.kind === 'submodule' + ? repository.dotGit.superProjectPath + : repository.kind === 'worktree' && repository.dotGit.commonPath + ? path.dirname(repository.dotGit.commonPath) + : undefined; + const parent = this.repositoryResolver.getRepository(parentRoot)?.sourceControl; + const root = Uri.file(repository.root); - this._sourceControl = scm.createSourceControl('git', 'Git', root); + this._sourceControl = scm.createSourceControl('git', 'Git', root, parent); this._sourceControl.contextValue = repository.kind; this._sourceControl.quickDiffProvider = this; diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 5a3bf4f413c..03deb6b564c 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -241,9 +241,12 @@ class MainThreadSCMHistoryProvider implements ISCMHistoryProvider { class MainThreadSCMProvider implements ISCMProvider { - private static ID_HANDLE = 0; - private _id = `scm${MainThreadSCMProvider.ID_HANDLE++}`; - get id(): string { return this._id; } + get id(): string { return `scm${this._handle}`; } + get parentId(): string | undefined { + return this._parentHandle !== undefined + ? `scm${this._parentHandle}` + : undefined; + } get providerId(): string { return this._providerId; } readonly groups: MainThreadSCMResourceGroup[] = []; @@ -302,6 +305,7 @@ class MainThreadSCMProvider implements ISCMProvider { constructor( private readonly proxy: ExtHostSCMShape, private readonly _handle: number, + private readonly _parentHandle: number | undefined, private readonly _providerId: string, private readonly _label: string, private readonly _rootUri: URI | undefined, @@ -567,11 +571,11 @@ export class MainThreadSCM implements MainThreadSCMShape { this._disposables.dispose(); } - async $registerSourceControl(handle: number, id: string, label: string, rootUri: UriComponents | undefined, inputBoxDocumentUri: UriComponents): Promise { + async $registerSourceControl(handle: number, parentHandle: number | undefined, id: string, label: string, rootUri: UriComponents | undefined, inputBoxDocumentUri: UriComponents): Promise { this._repositoryBarriers.set(handle, new Barrier()); const inputBoxTextModelRef = await this.textModelService.createModelReference(URI.revive(inputBoxDocumentUri)); - const provider = new MainThreadSCMProvider(this._proxy, handle, id, label, rootUri ? URI.revive(rootUri) : undefined, inputBoxTextModelRef.object.textEditorModel, this.quickDiffService, this._uriIdentService, this.workspaceContextService); + const provider = new MainThreadSCMProvider(this._proxy, handle, parentHandle, id, label, rootUri ? URI.revive(rootUri) : undefined, inputBoxTextModelRef.object.textEditorModel, this.quickDiffService, this._uriIdentService, this.workspaceContextService); const repository = this.scmService.registerSCMProvider(provider); this._repositories.set(handle, repository); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 45b638f65c0..9ec70611979 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1270,8 +1270,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostSCM.getLastInputBox(extension)!; // Strict null override - Deprecated api }, - createSourceControl(id: string, label: string, rootUri?: vscode.Uri) { - return extHostSCM.createSourceControl(extension, id, label, rootUri); + createSourceControl(id: string, label: string, rootUri?: vscode.Uri, parent?: vscode.SourceControl): vscode.SourceControl { + if (parent) { + checkProposedApiEnabled(extension, 'scmProviderOptions'); + } + return extHostSCM.createSourceControl(extension, id, label, rootUri, parent); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ba32b4a00a3..dfb27e196d0 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1648,7 +1648,7 @@ export interface SCMHistoryItemChangeDto { } export interface MainThreadSCMShape extends IDisposable { - $registerSourceControl(handle: number, id: string, label: string, rootUri: UriComponents | undefined, inputBoxDocumentUri: UriComponents): Promise; + $registerSourceControl(handle: number, parentHandle: number | undefined, id: string, label: string, rootUri: UriComponents | undefined, inputBoxDocumentUri: UriComponents): Promise; $updateSourceControl(handle: number, features: SCMProviderFeatures): Promise; $unregisterSourceControl(handle: number): Promise; diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index 46cf8ccfb05..4f72b7975d9 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -28,6 +28,7 @@ import { ExtHostDocuments } from './extHostDocuments.js'; import { Schemas } from '../../../base/common/network.js'; import { isLinux } from '../../../base/common/platform.js'; import { structuralEquals } from '../../../base/common/equals.js'; +import { Iterable } from '../../../base/common/iterator.js'; type ProviderHandle = number; type GroupHandle = number; @@ -542,6 +543,12 @@ class ExtHostSourceControl implements vscode.SourceControl { private static _handlePool: number = 0; + readonly onDidDisposeParent: Event; + + private readonly _onDidDispose = new Emitter(); + readonly onDidDispose = this._onDidDispose.event; + + #proxy: MainThreadSCMShape; private _groups: Map = new Map(); @@ -780,7 +787,8 @@ class ExtHostSourceControl implements vscode.SourceControl { private _commands: ExtHostCommands, private _id: string, private _label: string, - private _rootUri?: vscode.Uri + private _rootUri?: vscode.Uri, + _parent?: ExtHostSourceControl ) { this.#proxy = proxy; @@ -791,7 +799,9 @@ class ExtHostSourceControl implements vscode.SourceControl { }); this._inputBox = new ExtHostSCMInputBox(_extension, _extHostDocuments, this.#proxy, this.handle, inputBoxDocumentUri); - this.#proxy.$registerSourceControl(this.handle, _id, _label, _rootUri, inputBoxDocumentUri); + this.#proxy.$registerSourceControl(this.handle, _parent?.handle, _id, _label, _rootUri, inputBoxDocumentUri); + + this.onDidDisposeParent = _parent ? _parent.onDidDispose : Event.None; } private createdResourceGroups = new Map(); @@ -878,6 +888,9 @@ class ExtHostSourceControl implements vscode.SourceControl { this._groups.forEach(group => group.dispose()); this.#proxy.$unregisterSourceControl(this.handle); + + this._onDidDispose.fire(); + this._onDidDispose.dispose(); } } @@ -941,7 +954,7 @@ export class ExtHostSCM implements ExtHostSCMShape { }); } - createSourceControl(extension: IExtensionDescription, id: string, label: string, rootUri: vscode.Uri | undefined): vscode.SourceControl { + createSourceControl(extension: IExtensionDescription, id: string, label: string, rootUri: vscode.Uri | undefined, parent: vscode.SourceControl | undefined): vscode.SourceControl { this.logService.trace('ExtHostSCM#createSourceControl', extension.identifier.value, id, label, rootUri); type TEvent = { extensionId: string }; @@ -954,7 +967,8 @@ export class ExtHostSCM implements ExtHostSCMShape { extensionId: extension.identifier.value, }); - const sourceControl = new ExtHostSourceControl(extension, this._extHostDocuments, this._proxy, this._commands, id, label, rootUri); + const parentSourceControl = parent ? Iterable.find(this._sourceControls.values(), s => s === parent) : undefined; + const sourceControl = new ExtHostSourceControl(extension, this._extHostDocuments, this._proxy, this._commands, id, label, rootUri, parentSourceControl); this._sourceControls.set(sourceControl.handle, sourceControl); const sourceControls = this._sourceControlsByExtension.get(extension.identifier) || []; diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts index 1ace1d9a905..e210e3ff335 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts @@ -10,7 +10,7 @@ import { append, $ } from '../../../../base/browser/dom.js'; import { IListVirtualDelegate, IIdentityProvider } from '../../../../base/browser/ui/list/list.js'; import { IAsyncDataSource, ITreeEvent, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; import { WorkbenchCompressibleAsyncDataTree } from '../../../../platform/list/browser/listService.js'; -import { ISCMRepository, ISCMService, ISCMViewService } from '../common/scm.js'; +import { ISCMRepository, ISCMViewService } from '../common/scm.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -21,13 +21,13 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IViewDescriptorService } from '../../../common/views.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { RepositoryActionRunner, RepositoryRenderer } from './scmRepositoryRenderer.js'; -import { collectContextMenuActions, getActionViewItemProvider } from './util.js'; +import { collectContextMenuActions, getActionViewItemProvider, isSCMRepository } from './util.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { autorun, IObservable, IObservableSignal, observableSignal, observableSignalFromEvent, runOnChange } from '../../../../base/common/observable.js'; +import { autorun, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { Sequencer } from '../../../../base/common/async.js'; class ListDelegate implements IListVirtualDelegate { @@ -41,16 +41,45 @@ class ListDelegate implements IListVirtualDelegate { } } -class RepositoryTreeDataSource extends Disposable implements IAsyncDataSource { - async getChildren(inputOrElement: SCMRepositoriesViewModel | ISCMRepository): Promise> { - if (inputOrElement instanceof SCMRepositoriesViewModel) { - return inputOrElement.repositories; - } - return []; +class RepositoryTreeDataSource extends Disposable implements IAsyncDataSource { + constructor(@ISCMViewService private readonly scmViewService: ISCMViewService) { + super(); } - hasChildren(inputOrElement: SCMRepositoriesViewModel | ISCMRepository): boolean { - return inputOrElement instanceof SCMRepositoriesViewModel; + getChildren(inputOrElement: ISCMViewService | ISCMRepository): Iterable { + const parentId = isSCMRepository(inputOrElement) + ? inputOrElement.provider.id + : undefined; + + const repositories = this.scmViewService.repositories + .filter(r => r.provider.parentId === parentId); + + return repositories; + } + + getParent(element: ISCMViewService | ISCMRepository): ISCMViewService | ISCMRepository { + if (!isSCMRepository(element)) { + throw new Error('Unexpected call to getParent'); + } + + const repository = this.scmViewService.repositories + .find(r => r.provider.id === element.provider.parentId); + if (!repository) { + throw new Error('Invalid element passed to getParent'); + } + + return repository; + } + + hasChildren(inputOrElement: ISCMViewService | ISCMRepository): boolean { + const parentId = isSCMRepository(inputOrElement) + ? inputOrElement.provider.id + : undefined; + + const repositories = this.scmViewService.repositories + .filter(r => r.provider.parentId === parentId); + + return repositories.length > 0; } } @@ -60,46 +89,9 @@ class RepositoryTreeIdentityProvider implements IIdentityProvider; - readonly onDidChangeRepositoriesSignal: IObservable; - readonly onDidChangeVisibleRepositoriesSignal: IObservable; - - constructor( - @ISCMService private readonly scmService: ISCMService, - @ISCMViewService private readonly scmViewService: ISCMViewService - ) { - super(); - - this.onDidChangeRepositoryContextValueSignal = observableSignal(this); - - this.onDidChangeRepositoriesSignal = observableSignalFromEvent(this, - this.scmViewService.onDidChangeRepositories); - - this.onDidChangeVisibleRepositoriesSignal = observableSignalFromEvent(this, - this.scmViewService.onDidChangeVisibleRepositories); - - this.scmService.onDidAddRepository(this._onDidAddRepository, this, this._store); - for (const repository of this.scmService.repositories) { - this._onDidAddRepository(repository); - } - } - - private _onDidAddRepository(repository: ISCMRepository): void { - this._store.add(runOnChange(repository.provider.contextValue, () => { - this.onDidChangeRepositoryContextValueSignal.trigger(undefined); - })); - } - - get repositories(): ISCMRepository[] { - return this.scmViewService.repositories; - } -} - export class SCMRepositoriesViewPane extends ViewPane { - private tree!: WorkbenchCompressibleAsyncDataTree; - private treeViewModel!: SCMRepositoriesViewModel; + private tree!: WorkbenchCompressibleAsyncDataTree; private treeDataSource!: RepositoryTreeDataSource; private treeIdentityProvider!: RepositoryTreeIdentityProvider; private readonly treeOperationSequencer = new Sequencer(); @@ -111,7 +103,7 @@ export class SCMRepositoriesViewPane extends ViewPane { constructor( options: IViewPaneOptions, - @ISCMViewService protected scmViewService: ISCMViewService, + @ISCMViewService private readonly scmViewService: ISCMViewService, @IKeybindingService keybindingService: IKeybindingService, @IContextMenuService contextMenuService: IContextMenuService, @IInstantiationService instantiationService: IInstantiationService, @@ -148,33 +140,32 @@ export class SCMRepositoriesViewPane extends ViewPane { return; } - this.treeViewModel = this.instantiationService.createInstance(SCMRepositoriesViewModel); - this._register(this.treeViewModel); - this.treeOperationSequencer.queue(async () => { // Initial rendering - await this.tree.setInput(this.treeViewModel); + await this.tree.setInput(this.scmViewService); // scm.repositories.visible setting this.visibilityDisposables.add(autorun(reader => { const visibleCount = this.visibleCountObs.read(reader); - this.updateBodySize(visibleCount); + this.updateBodySize(this.tree.contentHeight, visibleCount); })); - // onDidChangeRepositoriesSignal - // onDidChangeRepositoryContextValueSignal - this.visibilityDisposables.add(autorun(async reader => { - this.treeViewModel.onDidChangeRepositoriesSignal.read(reader); - this.treeViewModel.onDidChangeRepositoryContextValueSignal.read(reader); + // Update tree + const onDidChangeRepositoriesSignal = observableSignalFromEvent( + this, this.scmViewService.onDidChangeRepositories); + this.visibilityDisposables.add(autorun(async reader => { + onDidChangeRepositoriesSignal.read(reader); await this.treeOperationSequencer.queue(() => this.updateChildren()); })); - // onDidChangeVisibleRepositoriesSignal - this.visibilityDisposables.add(autorun(async reader => { - this.treeViewModel.onDidChangeVisibleRepositoriesSignal.read(reader); + // Update tree selection + const onDidChangeVisibleRepositoriesSignal = observableSignalFromEvent( + this, this.scmViewService.onDidChangeVisibleRepositories); - await this.treeOperationSequencer.queue(async () => this.updateTreeSelection()); + this.visibilityDisposables.add(autorun(async reader => { + onDidChangeVisibleRepositoriesSignal.read(reader); + await this.treeOperationSequencer.queue(() => this.updateTreeSelection()); })); }); }, this, this._store); @@ -214,6 +205,8 @@ export class SCMRepositoriesViewPane extends ViewPane { horizontalScrolling: false, compressionEnabled: compressionEnabled.get(), overrideStyles: this.getLocationBasedColors().listOverrideStyles, + expandOnDoubleClick: false, + expandOnlyOnTwistieClick: true, accessibilityProvider: { getAriaLabel(r: ISCMRepository) { return r.provider.label; @@ -223,12 +216,13 @@ export class SCMRepositoriesViewPane extends ViewPane { } } } - ) as WorkbenchCompressibleAsyncDataTree; + ) as WorkbenchCompressibleAsyncDataTree; this._register(this.tree); this._register(this.tree.onDidChangeSelection(this.onTreeSelectionChange, this)); this._register(this.tree.onDidChangeFocus(this.onTreeDidChangeFocus, this)); this._register(this.tree.onContextMenu(this.onTreeContextMenu, this)); + this._register(this.tree.onDidChangeContentHeight(this.onTreeContentHeightChange, this)); } private onTreeContextMenu(e: ITreeContextMenuEvent): void { @@ -271,24 +265,29 @@ export class SCMRepositoriesViewPane extends ViewPane { } } - private async updateChildren(): Promise { - await this.tree.updateChildren(this.treeViewModel); - this.updateBodySize(this.visibleCountObs.get()); + private onTreeContentHeightChange(height: number): void { + this.updateBodySize(height); } - private updateBodySize(visibleCount: number): void { + private async updateChildren(): Promise { + await this.tree.updateChildren(); + this.updateBodySize(this.tree.contentHeight); + } + + private updateBodySize(contentHeight: number, visibleCount?: number): void { if (this.orientation === Orientation.HORIZONTAL) { return; } + visibleCount = visibleCount ?? this.visibleCountObs.get(); const empty = this.scmViewService.repositories.length === 0; - const size = Math.min(this.scmViewService.repositories.length, visibleCount) * 22; + const size = Math.min(contentHeight / 22, visibleCount) * 22; this.minimumBodySize = visibleCount === 0 ? 22 : size; this.maximumBodySize = visibleCount === 0 ? Number.POSITIVE_INFINITY : empty ? Number.POSITIVE_INFINITY : size; } - private updateTreeSelection(): void { + private async updateTreeSelection(): Promise { const oldSelection = this.tree.getSelection(); const oldSet = new Set(oldSelection); @@ -308,6 +307,10 @@ export class SCMRepositoriesViewPane extends ViewPane { } } + // Expand all selected items + for (const item of selection) { + await this.tree.expandTo(item); + } this.tree.setSelection(selection); if (selection.length > 0 && !this.tree.getFocus().includes(selection[0])) { diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoryRenderer.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoryRenderer.ts index 5e52a309795..3719c27eb6d 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoryRenderer.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoryRenderer.ts @@ -128,12 +128,19 @@ export class RepositoryRenderer implements ICompressibleTreeRenderer { - menuPrimaryActions = primary; - menuSecondaryActions = secondary; - updateToolbar(); + templateData.elementDisposables.add(autorun(reader => { + repository.provider.contextValue.read(reader); + + const repositoryMenus = this.scmViewService.menus.getRepositoryMenus(repository.provider); + const menu = this.toolbarMenuId === MenuId.SCMTitle + ? repositoryMenus.titleMenu.menu + : repositoryMenus.getRepositoryMenu(repository); + + reader.store.add(connectPrimaryMenu(menu, (primary, secondary) => { + menuPrimaryActions = primary; + menuSecondaryActions = secondary; + updateToolbar(); + })); })); templateData.toolBar.context = repository.provider; diff --git a/src/vs/workbench/contrib/scm/common/scm.ts b/src/vs/workbench/contrib/scm/common/scm.ts index d41bbf9361b..f51279a6030 100644 --- a/src/vs/workbench/contrib/scm/common/scm.ts +++ b/src/vs/workbench/contrib/scm/common/scm.ts @@ -70,6 +70,7 @@ export interface ISCMResourceGroup { export interface ISCMProvider extends IDisposable { readonly id: string; + readonly parentId?: string; readonly providerId: string; readonly label: string; readonly name: string; diff --git a/src/vscode-dts/vscode.proposed.scmProviderOptions.d.ts b/src/vscode-dts/vscode.proposed.scmProviderOptions.d.ts index 50b1db80478..b14c5ce1419 100644 --- a/src/vscode-dts/vscode.proposed.scmProviderOptions.d.ts +++ b/src/vscode-dts/vscode.proposed.scmProviderOptions.d.ts @@ -26,5 +26,14 @@ declare module 'vscode' { * This will show action `extension.gitAction` only for source controls with `contextValue` equal to `repository`. */ contextValue?: string; + + /** + * Fired when the parent source control is disposed. + */ + readonly onDidDisposeParent: Event; + } + + export namespace scm { + export function createSourceControl(id: string, label: string, rootUri?: Uri, parent?: SourceControl): SourceControl; } }