diff --git a/extensions/github-browser/src/changeStore.ts b/extensions/github-browser/src/changeStore.ts index 641bf1524fd..f4bd624b9e7 100644 --- a/extensions/github-browser/src/changeStore.ts +++ b/extensions/github-browser/src/changeStore.ts @@ -47,31 +47,17 @@ function fromSerialized(operations: StoredOperation): Operation { return { ...operations, uri: Uri.parse(operations.uri) }; } -interface CreatedFileChangeStoreEvent { - type: 'created'; +export interface ChangeStoreEvent { + type: 'created' | 'changed' | 'deleted'; rootUri: Uri; uri: Uri; } -interface ChangedFileChangeStoreEvent { - type: 'changed'; - rootUri: Uri; - uri: Uri; -} - -interface DeletedFileChangeStoreEvent { - type: 'deleted'; - rootUri: Uri; - uri: Uri; -} - -type ChangeStoreEvent = CreatedFileChangeStoreEvent | ChangedFileChangeStoreEvent | DeletedFileChangeStoreEvent; - function toChangeStoreEvent(operation: Operation | StoredOperation, rootUri: Uri, uri?: Uri): ChangeStoreEvent { return { type: operation.type, rootUri: rootUri, - uri: uri ?? (typeof operation.uri === 'string' ? Uri.parse(operation.uri) : operation.uri) + uri: uri ?? (typeof operation.uri === 'string' ? Uri.parse(operation.uri) : operation.uri), }; } @@ -82,6 +68,8 @@ export interface IChangeStore { discard(uri: Uri): Promise; discardAll(rootUri: Uri): Promise; + hasChanges(rootUri: Uri): boolean; + getChanges(rootUri: Uri): Operation[]; getContent(uri: Uri): string | undefined; @@ -116,9 +104,15 @@ export class ChangeStore implements IChangeStore, IWritableChangeStore { await this.saveWorkingOperations(rootUri, undefined); + const events: ChangeStoreEvent[] = []; + for (const operation of operations) { await this.discardWorkingContent(operation.uri); - this._onDidChange.fire(toChangeStoreEvent(operation, rootUri)); + events.push(toChangeStoreEvent(operation, rootUri)); + } + + for (const e of events) { + this._onDidChange.fire(e); } } @@ -143,7 +137,7 @@ export class ChangeStore implements IChangeStore, IWritableChangeStore { this._onDidChange.fire({ type: operation.type === 'created' ? 'deleted' : operation.type === 'deleted' ? 'created' : 'changed', rootUri: rootUri, - uri: uri + uri: uri, }); } @@ -152,9 +146,15 @@ export class ChangeStore implements IChangeStore, IWritableChangeStore { await this.saveWorkingOperations(rootUri, undefined); + const events: ChangeStoreEvent[] = []; + for (const operation of operations) { await this.discardWorkingContent(operation.uri); - this._onDidChange.fire(toChangeStoreEvent(operation, rootUri)); + events.push(toChangeStoreEvent(operation, rootUri)); + } + + for (const e of events) { + this._onDidChange.fire(e); } } diff --git a/extensions/github-browser/src/contextStore.ts b/extensions/github-browser/src/contextStore.ts index 20e34917662..80286445dfe 100644 --- a/extensions/github-browser/src/contextStore.ts +++ b/extensions/github-browser/src/contextStore.ts @@ -4,9 +4,13 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { Event, EventEmitter, Memento, Uri } from 'vscode'; +import { Event, EventEmitter, Memento, Uri, workspace } from 'vscode'; -export const contextKeyPrefix = 'github.context|'; +export interface WorkspaceFolderContext { + context: T; + name: string; + folderUri: Uri; +} export class ContextStore { private _onDidChange = new EventEmitter(); @@ -14,23 +18,36 @@ export class ContextStore { return this._onDidChange.event; } - constructor(private readonly memento: Memento, private readonly scheme: string) { } + constructor( + private readonly scheme: string, + private readonly originalScheme: string, + private readonly memento: Memento, + ) { } delete(uri: Uri) { return this.set(uri, undefined); } get(uri: Uri): T | undefined { - return this.memento.get(`${contextKeyPrefix}${uri.toString()}`); + return this.memento.get(`${this.originalScheme}.context|${this.getOriginalResource(uri).toString()}`); } + getForWorkspace(): WorkspaceFolderContext[] { + const folders = workspace.workspaceFolders?.filter(f => f.uri.scheme === this.scheme || f.uri.scheme === this.originalScheme) ?? []; + return folders.map(f => ({ context: this.get(f.uri)!, name: f.name, folderUri: f.uri })).filter(c => c.context !== undefined); + } async set(uri: Uri, context: T | undefined) { - if (uri.scheme !== this.scheme) { - throw new Error(`Invalid context scheme: ${uri.scheme}`); - } - - await this.memento.update(`${contextKeyPrefix}${uri.toString()}`, context); + uri = this.getOriginalResource(uri); + await this.memento.update(`${this.originalScheme}.context|${uri.toString()}`, context); this._onDidChange.fire(uri); } + + getOriginalResource(uri: Uri): Uri { + return uri.with({ scheme: this.originalScheme }); + } + + getWorkspaceResource(uri: Uri): Uri { + return uri.with({ scheme: this.scheme }); + } } diff --git a/extensions/github-browser/src/extension.ts b/extensions/github-browser/src/extension.ts index 8a454c693b1..893daf93c58 100644 --- a/extensions/github-browser/src/extension.ts +++ b/extensions/github-browser/src/extension.ts @@ -10,22 +10,24 @@ import { VirtualFS } from './fs'; import { GitHubApiContext, GitHubApi } from './github/api'; import { GitHubFS } from './github/fs'; import { VirtualSCM } from './scm'; +import { StatusBar } from './statusbar'; const repositoryRegex = /^(?:(?:https:\/\/)?github.com\/)?([^\/]+)\/([^\/]+?)(?:\/|.git|$)/i; -export function activate(context: ExtensionContext) { - const contextStore = new ContextStore(context.workspaceState, GitHubFS.scheme); +export async function activate(context: ExtensionContext) { + const contextStore = new ContextStore('codespace', GitHubFS.scheme, context.workspaceState); const changeStore = new ChangeStore(context.workspaceState); const githubApi = new GitHubApi(contextStore); const gitHubFS = new GitHubFS(githubApi); - const virtualFS = new VirtualFS('codespace', GitHubFS.scheme, contextStore, changeStore, gitHubFS); + const virtualFS = new VirtualFS('codespace', contextStore, changeStore, gitHubFS); context.subscriptions.push( githubApi, gitHubFS, virtualFS, - new VirtualSCM(GitHubFS.scheme, githubApi, changeStore) + new VirtualSCM(GitHubFS.scheme, githubApi, changeStore), + new StatusBar(contextStore, changeStore), ); commands.registerCommand('githubBrowser.openRepository', async () => { @@ -63,6 +65,11 @@ export function isDescendent(folderPath: string, filePath: string) { return folderPath.length === 0 || filePath.startsWith(folderPath.endsWith('/') ? folderPath : `${folderPath}/`); } +const shaRegex = /^[0-9a-f]{40}$/; +export function isSha(ref: string) { + return shaRegex.test(ref); +} + function openWorkspace(uri: Uri, name: string, location: 'currentWindow' | 'newWindow' | 'addToCurrentWorkspace') { if (location === 'addToCurrentWorkspace') { const count = (workspace.workspaceFolders && workspace.workspaceFolders.length) || 0; diff --git a/extensions/github-browser/src/fs.ts b/extensions/github-browser/src/fs.ts index 84dce33b1a8..56af40f21ba 100644 --- a/extensions/github-browser/src/fs.ts +++ b/extensions/github-browser/src/fs.ts @@ -43,26 +43,22 @@ export class VirtualFS implements FileSystemProvider, FileSearchProvider, TextSe constructor( readonly scheme: string, - private readonly originalScheme: string, - contextStore: ContextStore, + private readonly contextStore: ContextStore, private readonly changeStore: IWritableChangeStore, private readonly fs: FileSystemProvider & FileSearchProvider & TextSearchProvider ) { // TODO@eamodio listen for workspace folder changes - for (const folder of workspace.workspaceFolders ?? []) { - const uri = this.getOriginalResource(folder.uri); - + for (const context of contextStore.getForWorkspace()) { // If we have a saved context, but no longer have any changes, reset the context // We only do this on startup/reload to keep things consistent - if (contextStore.get(uri) !== undefined && !changeStore.hasChanges(folder.uri)) { - contextStore.delete(uri); + if (!changeStore.hasChanges(context.folderUri)) { + console.log('Clear context', context.folderUri.toString()); + contextStore.delete(context.folderUri); } } this.disposable = Disposable.from( - workspace.registerFileSystemProvider(scheme, this, { - isCaseSensitive: true, - }), + workspace.registerFileSystemProvider(scheme, this, { isCaseSensitive: true }), workspace.registerFileSearchProvider(scheme, this), workspace.registerTextSearchProvider(scheme, this), changeStore.onDidChange(e => { @@ -86,11 +82,11 @@ export class VirtualFS implements FileSystemProvider, FileSearchProvider, TextSe } private getOriginalResource(uri: Uri): Uri { - return uri.with({ scheme: this.originalScheme }); + return this.contextStore.getOriginalResource(uri); } - private getVirtualResource(uri: Uri): Uri { - return uri.with({ scheme: this.scheme }); + private getWorkspaceResource(uri: Uri): Uri { + return this.contextStore.getWorkspaceResource(uri); } //#region FileSystemProvider @@ -211,7 +207,7 @@ export class VirtualFS implements FileSystemProvider, FileSearchProvider, TextSe return this.fs.provideTextSearchResults( query, { ...options, folder: this.getOriginalResource(options.folder) }, - { report: (result: TextSearchResult) => progress.report({ ...result, uri: this.getVirtualResource(result.uri) }) }, + { report: (result: TextSearchResult) => progress.report({ ...result, uri: this.getWorkspaceResource(result.uri) }) }, token ); } diff --git a/extensions/github-browser/src/github/api.ts b/extensions/github-browser/src/github/api.ts index c90d874bfab..41eedc8fe39 100644 --- a/extensions/github-browser/src/github/api.ts +++ b/extensions/github-browser/src/github/api.ts @@ -6,14 +6,16 @@ import { authentication, AuthenticationSession, Disposable, Event, EventEmitter, Range, Uri } from 'vscode'; import { graphql } from '@octokit/graphql'; import { Octokit } from '@octokit/rest'; -import { fromGitHubUri } from './fs'; import { ContextStore } from '../contextStore'; +import { fromGitHubUri } from './fs'; +import { isSha } from '../extension'; import { Iterables } from '../iterables'; -export const shaRegex = /^[0-9a-f]{40}$/; - export interface GitHubApiContext { - sha: string; + requestRef: string; + + branch: string; + sha: string | undefined; timestamp: number; } @@ -110,19 +112,12 @@ export class GitHubApi implements Disposable { } async commit(rootUri: Uri, message: string, operations: CommitOperation[]): Promise { - let { owner, repo, ref } = fromGitHubUri(rootUri); + const { owner, repo } = fromGitHubUri(rootUri); try { - if (ref === undefined || ref === 'HEAD') { - ref = await this.defaultBranchQuery(rootUri); - if (ref === undefined) { - throw new Error('Cannot commit — invalid ref'); - } - } - const context = await this.getContext(rootUri); if (context.sha === undefined) { - throw new Error('Cannot commit — invalid context'); + throw new Error(`Cannot commit to Uri(${rootUri.toString(true)}); Invalid context sha`); } const hasDeletes = operations.some(op => op.type === 'deleted'); @@ -204,14 +199,14 @@ export class GitHubApi implements Disposable { parents: [context.sha] }); - this.updateContext(rootUri, { sha: resp.data.sha, timestamp: Date.now() }); + this.updateContext(rootUri, { ...context, sha: resp.data.sha, timestamp: Date.now() }); // TODO@eamodio need to send a file change for any open files await github.git.updateRef({ owner: owner, repo: repo, - ref: `heads/${ref}`, + ref: `heads/${context.branch}`, sha: resp.data.sha }); @@ -256,7 +251,7 @@ export class GitHubApi implements Disposable { owner: owner, repo: repo, recursive: '1', - tree_sha: context?.sha ?? ref ?? 'HEAD', + tree_sha: context?.sha ?? ref, }); return Iterables.filterMap(resp.data.tree, p => p.type === 'blob' ? p.path : undefined); } catch (ex) { @@ -283,7 +278,7 @@ export class GitHubApi implements Disposable { }>(query, { owner: owner, repo: repo, - path: `${context.sha ?? ref ?? 'HEAD'}:${path}`, + path: `${context.sha ?? ref}:${path}`, }); return rsp?.repository?.object ?? undefined; } catch (ex) { @@ -295,7 +290,7 @@ export class GitHubApi implements Disposable { const { owner, repo, ref } = fromGitHubUri(uri); try { - if (ref === undefined || ref === 'HEAD') { + if (ref === 'HEAD') { const query = `query latest($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { defaultBranchRef { @@ -322,6 +317,7 @@ export class GitHubApi implements Disposable { oid } } + } }`; const rsp = await this.gqlQuery<{ @@ -345,7 +341,7 @@ export class GitHubApi implements Disposable { const { owner, repo, ref } = fromGitHubUri(uri); // If we have a specific ref, don't try to search, because GitHub search only works against the default branch - if (ref === undefined) { + if (ref !== 'HEAD') { return { matches: [], limitHit: true }; } @@ -436,29 +432,46 @@ export class GitHubApi implements Disposable { private readonly rootUriToContextMap = new Map(); private async getContextCore(rootUri: Uri): Promise { - let context = this.rootUriToContextMap.get(rootUri.toString()); - if (context === undefined) { - const { ref } = fromGitHubUri(rootUri); - if (ref !== undefined && shaRegex.test(ref)) { - context = { sha: ref, timestamp: Date.now() }; - } else { - context = this.context.get(rootUri); - if (context?.sha === undefined) { - const sha = await this.latestCommitQuery(rootUri); - if (sha !== undefined) { - context = { sha: sha, timestamp: Date.now() }; - } else { - context = undefined; - } - } - } + const key = rootUri.toString(); + let context = this.rootUriToContextMap.get(key); - if (context !== undefined) { - this.updateContext(rootUri, context); - } + // Check if we have a cached a context + if (context?.sha !== undefined) { + return context; } - return context ?? { sha: rootUri.authority, timestamp: Date.now() }; + // Check if we have a saved context + context = this.context.get(rootUri); + if (context?.sha !== undefined) { + this.rootUriToContextMap.set(key, context); + + return context; + } + + const { ref } = fromGitHubUri(rootUri); + + // If the requested ref looks like a sha, then use it + if (isSha(ref)) { + context = { requestRef: ref, branch: ref, sha: ref, timestamp: Date.now() }; + } else { + let branch; + if (ref === 'HEAD') { + branch = await this.defaultBranchQuery(rootUri); + if (branch === undefined) { + throw new Error(`Cannot get context for Uri(${rootUri.toString(true)}); unable to get default branch`); + } + } else { + branch = ref; + } + + // Query for the latest sha for the give ref + const sha = await this.latestCommitQuery(rootUri); + context = { requestRef: ref, branch: branch, sha: sha, timestamp: Date.now() }; + } + + this.updateContext(rootUri, context); + + return context; } private updateContext(rootUri: Uri, context: GitHubApiContext) { diff --git a/extensions/github-browser/src/github/fs.ts b/extensions/github-browser/src/github/fs.ts index 4040cdcc7ba..d0af10751c0 100644 --- a/extensions/github-browser/src/github/fs.ts +++ b/extensions/github-browser/src/github/fs.ts @@ -299,7 +299,7 @@ function typenameToFileType(typename: string | undefined | null) { } } -type RepoInfo = { owner: string; repo: string; path: string | undefined; ref?: string }; +type RepoInfo = { owner: string; repo: string; path: string | undefined; ref: string }; export function fromGitHubUri(uri: Uri): RepoInfo { const [, owner, repo, ...rest] = uri.path.split('/'); @@ -311,7 +311,7 @@ export function fromGitHubUri(uri: Uri): RepoInfo { ref = 'HEAD'; } } - return { owner: owner, repo: repo, path: rest.join('/'), ref: ref }; + return { owner: owner, repo: repo, path: rest.join('/'), ref: ref ?? 'HEAD' }; } function getHashCode(s: string): number { diff --git a/extensions/github-browser/src/scm.ts b/extensions/github-browser/src/scm.ts index 02666d3536e..56671b46859 100644 --- a/extensions/github-browser/src/scm.ts +++ b/extensions/github-browser/src/scm.ts @@ -32,17 +32,15 @@ export class VirtualSCM implements Disposable { // TODO@eamodio listen for workspace folder changes for (const folder of workspace.workspaceFolders ?? []) { this.createScmProvider(folder.uri, folder.name); + + for (const operation of changeStore.getChanges(folder.uri)) { + this.update(folder.uri, operation.uri); + } } this.disposable = Disposable.from( changeStore.onDidChange(e => this.update(e.rootUri, e.uri)), ); - - for (const { uri } of workspace.workspaceFolders ?? []) { - for (const operation of changeStore.getChanges(uri)) { - this.update(uri, operation.uri); - } - } } dispose() { diff --git a/extensions/github-browser/src/statusbar.ts b/extensions/github-browser/src/statusbar.ts new file mode 100644 index 00000000000..e5a049a8631 --- /dev/null +++ b/extensions/github-browser/src/statusbar.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import { Disposable, StatusBarAlignment, StatusBarItem, Uri, window, workspace } from 'vscode'; +import { ChangeStoreEvent, IChangeStore } from './changeStore'; +import { GitHubApiContext } from './github/api'; +import { isSha } from './extension'; +import { ContextStore, WorkspaceFolderContext } from './contextStore'; + +export class StatusBar implements Disposable { + private readonly disposable: Disposable; + + private readonly items = new Map(); + + constructor( + private readonly contextStore: ContextStore, + private readonly changeStore: IChangeStore + ) { + this.disposable = Disposable.from( + contextStore.onDidChange(this.onContextsChanged, this), + changeStore.onDidChange(this.onChanged, this) + ); + + for (const context of this.contextStore.getForWorkspace()) { + this.createOrUpdateStatusBarItem(context); + } + } + + dispose() { + this.disposable?.dispose(); + this.items.forEach(i => i.dispose()); + } + + private createOrUpdateStatusBarItem(wc: WorkspaceFolderContext) { + let item = this.items.get(wc.folderUri.toString()); + if (item === undefined) { + item = window.createStatusBarItem({ + id: `githubBrowser.branch:${wc.folderUri.toString()}`, + name: `GitHub Browser: ${wc.name}`, + alignment: StatusBarAlignment.Left, + priority: 1000 + }); + } + + if (isSha(wc.context.branch)) { + item.text = `$(git-commit) ${wc.context.branch.substr(0, 8)}`; + item.tooltip = `${wc.name} \u2022 ${wc.context.branch.substr(0, 8)}`; + } else { + item.text = `$(git-branch) ${wc.context.branch}`; + item.tooltip = `${wc.name} \u2022 ${wc.context.branch}${wc.context.sha ? ` @ ${wc.context.sha?.substr(0, 8)}` : ''}`; + } + + const hasChanges = this.changeStore.hasChanges(wc.folderUri); + if (hasChanges) { + item.text += '*'; + } + + item.show(); + + this.items.set(wc.folderUri.toString(), item); + } + + private onContextsChanged(uri: Uri) { + const folder = workspace.getWorkspaceFolder(this.contextStore.getWorkspaceResource(uri)); + if (folder === undefined) { + return; + } + + const context = this.contextStore.get(uri); + if (context === undefined) { + return; + } + + this.createOrUpdateStatusBarItem({ + context: context, + name: folder.name, + folderUri: folder.uri, + }); + } + + private onChanged(e: ChangeStoreEvent) { + const item = this.items.get(e.rootUri.toString()); + if (item !== undefined) { + const hasChanges = this.changeStore.hasChanges(e.rootUri); + if (hasChanges) { + if (!item.text.endsWith('*')) { + item.text += '*'; + } + } else { + if (item.text.endsWith('*')) { + item.text = item.text.substr(0, item.text.length - 1); + } + } + } + } +}