diff --git a/extensions/git/package.json b/extensions/git/package.json index 1ac07ba7120..17e8de43d38 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -84,6 +84,12 @@ "category": "Git", "enablement": "!operationInProgress" }, + { + "command": "git.reopenClosedRepositories", + "title": "%command.reopenClosedRepositories%", + "category": "Git", + "enablement": "!operationInProgress && git.ClosedRepositoryCount != 0" + }, { "command": "git.close", "title": "%command.close%", @@ -2860,14 +2866,14 @@ { "view": "scm", "contents": "%view.workbench.scm.empty%", - "when": "config.git.enabled && !git.missing && workbenchState == empty && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0", + "when": "config.git.enabled && !git.missing && workbenchState == empty && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0", "enablement": "git.state == initialized", "group": "2_open@1" }, { "view": "scm", "contents": "%view.workbench.scm.emptyWorkspace%", - "when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0", + "when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0", "enablement": "git.state == initialized", "group": "2_open@1" }, @@ -2884,13 +2890,13 @@ { "view": "scm", "contents": "%view.workbench.scm.folder%", - "when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == folder && scmRepositoryCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && remoteName != 'codespaces'", + "when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == folder && scmRepositoryCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0 && remoteName != 'codespaces'", "group": "5_scm@1" }, { "view": "scm", "contents": "%view.workbench.scm.workspace%", - "when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && scmRepositoryCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && remoteName != 'codespaces'", + "when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && scmRepositoryCount == 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0 && remoteName != 'codespaces'", "group": "5_scm@1" }, { @@ -2913,6 +2919,16 @@ "contents": "%view.workbench.scm.unsafeRepositories%", "when": "config.git.enabled && !git.missing && git.state == initialized && git.unsafeRepositoryCount > 1" }, + { + "view": "scm", + "contents": "%view.workbench.scm.closedRepository%", + "when": "config.git.enabled && !git.missing && git.state == initialized && git.closedRepositoryCount == 1" + }, + { + "view": "scm", + "contents": "%view.workbench.scm.closedRepositories%", + "when": "config.git.enabled && !git.missing && git.state == initialized && git.closedRepositoryCount > 1" + }, { "view": "explorer", "contents": "%view.workbench.cloneRepository%", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 2471b5d9286..89bd6b522fa 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -7,6 +7,7 @@ "command.cloneRecursive": "Clone (Recursive)", "command.init": "Initialize Repository", "command.openRepository": "Open Repository", + "command.reopenClosedRepositories": "Reopen Closed Repositories...", "command.close": "Close Repository", "command.refresh": "Refresh", "command.openChange": "Open Changes", @@ -378,6 +379,22 @@ "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" ] }, + "view.workbench.scm.closedRepository": { + "message": "A git repository was found that was previously closed.\n[Reopen Closed Repository](command:git.reopenClosedRepositories)\nTo learn more about how to use git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", + "comment": [ + "{Locked='](command:git.reopenClosedRepositories'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, + "view.workbench.scm.closedRepositories": { + "message": "Git repositories were found that were previously closed.\n[Reopen Closed Repositories](command:git.reopenClosedRepositories)\nTo learn more about how to use git and source control in VS Code [read our docs](https://aka.ms/vscode-scm).", + "comment": [ + "{Locked='](command:git.reopenClosedRepositories'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, "view.workbench.cloneRepository": { "message": "You can clone a repository locally.\n[Clone Repository](command:git.clone 'Clone a repository once the git extension has activated')", "comment": [ diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 441c8e557a8..905e102c7cd 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -880,7 +880,43 @@ export class CommandCenter { path = result[0].fsPath; } - await this.model.openRepository(path); + await this.model.openRepository(path, true); + } + + @command('git.reopenClosedRepositories', { repository: false }) + async reopenClosedRepositories(): Promise { + if (this.model.closedRepositories.length === 0) { + return; + } + + const closedRepositories: string[] = []; + + const title = l10n.t('Reopen Closed Repositories'); + const placeHolder = l10n.t('Pick a repository to reopen'); + + const allRepositoriesLabel = l10n.t('All Repositories'); + const allRepositoriesQuickPickItem: QuickPickItem = { label: allRepositoriesLabel }; + const repositoriesQuickPickItems: QuickPickItem[] = this.model.closedRepositories.sort().map(r => new RepositoryItem(r)); + + const items = this.model.closedRepositories.length === 1 ? [...repositoriesQuickPickItems] : + [...repositoriesQuickPickItems, { label: '', kind: QuickPickItemKind.Separator }, allRepositoriesQuickPickItem]; + + const repositoryItem = await window.showQuickPick(items, { title, placeHolder }); + if (!repositoryItem) { + return; + } + + if (repositoryItem === allRepositoriesQuickPickItem) { + // All Repositories + closedRepositories.push(...this.model.closedRepositories.values()); + } else { + // One Repository + closedRepositories.push((repositoryItem as RepositoryItem).path); + } + + for (const repository of closedRepositories) { + await this.model.openRepository(repository, true); + } } @command('git.close', { repository: true }) diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index 54b86c843dc..7c93979ee69 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -86,7 +86,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, version: info.version, env: environment, }); - const model = new Model(git, askpass, context.globalState, logger, telemetryReporter); + const model = new Model(git, askpass, context.globalState, context.workspaceState, logger, telemetryReporter); disposables.push(model); const onRepository = () => commands.executeCommand('setContext', 'gitOpenRepositoryCount', `${model.repositories.length}`); diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 360f8401507..48f3a5c43e6 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -19,6 +19,7 @@ import { ApiRepository } from './api/api1'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { IPostCommitCommandsProviderRegistry } from './postCommitCommands'; import { IBranchProtectionProviderRegistry } from './branchProtection'; +import { ObservableSet } from './observable'; class RepositoryPick implements QuickPickItem { @memoize get label(): string { @@ -169,6 +170,11 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu return this._parentRepositories; } + private _closedRepositories: ObservableSet; + get closedRepositories(): string[] { + return [...this._closedRepositories.values()]; + } + /** * We maintain a map containing both the path and the canonical path of the * workspace folders. We are doing this as `git.exe` expands the symbolic links @@ -181,7 +187,11 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu private disposables: Disposable[] = []; - constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, private logger: LogOutputChannel, private telemetryReporter: TelemetryReporter) { + constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, private workspaceState: Memento, private logger: LogOutputChannel, private telemetryReporter: TelemetryReporter) { + this._closedRepositories = new ObservableSet(workspaceState.get('closedRepositories', [])); + this._closedRepositories.onDidChange(this.onDidChangeClosedRepositories, this, this.disposables); + this.onDidChangeClosedRepositories(); + workspace.onDidChangeWorkspaceFolders(this.onDidChangeWorkspaceFolders, this, this.disposables); window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors, this, this.disposables); workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables); @@ -369,6 +379,11 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu openRepositoriesToDispose.forEach(r => r.dispose()); } + private onDidChangeClosedRepositories(): void { + this.workspaceState.update('closedRepositories', [...this._closedRepositories.values()]); + commands.executeCommand('setContext', 'git.closedRepositoryCount', this._closedRepositories.size); + } + private async onDidChangeVisibleTextEditors(editors: readonly TextEditor[]): Promise { if (!workspace.isTrusted) { this.logger.trace('[svte] Workspace is not trusted.'); @@ -403,7 +418,7 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu } @sequentialize - async openRepository(repoPath: string): Promise { + async openRepository(repoPath: string, openIfClosed = false): Promise { this.logger.trace(`Opening repository: ${repoPath}`); if (this.getRepositoryExact(repoPath)) { this.logger.trace(`Repository for path ${repoPath} already exists`); @@ -480,12 +495,22 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu return; } + // Handle repositories that were closed by the user + if (!openIfClosed && this._closedRepositories.has(repositoryRoot)) { + this.logger.trace(`Repository for path ${repositoryRoot} is closed`); + return; + } + // Open repository const dotGit = await this.git.getRepositoryDotGit(repositoryRoot); const repository = new Repository(this.git.open(repositoryRoot, dotGit, this.logger), this, this, this, this, this.globalState, this.logger, this.telemetryReporter); this.open(repository); - repository.status(); // do not await this, we want SCM to know about the repo asap + this._closedRepositories.delete(repository.root); + + // Do not await this, we want SCM + // to know about the repo asap + repository.status(); } catch (err) { // noop this.logger.trace(`Opening repository for path='${repoPath}' failed; ex=${err}`); @@ -633,6 +658,8 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu } this.logger.info(`Close repository: ${repository.root}`); + this._closedRepositories.add(openRepository.repository.root.toString()); + openRepository.dispose(); } diff --git a/extensions/git/src/observable.ts b/extensions/git/src/observable.ts new file mode 100644 index 00000000000..b32fbf156dd --- /dev/null +++ b/extensions/git/src/observable.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter } from 'vscode'; + +export class ObservableSet implements Set { + + readonly [Symbol.toStringTag]: string = 'ObservableSet'; + + private _set: Set; + private _onDidChange = new EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + constructor(values?: readonly T[] | null) { + this._set = new Set(values); + } + + get size(): number { + return this._set.size; + } + + add(value: T): this { + this._set.add(value); + this._onDidChange.fire(); + + return this; + } + + clear(): void { + if (this._set.size > 0) { + this._set.clear(); + this._onDidChange.fire(); + } + } + + delete(value: T): boolean { + const result = this._set.delete(value); + if (result) { + this._onDidChange.fire(); + } + + return result; + } + + forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { + this._set.forEach((_value, key) => callbackfn.call(thisArg, key, key, this)); + } + + has(value: T): boolean { + return this._set.has(value); + } + + entries(): IterableIterator<[T, T]> { + return this._set.entries(); + } + + keys(): IterableIterator { + return this._set.keys(); + } + + values(): IterableIterator { + return this._set.keys(); + } + + [Symbol.iterator](): IterableIterator { + return this.keys(); + } +} diff --git a/extensions/github/package.json b/extensions/github/package.json index 64916932d37..7ae92f53e10 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -160,12 +160,12 @@ { "view": "scm", "contents": "%welcome.publishFolder%", - "when": "config.git.enabled && git.state == initialized && workbenchState == folder && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0" + "when": "config.git.enabled && git.state == initialized && workbenchState == folder && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0" }, { "view": "scm", "contents": "%welcome.publishWorkspaceFolder%", - "when": "config.git.enabled && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0" + "when": "config.git.enabled && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && git.parentRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && git.closedRepositoryCount == 0" } ], "markdown.previewStyles": [