From 6e64d4e9db916e5132bdb85c6f77c90b4b0d69f3 Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 03:45:21 +0200 Subject: [PATCH 1/9] feat: add the button Go To Commit when diff files (cherry picked from commit 07bc8f50d00f9935751f944a9b9f847e17b43f15) --- package.json | 16 + src/commands.ts | 865 ++--- src/gitGraphView.ts | 33 + src/types.ts | 10 + web/main.ts | 8268 ++++++++++++++++++++++--------------------- 5 files changed, 4670 insertions(+), 4522 deletions(-) diff --git a/package.json b/package.json index e02b5127..814df118 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,13 @@ "command": "git-graph.version", "title": "Get Version Information" }, + { + "category": "Git Graph", + "command": "git-graph.goToCommit", + "title": "Go To Commit", + "icon": "$(git-commit)", + "enablement": "isInDiffEditor" + }, { "category": "Git Graph", "command": "git-graph.openFile", @@ -1518,12 +1525,21 @@ }, "menus": { "commandPalette": [ + { + "command": "git-graph.goToCommit", + "when": "isInDiffEditor" + }, { "command": "git-graph.openFile", "when": "isInDiffEditor && resourceScheme == git-graph && git-graph:codiconsSupported" } ], "editor/title": [ + { + "command": "git-graph.goToCommit", + "group": "navigation@-150", + "when": "isInDiffEditor" + }, { "command": "git-graph.openFile", "group": "navigation", diff --git a/src/commands.ts b/src/commands.ts index a6474a68..3296037e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,405 +1,460 @@ -import * as os from 'os'; -import * as vscode from 'vscode'; -import { AvatarManager } from './avatarManager'; -import { getConfig } from './config'; -import { DataSource } from './dataSource'; -import { DiffDocProvider, decodeDiffDocUri } from './diffDocProvider'; -import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; -import { GitGraphView } from './gitGraphView'; -import { Logger } from './logger'; -import { RepoManager } from './repoManager'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; -import { Disposable } from './utils/disposable'; -import { Event } from './utils/event'; - -/** - * Manages the registration and execution of Git Graph Commands. - */ -export class CommandManager extends Disposable { - private readonly context: vscode.ExtensionContext; - private readonly avatarManager: AvatarManager; - private readonly dataSource: DataSource; - private readonly extensionState: ExtensionState; - private readonly logger: Logger; - private readonly repoManager: RepoManager; - private gitExecutable: GitExecutable | null; - - /** - * Creates the Git Graph Command Manager. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param avatarManger The Git Graph AvatarManager instance. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param repoManager The Git Graph RepoManager instance. - * @param gitExecutable The Git executable available to Git Graph at startup. - * @param onDidChangeGitExecutable The Event emitting the Git executable for Git Graph to use. - * @param logger The Git Graph Logger instance. - */ - constructor(context: vscode.ExtensionContext, avatarManger: AvatarManager, dataSource: DataSource, extensionState: ExtensionState, repoManager: RepoManager, gitExecutable: GitExecutable | null, onDidChangeGitExecutable: Event, logger: Logger) { - super(); - this.context = context; - this.avatarManager = avatarManger; - this.dataSource = dataSource; - this.extensionState = extensionState; - this.logger = logger; - this.repoManager = repoManager; - this.gitExecutable = gitExecutable; - - // Register Extension Commands - this.registerCommand('git-graph.view', (arg) => this.view(arg)); - this.registerCommand('git-graph.addGitRepository', () => this.addGitRepository()); - this.registerCommand('git-graph.removeGitRepository', () => this.removeGitRepository()); - this.registerCommand('git-graph.clearAvatarCache', () => this.clearAvatarCache()); - this.registerCommand('git-graph.fetch', () => this.fetch()); - this.registerCommand('git-graph.endAllWorkspaceCodeReviews', () => this.endAllWorkspaceCodeReviews()); - this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); - this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); - this.registerCommand('git-graph.version', () => this.version()); - this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); - - this.registerDisposable( - onDidChangeGitExecutable((gitExecutable) => { - this.gitExecutable = gitExecutable; - }) - ); - - // Register Extension Contexts - try { - this.registerContext('git-graph:codiconsSupported', doesVersionMeetRequirement(vscode.version, VsCodeVersionRequirement.Codicons)); - } catch (_) { - this.logger.logError('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); - } - } - - /** - * Register a Git Graph command with Visual Studio Code. - * @param command A unique identifier for the command. - * @param callback A command handler function. - */ - private registerCommand(command: string, callback: (...args: any[]) => any) { - this.registerDisposable( - vscode.commands.registerCommand(command, (...args: any[]) => { - this.logger.log('Command Invoked: ' + command); - callback(...args); - }) - ); - } - - /** - * Register a context with Visual Studio Code. - * @param key The Context Key. - * @param value The Context Value. - */ - private registerContext(key: string, value: any) { - return vscode.commands.executeCommand('setContext', key, value).then( - () => this.logger.log('Successfully set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"'), - () => this.logger.logError('Failed to set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"') - ); - } - - - /* Commands */ - - /** - * The method run when the `git-graph.view` command is invoked. - * @param arg An optional argument passed to the command (when invoked from the Visual Studio Code Git Extension). - */ - private async view(arg: any) { - let loadRepo: string | null = null; - - if (typeof arg === 'object' && arg.rootUri) { - // If command is run from the Visual Studio Code Source Control View, load the specific repo - const repoPath = getPathFromUri(arg.rootUri); - loadRepo = await this.repoManager.getKnownRepo(repoPath); - if (loadRepo === null) { - // The repo is not currently known, add it - loadRepo = (await this.repoManager.registerRepo(await resolveToSymbolicPath(repoPath), true)).root; - } - } else if (getConfig().openToTheRepoOfTheActiveTextEditorDocument && vscode.window.activeTextEditor) { - // If the config setting is enabled, load the repo containing the active text editor document - loadRepo = this.repoManager.getRepoContainingFile(getPathFromUri(vscode.window.activeTextEditor.document.uri)); - } - - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, loadRepo !== null ? { repo: loadRepo } : null); - } - - /** - * The method run when the `git-graph.addGitRepository` command is invoked. - */ - private addGitRepository() { - if (this.gitExecutable === null) { - showErrorMessage(UNABLE_TO_FIND_GIT_MSG); - return; - } - - vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false }).then(uris => { - if (uris && uris.length > 0) { - let path = getPathFromUri(uris[0]); - if (isPathInWorkspace(path)) { - this.repoManager.registerRepo(path, false).then(status => { - if (status.error === null) { - showInformationMessage('The repository "' + status.root! + '" was added to Git Graph.'); - } else { - showErrorMessage(status.error + ' Therefore it could not be added to Git Graph.'); - } - }); - } else { - showErrorMessage('The folder "' + path + '" is not within the opened Visual Studio Code workspace, and therefore could not be added to Git Graph.'); - } - } - }, () => { }); - } - - /** - * The method run when the `git-graph.removeGitRepository` command is invoked. - */ - private removeGitRepository() { - if (this.gitExecutable === null) { - showErrorMessage(UNABLE_TO_FIND_GIT_MSG); - return; - } - - const repos = this.repoManager.getRepos(); - const items: vscode.QuickPickItem[] = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder).map((path) => ({ - label: repos[path].name || getRepoName(path), - description: path - })); - - vscode.window.showQuickPick(items, { - placeHolder: 'Select a repository to remove from Git Graph:', - canPickMany: false - }).then((item) => { - if (item && item.description !== undefined) { - if (this.repoManager.ignoreRepo(item.description)) { - showInformationMessage('The repository "' + item.label + '" was removed from Git Graph.'); - } else { - showErrorMessage('The repository "' + item.label + '" is not known to Git Graph.'); - } - } - }, () => { }); - } - - /** - * The method run when the `git-graph.clearAvatarCache` command is invoked. - */ - private clearAvatarCache() { - this.avatarManager.clearCache().then((errorInfo) => { - if (errorInfo === null) { - showInformationMessage('The Avatar Cache was successfully cleared.'); - } else { - showErrorMessage(errorInfo); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Clear Avatar Cache".'); - }); - } - - /** - * The method run when the `git-graph.fetch` command is invoked. - */ - private fetch() { - const repos = this.repoManager.getRepos(); - const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); - - if (repoPaths.length > 1) { - const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ - label: repos[path].name || getRepoName(path), - description: path - })); - - const lastActiveRepo = this.extensionState.getLastActiveRepo(); - if (lastActiveRepo !== null) { - let lastActiveRepoIndex = items.findIndex((item) => item.description === lastActiveRepo); - if (lastActiveRepoIndex > -1) { - const item = items.splice(lastActiveRepoIndex, 1)[0]; - items.unshift(item); - } - } - - vscode.window.showQuickPick(items, { - placeHolder: 'Select the repository you want to open in Git Graph, and fetch from remote(s):', - canPickMany: false - }).then((item) => { - if (item && item.description) { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: item.description, - runCommandOnLoad: 'fetch' - }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Fetch from Remote(s)".'); - }); - } else if (repoPaths.length === 1) { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: repoPaths[0], - runCommandOnLoad: 'fetch' - }); - } else { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, null); - } - } - - /** - * The method run when the `git-graph.endAllWorkspaceCodeReviews` command is invoked. - */ - private endAllWorkspaceCodeReviews() { - this.extensionState.endAllWorkspaceCodeReviews(); - showInformationMessage('Ended All Code Reviews in Workspace'); - } - - /** - * The method run when the `git-graph.endSpecificWorkspaceCodeReview` command is invoked. - */ - private endSpecificWorkspaceCodeReview() { - const codeReviews = this.extensionState.getCodeReviews(); - if (Object.keys(codeReviews).length === 0) { - showErrorMessage('There are no Code Reviews in progress within the current workspace.'); - return; - } - - vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { - placeHolder: 'Select the Code Review you want to end:', - canPickMany: false - }).then((item) => { - if (item) { - this.extensionState.endCodeReview(item.codeReviewRepo, item.codeReviewId).then((errorInfo) => { - if (errorInfo === null) { - showInformationMessage('Successfully ended Code Review "' + item.label + '".'); - } else { - showErrorMessage(errorInfo); - } - }, () => { }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "End a specific Code Review in Workspace...".'); - }); - } - - /** - * The method run when the `git-graph.resumeWorkspaceCodeReview` command is invoked. - */ - private resumeWorkspaceCodeReview() { - const codeReviews = this.extensionState.getCodeReviews(); - if (Object.keys(codeReviews).length === 0) { - showErrorMessage('There are no Code Reviews in progress within the current workspace.'); - return; - } - - vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { - placeHolder: 'Select the Code Review you want to resume:', - canPickMany: false - }).then((item) => { - if (item) { - const commitHashes = item.codeReviewId.split('-'); - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: item.codeReviewRepo, - commitDetails: { - commitHash: commitHashes[commitHashes.length > 1 ? 1 : 0], - compareWithHash: commitHashes.length > 1 ? commitHashes[0] : null - } - }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Resume a specific Code Review in Workspace...".'); - }); - } - - /** - * The method run when the `git-graph.version` command is invoked. - */ - private async version() { - try { - const gitGraphVersion = await getExtensionVersion(this.context); - const information = 'Git Graph: ' + gitGraphVersion + '\nVisual Studio Code: ' + vscode.version + '\nOS: ' + os.type() + ' ' + os.arch() + ' ' + os.release() + '\nGit: ' + (this.gitExecutable !== null ? this.gitExecutable.version : '(none)'); - vscode.window.showInformationMessage(information, { modal: true }, 'Copy').then((selectedItem) => { - if (selectedItem === 'Copy') { - copyToClipboard(information).then((result) => { - if (result !== null) { - showErrorMessage(result); - } - }); - } - }, () => { }); - } catch (_) { - showErrorMessage('An unexpected error occurred while retrieving version information.'); - } - } - - /** - * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). - * The method run when the `git-graph.openFile` command is invoked. - * @param arg The Git Graph URI. - */ - private openFile(arg?: vscode.Uri) { - const uri = arg || vscode.window.activeTextEditor?.document.uri; - if (typeof uri === 'object' && uri && uri.scheme === DiffDocProvider.scheme) { - // A Git Graph URI has been provided - const request = decodeDiffDocUri(uri); - return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { - if (errorInfo !== null) { - return showErrorMessage('Unable to Open File: ' + errorInfo); - } - }); - } else { - return showErrorMessage('Unable to Open File: The command was not called with the required arguments.'); - } - } - - - /* Helper Methods */ - - /** - * Transform a set of Code Reviews into a list of Quick Pick items for use with `vscode.window.showQuickPick`. - * @param codeReviews A set of Code Reviews. - * @returns A list of Quick Pick items. - */ - private getCodeReviewQuickPickItems(codeReviews: CodeReviews): Promise { - const repos = this.repoManager.getRepos(); - const enrichedCodeReviews: { repo: string, id: string, review: CodeReviewData, fromCommitHash: string, toCommitHash: string }[] = []; - const fetchCommits: { repo: string, commitHash: string }[] = []; - - Object.keys(codeReviews).forEach((repo) => { - if (typeof repos[repo] === 'undefined') return; - Object.keys(codeReviews[repo]).forEach((id) => { - const commitHashes = id.split('-'); - commitHashes.forEach((commitHash) => fetchCommits.push({ repo: repo, commitHash: commitHash })); - enrichedCodeReviews.push({ - repo: repo, id: id, review: codeReviews[repo][id], - fromCommitHash: commitHashes[0], toCommitHash: commitHashes[commitHashes.length > 1 ? 1 : 0] - }); - }); - }); - - return Promise.all(fetchCommits.map((fetch) => this.dataSource.getCommitSubject(fetch.repo, fetch.commitHash))).then( - (subjects) => { - const commitSubjects: { [repo: string]: { [commitHash: string]: string } } = {}; - subjects.forEach((subject, i) => { - if (typeof commitSubjects[fetchCommits[i].repo] === 'undefined') { - commitSubjects[fetchCommits[i].repo] = {}; - } - commitSubjects[fetchCommits[i].repo][fetchCommits[i].commitHash] = subject !== null ? subject : ''; - }); - - return enrichedCodeReviews.sort((a, b) => b.review.lastActive - a.review.lastActive).map((codeReview) => { - const fromSubject = commitSubjects[codeReview.repo][codeReview.fromCommitHash]; - const toSubject = commitSubjects[codeReview.repo][codeReview.toCommitHash]; - const isComparison = codeReview.fromCommitHash !== codeReview.toCommitHash; - return { - codeReviewRepo: codeReview.repo, - codeReviewId: codeReview.id, - label: (repos[codeReview.repo].name || getRepoName(codeReview.repo)) + ': ' + abbrevCommit(codeReview.fromCommitHash) + (isComparison ? ' ↔ ' + abbrevCommit(codeReview.toCommitHash) : ''), - description: getRelativeTimeDiff(Math.round(codeReview.review.lastActive / 1000)), - detail: isComparison - ? abbrevText(fromSubject, 50) + ' ↔ ' + abbrevText(toSubject, 50) - : fromSubject - }; - }); - } - ); - } -} - -interface CodeReviewQuickPickItem extends vscode.QuickPickItem { - codeReviewRepo: string; - codeReviewId: string; -} +import * as os from 'os'; +import * as vscode from 'vscode'; +import { AvatarManager } from './avatarManager'; +import { getConfig } from './config'; +import { DataSource } from './dataSource'; +import { DiffDocProvider, decodeDiffDocUri } from './diffDocProvider'; +import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; +import { GitGraphView } from './gitGraphView'; +import { Logger } from './logger'; +import { RepoManager } from './repoManager'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; +import { Disposable } from './utils/disposable'; +import { Event } from './utils/event'; + +/** + * Manages the registration and execution of Git Graph Commands. + */ +export class CommandManager extends Disposable { + private readonly context: vscode.ExtensionContext; + private readonly avatarManager: AvatarManager; + private readonly dataSource: DataSource; + private readonly extensionState: ExtensionState; + private readonly logger: Logger; + private readonly repoManager: RepoManager; + private gitExecutable: GitExecutable | null; + + /** + * Creates the Git Graph Command Manager. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param avatarManger The Git Graph AvatarManager instance. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param repoManager The Git Graph RepoManager instance. + * @param gitExecutable The Git executable available to Git Graph at startup. + * @param onDidChangeGitExecutable The Event emitting the Git executable for Git Graph to use. + * @param logger The Git Graph Logger instance. + */ + constructor(context: vscode.ExtensionContext, avatarManger: AvatarManager, dataSource: DataSource, extensionState: ExtensionState, repoManager: RepoManager, gitExecutable: GitExecutable | null, onDidChangeGitExecutable: Event, logger: Logger) { + super(); + this.context = context; + this.avatarManager = avatarManger; + this.dataSource = dataSource; + this.extensionState = extensionState; + this.logger = logger; + this.repoManager = repoManager; + this.gitExecutable = gitExecutable; + + // Register Extension Commands + this.registerCommand('git-graph.view', (arg) => this.view(arg)); + this.registerCommand('git-graph.addGitRepository', () => this.addGitRepository()); + this.registerCommand('git-graph.removeGitRepository', () => this.removeGitRepository()); + this.registerCommand('git-graph.clearAvatarCache', () => this.clearAvatarCache()); + this.registerCommand('git-graph.fetch', () => this.fetch()); + this.registerCommand('git-graph.endAllWorkspaceCodeReviews', () => this.endAllWorkspaceCodeReviews()); + this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); + this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); + this.registerCommand('git-graph.version', () => this.version()); + this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); + this.registerCommand('git-graph.goToCommit', (arg) => this.goToCommit(arg)); + + this.registerDisposable( + onDidChangeGitExecutable((gitExecutable) => { + this.gitExecutable = gitExecutable; + }) + ); + + // Register Extension Contexts + try { + this.registerContext('git-graph:codiconsSupported', doesVersionMeetRequirement(vscode.version, VsCodeVersionRequirement.Codicons)); + } catch (_) { + this.logger.logError('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); + } + } + + /** + * Register a Git Graph command with Visual Studio Code. + * @param command A unique identifier for the command. + * @param callback A command handler function. + */ + private registerCommand(command: string, callback: (...args: any[]) => any) { + this.registerDisposable( + vscode.commands.registerCommand(command, (...args: any[]) => { + this.logger.log('Command Invoked: ' + command); + callback(...args); + }) + ); + } + + /** + * Register a context with Visual Studio Code. + * @param key The Context Key. + * @param value The Context Value. + */ + private registerContext(key: string, value: any) { + return vscode.commands.executeCommand('setContext', key, value).then( + () => this.logger.log('Successfully set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"'), + () => this.logger.logError('Failed to set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"') + ); + } + + + /* Commands */ + + /** + * The method run when the `git-graph.view` command is invoked. + * @param arg An optional argument passed to the command (when invoked from the Visual Studio Code Git Extension). + */ + private async view(arg: any) { + let loadRepo: string | null = null; + + if (typeof arg === 'object' && arg.rootUri) { + // If command is run from the Visual Studio Code Source Control View, load the specific repo + const repoPath = getPathFromUri(arg.rootUri); + loadRepo = await this.repoManager.getKnownRepo(repoPath); + if (loadRepo === null) { + // The repo is not currently known, add it + loadRepo = (await this.repoManager.registerRepo(await resolveToSymbolicPath(repoPath), true)).root; + } + } else if (getConfig().openToTheRepoOfTheActiveTextEditorDocument && vscode.window.activeTextEditor) { + // If the config setting is enabled, load the repo containing the active text editor document + loadRepo = this.repoManager.getRepoContainingFile(getPathFromUri(vscode.window.activeTextEditor.document.uri)); + } + + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, loadRepo !== null ? { repo: loadRepo } : null); + } + + /** + * The method run when the `git-graph.addGitRepository` command is invoked. + */ + private addGitRepository() { + if (this.gitExecutable === null) { + showErrorMessage(UNABLE_TO_FIND_GIT_MSG); + return; + } + + vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false }).then(uris => { + if (uris && uris.length > 0) { + let path = getPathFromUri(uris[0]); + if (isPathInWorkspace(path)) { + this.repoManager.registerRepo(path, false).then(status => { + if (status.error === null) { + showInformationMessage('The repository "' + status.root! + '" was added to Git Graph.'); + } else { + showErrorMessage(status.error + ' Therefore it could not be added to Git Graph.'); + } + }); + } else { + showErrorMessage('The folder "' + path + '" is not within the opened Visual Studio Code workspace, and therefore could not be added to Git Graph.'); + } + } + }, () => { }); + } + + /** + * The method run when the `git-graph.removeGitRepository` command is invoked. + */ + private removeGitRepository() { + if (this.gitExecutable === null) { + showErrorMessage(UNABLE_TO_FIND_GIT_MSG); + return; + } + + const repos = this.repoManager.getRepos(); + const items: vscode.QuickPickItem[] = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder).map((path) => ({ + label: repos[path].name || getRepoName(path), + description: path + })); + + vscode.window.showQuickPick(items, { + placeHolder: 'Select a repository to remove from Git Graph:', + canPickMany: false + }).then((item) => { + if (item && item.description !== undefined) { + if (this.repoManager.ignoreRepo(item.description)) { + showInformationMessage('The repository "' + item.label + '" was removed from Git Graph.'); + } else { + showErrorMessage('The repository "' + item.label + '" is not known to Git Graph.'); + } + } + }, () => { }); + } + + /** + * The method run when the `git-graph.clearAvatarCache` command is invoked. + */ + private clearAvatarCache() { + this.avatarManager.clearCache().then((errorInfo) => { + if (errorInfo === null) { + showInformationMessage('The Avatar Cache was successfully cleared.'); + } else { + showErrorMessage(errorInfo); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Clear Avatar Cache".'); + }); + } + + /** + * The method run when the `git-graph.fetch` command is invoked. + */ + private fetch() { + const repos = this.repoManager.getRepos(); + const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); + + if (repoPaths.length > 1) { + const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ + label: repos[path].name || getRepoName(path), + description: path + })); + + const lastActiveRepo = this.extensionState.getLastActiveRepo(); + if (lastActiveRepo !== null) { + let lastActiveRepoIndex = items.findIndex((item) => item.description === lastActiveRepo); + if (lastActiveRepoIndex > -1) { + const item = items.splice(lastActiveRepoIndex, 1)[0]; + items.unshift(item); + } + } + + vscode.window.showQuickPick(items, { + placeHolder: 'Select the repository you want to open in Git Graph, and fetch from remote(s):', + canPickMany: false + }).then((item) => { + if (item && item.description) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: item.description, + runCommandOnLoad: 'fetch' + }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Fetch from Remote(s)".'); + }); + } else if (repoPaths.length === 1) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: repoPaths[0], + runCommandOnLoad: 'fetch' + }); + } else { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, null); + } + } + + /** + * The method run when the `git-graph.endAllWorkspaceCodeReviews` command is invoked. + */ + private endAllWorkspaceCodeReviews() { + this.extensionState.endAllWorkspaceCodeReviews(); + showInformationMessage('Ended All Code Reviews in Workspace'); + } + + /** + * The method run when the `git-graph.endSpecificWorkspaceCodeReview` command is invoked. + */ + private endSpecificWorkspaceCodeReview() { + const codeReviews = this.extensionState.getCodeReviews(); + if (Object.keys(codeReviews).length === 0) { + showErrorMessage('There are no Code Reviews in progress within the current workspace.'); + return; + } + + vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { + placeHolder: 'Select the Code Review you want to end:', + canPickMany: false + }).then((item) => { + if (item) { + this.extensionState.endCodeReview(item.codeReviewRepo, item.codeReviewId).then((errorInfo) => { + if (errorInfo === null) { + showInformationMessage('Successfully ended Code Review "' + item.label + '".'); + } else { + showErrorMessage(errorInfo); + } + }, () => { }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "End a specific Code Review in Workspace...".'); + }); + } + + /** + * The method run when the `git-graph.resumeWorkspaceCodeReview` command is invoked. + */ + private resumeWorkspaceCodeReview() { + const codeReviews = this.extensionState.getCodeReviews(); + if (Object.keys(codeReviews).length === 0) { + showErrorMessage('There are no Code Reviews in progress within the current workspace.'); + return; + } + + vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { + placeHolder: 'Select the Code Review you want to resume:', + canPickMany: false + }).then((item) => { + if (item) { + const commitHashes = item.codeReviewId.split('-'); + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: item.codeReviewRepo, + commitDetails: { + commitHash: commitHashes[commitHashes.length > 1 ? 1 : 0], + compareWithHash: commitHashes.length > 1 ? commitHashes[0] : null + } + }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Resume a specific Code Review in Workspace...".'); + }); + } + + /** + * The method run when the `git-graph.version` command is invoked. + */ + private async version() { + try { + const gitGraphVersion = await getExtensionVersion(this.context); + const information = 'Git Graph: ' + gitGraphVersion + '\nVisual Studio Code: ' + vscode.version + '\nOS: ' + os.type() + ' ' + os.arch() + ' ' + os.release() + '\nGit: ' + (this.gitExecutable !== null ? this.gitExecutable.version : '(none)'); + vscode.window.showInformationMessage(information, { modal: true }, 'Copy').then((selectedItem) => { + if (selectedItem === 'Copy') { + copyToClipboard(information).then((result) => { + if (result !== null) { + showErrorMessage(result); + } + }); + } + }, () => { }); + } catch (_) { + showErrorMessage('An unexpected error occurred while retrieving version information.'); + } + } + + /** + * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). + * The method run when the `git-graph.openFile` command is invoked. + * @param arg The Git Graph URI. + */ + private openFile(arg?: vscode.Uri) { + const uri = arg || vscode.window.activeTextEditor?.document.uri; + if (typeof uri === 'object' && uri && uri.scheme === DiffDocProvider.scheme) { + // A Git Graph URI has been provided + const request = decodeDiffDocUri(uri); + return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { + if (errorInfo !== null) { + return showErrorMessage('Unable to Open File: ' + errorInfo); + } + }); + } else { + return showErrorMessage('Unable to Open File: The command was not called with the required arguments.'); + } + } + + /** + * Opens a position commit in Git Graph, based on a Git Graph URI (from the Diff View). + * The method run when the `git-graph.goToCommit` command is invoked. + * @param arg The Git Graph URI. + */ + private goToCommit(arg?: vscode.Uri) { + const uri = arg || vscode.window.activeTextEditor?.document.uri; + if (typeof uri === 'object' && uri && uri.query) { + let commitHash = ''; + + if (uri.scheme === 'git-graph') { + commitHash = decodeDiffDocUri(uri).commit; + } + if (uri.scheme === 'gitlens') { + commitHash = JSON.parse(uri.query).ref; + } + + if (commitHash !== '') { + if (GitGraphView.currentPanel) { + const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports; + if (!gitExtension) { + showErrorMessage('Unable to load Git extension.'); + return; + } + + // Get the API from the Git extension + const api = gitExtension.getAPI(1); + + // Access the first repository (assuming there is one) + const repository = api.repositories[0]; + if (!repository) { + showErrorMessage('No Git repository found.'); + return; + } + + this.view(repository); + setTimeout(() => { + GitGraphView.scrollToCommit(commitHash, true, false, true, true); + }, 2000); + } else { + this.view(undefined); + setTimeout(() => { + GitGraphView.scrollToCommit(commitHash, true, false, true, true); + }, 4000); + } + return; + } else { + return showErrorMessage('Unable Go To Commit: The commit hash not found.'); + } + } else { + return showErrorMessage('Unable Go To Commit: The command was not called with the required arguments.'); + } + } + + + /* Helper Methods */ + + /** + * Transform a set of Code Reviews into a list of Quick Pick items for use with `vscode.window.showQuickPick`. + * @param codeReviews A set of Code Reviews. + * @returns A list of Quick Pick items. + */ + private getCodeReviewQuickPickItems(codeReviews: CodeReviews): Promise { + const repos = this.repoManager.getRepos(); + const enrichedCodeReviews: { repo: string, id: string, review: CodeReviewData, fromCommitHash: string, toCommitHash: string }[] = []; + const fetchCommits: { repo: string, commitHash: string }[] = []; + + Object.keys(codeReviews).forEach((repo) => { + if (typeof repos[repo] === 'undefined') return; + Object.keys(codeReviews[repo]).forEach((id) => { + const commitHashes = id.split('-'); + commitHashes.forEach((commitHash) => fetchCommits.push({ repo: repo, commitHash: commitHash })); + enrichedCodeReviews.push({ + repo: repo, id: id, review: codeReviews[repo][id], + fromCommitHash: commitHashes[0], toCommitHash: commitHashes[commitHashes.length > 1 ? 1 : 0] + }); + }); + }); + + return Promise.all(fetchCommits.map((fetch) => this.dataSource.getCommitSubject(fetch.repo, fetch.commitHash))).then( + (subjects) => { + const commitSubjects: { [repo: string]: { [commitHash: string]: string } } = {}; + subjects.forEach((subject, i) => { + if (typeof commitSubjects[fetchCommits[i].repo] === 'undefined') { + commitSubjects[fetchCommits[i].repo] = {}; + } + commitSubjects[fetchCommits[i].repo][fetchCommits[i].commitHash] = subject !== null ? subject : ''; + }); + + return enrichedCodeReviews.sort((a, b) => b.review.lastActive - a.review.lastActive).map((codeReview) => { + const fromSubject = commitSubjects[codeReview.repo][codeReview.fromCommitHash]; + const toSubject = commitSubjects[codeReview.repo][codeReview.toCommitHash]; + const isComparison = codeReview.fromCommitHash !== codeReview.toCommitHash; + return { + codeReviewRepo: codeReview.repo, + codeReviewId: codeReview.id, + label: (repos[codeReview.repo].name || getRepoName(codeReview.repo)) + ': ' + abbrevCommit(codeReview.fromCommitHash) + (isComparison ? ' ↔ ' + abbrevCommit(codeReview.toCommitHash) : ''), + description: getRelativeTimeDiff(Math.round(codeReview.review.lastActive / 1000)), + detail: isComparison + ? abbrevText(fromSubject, 50) + ' ↔ ' + abbrevText(toSubject, 50) + : fromSubject + }; + }); + } + ); + } +} + +interface CodeReviewQuickPickItem extends vscode.QuickPickItem { + codeReviewRepo: string; + codeReviewId: string; +} diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 1dd36794..170be928 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -64,6 +64,20 @@ export class GitGraphView extends Disposable { } } + /** + * Scroll the view to a commit (if it exists). + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public static scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + if (GitGraphView.currentPanel) { + GitGraphView.currentPanel.respondScrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); + } + } + /** * Creates a Git Graph View. * @param extensionPath The absolute file path of the directory containing the extension. @@ -818,6 +832,25 @@ export class GitGraphView extends Disposable { loadViewTo: loadViewTo }); } + + /** + * Call the command to scroll to the specified commit to the front-end. + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public respondScrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + this.sendMessage({ + command: 'scrollToCommit', + hash: hash, + alwaysCenterCommit: alwaysCenterCommit, + flash: flash, + openDetails: openDetails, + persistently: persistently + }); + } } /** diff --git a/src/types.ts b/src/types.ts index 293c1a56..759f8fec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -978,6 +978,15 @@ export interface ResponseLoadRepos extends BaseMessage { readonly loadViewTo: LoadGitGraphViewTo; } +export interface ResponseScrollToCommit extends BaseMessage { + readonly command: 'scrollToCommit'; + readonly hash: string; + readonly alwaysCenterCommit: boolean; + readonly flash: boolean; + readonly openDetails: boolean; + readonly persistently: boolean; +} + export const enum MergeActionOn { Branch = 'Branch', RemoteTrackingBranch = 'Remote-tracking Branch', @@ -1363,6 +1372,7 @@ export type ResponseMessage = | ResponseLoadConfig | ResponseLoadRepoInfo | ResponseLoadRepos + | ResponseScrollToCommit | ResponseMerge | ResponseOpenExtensionSettings | ResponseOpenExternalDirDiff diff --git a/web/main.ts b/web/main.ts index 54976f4a..de1411d1 100644 --- a/web/main.ts +++ b/web/main.ts @@ -1,4117 +1,4151 @@ -class GitGraphView { - private gitRepos: GG.GitRepoSet; - private gitBranches: ReadonlyArray = []; - private gitBranchHead: string | null = null; - private gitConfig: GG.GitRepoConfig | null = null; - private gitRemotes: ReadonlyArray = []; - private gitStashes: ReadonlyArray = []; - private gitTags: ReadonlyArray = []; - private commits: GG.GitCommit[] = []; - private commitHead: string | null = null; - private commitLookup: { [hash: string]: number } = {}; - private onlyFollowFirstParent: boolean = false; - private avatars: AvatarImageCollection = {}; - private currentBranches: string[] | null = null; - private currentAuthors: string[] | null = null; - - private currentRepo!: string; - private currentRepoLoading: boolean = true; - private currentRepoRefreshState: { - inProgress: boolean; - hard: boolean; - loadRepoInfoRefreshId: number; - loadCommitsRefreshId: number; - repoInfoChanges: boolean; - configChanges: boolean; - requestingRepoInfo: boolean; - requestingConfig: boolean; - }; - private loadViewTo: GG.LoadGitGraphViewTo = null; - - private readonly graph: Graph; - private readonly config: Config; - - private moreCommitsAvailable: boolean = false; - private expandedCommit: ExpandedCommit | null = null; - private maxCommits: number; - private scrollTop = 0; - private renderedGitBranchHead: string | null = null; - - private lastScrollToStash: { - time: number, - hash: string | null - } = { time: 0, hash: null }; - - private readonly findWidget: FindWidget; - private readonly settingsWidget: SettingsWidget; - private readonly repoDropdown: Dropdown; - private readonly branchDropdown: Dropdown; - private readonly authorDropdown: Dropdown; - - private readonly viewElem: HTMLElement; - private readonly controlsElem: HTMLElement; - private readonly tableElem: HTMLElement; - private tableColHeadersElem: HTMLElement | null; - private readonly footerElem: HTMLElement; - private readonly showRemoteBranchesElem: HTMLInputElement; - private readonly simplifyByDecorationElem: HTMLInputElement; - private readonly refreshBtnElem: HTMLElement; - - constructor(viewElem: HTMLElement, prevState: WebViewState | null) { - this.gitRepos = initialState.repos; - this.config = initialState.config; - this.maxCommits = this.config.initialLoadCommits; - this.viewElem = viewElem; - this.currentRepoRefreshState = { - inProgress: false, - hard: true, - loadRepoInfoRefreshId: initialState.loadRepoInfoRefreshId, - loadCommitsRefreshId: initialState.loadCommitsRefreshId, - repoInfoChanges: false, - configChanges: false, - requestingRepoInfo: false, - requestingConfig: false - }; - - this.controlsElem = document.getElementById('controls')!; - this.tableElem = document.getElementById('commitTable')!; - this.tableColHeadersElem = document.getElementById('tableColHeaders')!; - this.footerElem = document.getElementById('footer')!; - - viewElem.focus(); - - this.graph = new Graph('commitGraph', viewElem, this.config.graph, this.config.mute); - - this.repoDropdown = new Dropdown('repoDropdown', true, false, 'Repos', (values) => { - this.loadRepo(values[0]); - }); - - this.branchDropdown = new Dropdown('branchDropdown', false, true, 'Branches', (values) => { - this.currentBranches = values; - this.maxCommits = this.config.initialLoadCommits; - this.saveState(); - this.clearCommits(); - this.requestLoadRepoInfoAndCommits(true, true); - }); - this.authorDropdown = new Dropdown('authorDropdown', false, true, 'Authors', (values) => { - this.currentAuthors = values; - this.maxCommits = this.config.initialLoadCommits; - this.saveState(); - this.clearCommits(); - this.requestLoadRepoInfoAndCommits(true, true); - }); - this.showRemoteBranchesElem = document.getElementById('showRemoteBranchesCheckbox')!; - this.showRemoteBranchesElem.addEventListener('change', () => { - this.saveRepoStateValue(this.currentRepo, 'showRemoteBranchesV2', this.showRemoteBranchesElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); - this.refresh(true); - }); - this.simplifyByDecorationElem = document.getElementById('simplifyByDecorationCheckbox')!; - this.simplifyByDecorationElem.addEventListener('change', () => { - this.saveRepoStateValue(this.currentRepo, 'simplifyByDecoration', this.simplifyByDecorationElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); - this.refresh(true); - }); - - this.refreshBtnElem = document.getElementById('refreshBtn')!; - this.refreshBtnElem.addEventListener('click', () => { - if (!this.refreshBtnElem.classList.contains(CLASS_REFRESHING)) { - this.refresh(true, true); - } - }); - this.renderRefreshButton(); - - this.findWidget = new FindWidget(this); - this.settingsWidget = new SettingsWidget(this); - - alterClass(document.body, CLASS_BRANCH_LABELS_ALIGNED_TO_GRAPH, this.config.referenceLabels.branchLabelsAlignedToGraph); - alterClass(document.body, CLASS_TAG_LABELS_RIGHT_ALIGNED, this.config.referenceLabels.tagLabelsOnRight); - - this.observeWindowSizeChanges(); - this.observeWebviewStyleChanges(); - this.observeViewScroll(); - this.observeKeyboardEvents(); - this.observeUrls(); - this.observeTableEvents(); - - if (prevState && !prevState.currentRepoLoading && typeof this.gitRepos[prevState.currentRepo] !== 'undefined') { - this.currentRepo = prevState.currentRepo; - this.currentBranches = prevState.currentBranches; - this.currentAuthors = prevState.currentAuthors; - this.maxCommits = prevState.maxCommits; - this.expandedCommit = prevState.expandedCommit; - this.avatars = prevState.avatars; - this.gitConfig = prevState.gitConfig; - this.loadRepoInfo(prevState.gitBranches, prevState.gitBranchHead, prevState.gitRemotes, prevState.gitStashes, true); - this.loadCommits(prevState.commits, prevState.commitHead, prevState.gitTags, prevState.moreCommitsAvailable, prevState.onlyFollowFirstParent); - this.findWidget.restoreState(prevState.findWidget); - this.settingsWidget.restoreState(prevState.settingsWidget); - this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[prevState.currentRepo].showRemoteBranchesV2); - this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[prevState.currentRepo].simplifyByDecoration); - } - - let loadViewTo = initialState.loadViewTo; - if (loadViewTo === null && prevState && prevState.currentRepoLoading && typeof prevState.currentRepo !== 'undefined') { - loadViewTo = { repo: prevState.currentRepo }; - } - - if (!this.loadRepos(this.gitRepos, initialState.lastActiveRepo, loadViewTo)) { - if (prevState) { - this.scrollTop = prevState.scrollTop; - this.viewElem.scroll(0, this.scrollTop); - } - this.requestLoadRepoInfoAndCommits(false, false); - } - - const currentBtn = document.getElementById('currentBtn')!, fetchBtn = document.getElementById('fetchBtn')!, findBtn = document.getElementById('findBtn')!, settingsBtn = document.getElementById('settingsBtn')!, terminalBtn = document.getElementById('terminalBtn')!; - currentBtn.innerHTML = SVG_ICONS.current; - currentBtn.addEventListener('click', () => { - if (this.commitHead) { - this.scrollToCommit(this.commitHead, true, true); - } - }); - fetchBtn.title = 'Fetch' + (this.config.fetchAndPrune ? ' & Prune' : '') + ' from Remote(s)'; - fetchBtn.innerHTML = SVG_ICONS.download; - fetchBtn.addEventListener('click', () => this.fetchFromRemotesAction()); - findBtn.innerHTML = SVG_ICONS.search; - findBtn.addEventListener('click', () => this.findWidget.show(true)); - settingsBtn.innerHTML = SVG_ICONS.gear; - settingsBtn.addEventListener('click', () => this.settingsWidget.show(this.currentRepo)); - terminalBtn.innerHTML = SVG_ICONS.terminal; - terminalBtn.addEventListener('click', () => { - runAction({ - command: 'openTerminal', - repo: this.currentRepo, - name: this.gitRepos[this.currentRepo].name || getRepoName(this.currentRepo) - }, 'Opening Terminal'); - }); - } - - - /* Loading Data */ - - public loadRepos(repos: GG.GitRepoSet, lastActiveRepo: string | null, loadViewTo: GG.LoadGitGraphViewTo) { - this.gitRepos = repos; - this.saveState(); - - let newRepo: string; - if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { - newRepo = loadViewTo.repo; - } else if (typeof repos[this.currentRepo] === 'undefined') { - newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' - ? lastActiveRepo - : getSortedRepositoryPaths(repos, this.config.repoDropdownOrder)[0]; - } else { - newRepo = this.currentRepo; - } - - alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); - this.renderRepoDropdownOptions(newRepo); - - if (loadViewTo !== null) { - if (loadViewTo.repo === newRepo) { - this.loadViewTo = loadViewTo; - } else { - this.loadViewTo = null; - showErrorMessage('Unable to load the Git Graph View for the repository "' + loadViewTo.repo + '". It is not currently included in Git Graph.'); - } - } else { - this.loadViewTo = null; - } - - if (this.currentRepo !== newRepo) { - this.loadRepo(newRepo); - return true; - } else { - this.finaliseRepoLoad(false); - return false; - } - } - - private loadRepo(repo: string) { - this.currentRepo = repo; - this.currentRepoLoading = true; - this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[this.currentRepo].showRemoteBranchesV2); - this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[this.currentRepo].simplifyByDecoration); - this.maxCommits = this.config.initialLoadCommits; - this.gitConfig = null; - this.gitRemotes = []; - this.gitStashes = []; - this.gitTags = []; - this.currentBranches = null; - this.currentAuthors = null; - this.renderFetchButton(); - this.closeCommitDetails(false); - this.settingsWidget.close(); - this.saveState(); - this.refresh(true); - } - - private loadRepoInfo(branchOptions: ReadonlyArray, branchHead: string | null, remotes: ReadonlyArray, stashes: ReadonlyArray, isRepo: boolean) { - // Changes to this.gitStashes are reflected as changes to the commits when loadCommits is run - this.gitStashes = stashes; - - if (!isRepo || (!this.currentRepoRefreshState.hard && arraysStrictlyEqual(this.gitBranches, branchOptions) && this.gitBranchHead === branchHead && arraysStrictlyEqual(this.gitRemotes, remotes))) { - this.saveState(); - this.finaliseLoadRepoInfo(false, isRepo); - return; - } - - // Changes to these properties must be indicated as a repository info change - this.gitBranches = branchOptions; - this.gitBranchHead = branchHead; - this.gitRemotes = remotes; - - // Update the state of the fetch button - this.renderFetchButton(); - - const filterCurrentBranches = () => { - // Configure current branches - if (this.currentBranches !== null && !(this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES)) { - // Filter any branches that are currently selected, but no longer exist - const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); - this.currentBranches = this.currentBranches.filter((branch) => - this.gitBranches.includes(branch) || globPatterns.includes(branch) || branch === 'HEAD' - ); - } - }; - - filterCurrentBranches(); - if (this.currentBranches === null || this.currentBranches.length === 0) { - // No branches are currently selected - const onRepoLoadShowCheckedOutBranch = getOnRepoLoadShowCheckedOutBranch(this.gitRepos[this.currentRepo].onRepoLoadShowCheckedOutBranch); - const onRepoLoadShowSpecificBranches = getOnRepoLoadShowSpecificBranches(this.gitRepos[this.currentRepo].onRepoLoadShowSpecificBranches); - this.currentBranches = []; - if (onRepoLoadShowSpecificBranches.length > 0) { - // Show specific branches if they exist in the repository - const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); - this.currentBranches.push(...onRepoLoadShowSpecificBranches.filter((branch) => - this.gitBranches.includes(branch) || globPatterns.includes(branch) - )); - } - if (onRepoLoadShowCheckedOutBranch && this.gitBranchHead !== null && !this.currentBranches.includes(this.gitBranchHead)) { - // Show the checked-out branch, and it hasn't already been added as a specific branch - this.currentBranches.push(this.gitBranchHead); - } - if (this.currentBranches.length === 0) { - this.currentBranches.push(SHOW_ALL_BRANCHES); - } - } - filterCurrentBranches(); - - this.saveState(); - - // Set up branch dropdown options - this.branchDropdown.setOptions(this.getBranchOptions(true), this.currentBranches); - this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); - - // Remove hidden remotes that no longer exist - let hiddenRemotes = this.gitRepos[this.currentRepo].hideRemotes; - let hideRemotes = hiddenRemotes.filter((hiddenRemote) => remotes.includes(hiddenRemote)); - if (hiddenRemotes.length !== hideRemotes.length) { - this.saveRepoStateValue(this.currentRepo, 'hideRemotes', hideRemotes); - } - - this.finaliseLoadRepoInfo(true, isRepo); - } - - private finaliseLoadRepoInfo(repoInfoChanges: boolean, isRepo: boolean) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - if (isRepo) { - refreshState.repoInfoChanges = refreshState.repoInfoChanges || repoInfoChanges; - refreshState.requestingRepoInfo = false; - this.requestLoadCommits(); - } else { - dialog.closeActionRunning(); - refreshState.inProgress = false; - this.loadViewTo = null; - this.renderRefreshButton(); - sendMessage({ command: 'loadRepos', check: true }); - } - } - } - - private loadCommits(commits: GG.GitCommit[], commitHead: string | null, tags: ReadonlyArray, moreAvailable: boolean, onlyFollowFirstParent: boolean) { - // This list of tags is just used to provide additional information in the dialogs. Tag information included in commits is used for all other purposes (e.g. rendering, context menus) - const tagsChanged = !arraysStrictlyEqual(this.gitTags, tags); - this.gitTags = tags; - - if (!this.currentRepoLoading && !this.currentRepoRefreshState.hard && this.moreCommitsAvailable === moreAvailable && this.onlyFollowFirstParent === onlyFollowFirstParent && this.commitHead === commitHead && commits.length > 0 && arraysEqual(this.commits, commits, (a, b) => - a.hash === b.hash && - arraysStrictlyEqual(a.heads, b.heads) && - arraysEqual(a.tags, b.tags, (a, b) => a.name === b.name && a.annotated === b.annotated) && - arraysEqual(a.remotes, b.remotes, (a, b) => a.name === b.name && a.remote === b.remote) && - arraysStrictlyEqual(a.parents, b.parents) && - ((a.stash === null && b.stash === null) || (a.stash !== null && b.stash !== null && a.stash.selector === b.stash.selector)) - ) && this.renderedGitBranchHead === this.gitBranchHead) { - - if (this.commits[0].hash === UNCOMMITTED) { - this.commits[0] = commits[0]; - this.saveState(); - this.renderUncommittedChanges(); - if (this.expandedCommit !== null && this.expandedCommit.commitElem !== null) { - if (this.expandedCommit.compareWithHash === null) { - // Commit Details View is open - if (this.expandedCommit.commitHash === UNCOMMITTED) { - this.requestCommitDetails(this.expandedCommit.commitHash, true); - } - } else { - // Commit Comparison is open - if (this.expandedCommit.compareWithElem !== null && (this.expandedCommit.commitHash === UNCOMMITTED || this.expandedCommit.compareWithHash === UNCOMMITTED)) { - this.requestCommitComparison(this.expandedCommit.commitHash, this.expandedCommit.compareWithHash, true); - } - } - } - } else if (tagsChanged) { - this.saveState(); - } - this.finaliseLoadCommits(); - return; - } - - const currentRepoLoading = this.currentRepoLoading; - this.currentRepoLoading = false; - this.moreCommitsAvailable = moreAvailable; - this.onlyFollowFirstParent = onlyFollowFirstParent; - this.commits = commits; - this.commitHead = commitHead; - this.commitLookup = {}; - - let i: number, expandedCommitVisible = false, expandedCompareWithCommitVisible = false, avatarsNeeded: { [email: string]: string[] } = {}, commit; - for (i = 0; i < this.commits.length; i++) { - commit = this.commits[i]; - this.commitLookup[commit.hash] = i; - if (this.expandedCommit !== null) { - if (this.expandedCommit.commitHash === commit.hash) { - expandedCommitVisible = true; - } else if (this.expandedCommit.compareWithHash === commit.hash) { - expandedCompareWithCommitVisible = true; - } - } - if (this.config.fetchAvatars && typeof this.avatars[commit.email] !== 'string' && commit.email !== '') { - if (typeof avatarsNeeded[commit.email] === 'undefined') { - avatarsNeeded[commit.email] = [commit.hash]; - } else { - avatarsNeeded[commit.email].push(commit.hash); - } - } - } - - if (this.expandedCommit !== null && (!expandedCommitVisible || (this.expandedCommit.compareWithHash !== null && !expandedCompareWithCommitVisible))) { - this.closeCommitDetails(false); - } - - this.saveState(); - - this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); - this.render(); - - if (currentRepoLoading && this.config.onRepoLoad.scrollToHead && this.commitHead !== null) { - this.scrollToCommit(this.commitHead, true); - } - - this.finaliseLoadCommits(); - this.requestAvatars(avatarsNeeded); - } - - private finaliseLoadCommits() { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - dialog.closeActionRunning(); - - if (dialog.isTargetDynamicSource()) { - if (refreshState.repoInfoChanges) { - dialog.close(); - } else { - dialog.refresh(this.getCommits()); - } - } - - if (contextMenu.isTargetDynamicSource()) { - if (refreshState.repoInfoChanges) { - contextMenu.close(); - } else { - contextMenu.refresh(this.getCommits()); - } - } - - refreshState.inProgress = false; - this.renderRefreshButton(); - } - - this.finaliseRepoLoad(true); - } - - private finaliseRepoLoad(didLoadRepoData: boolean) { - if (this.loadViewTo !== null && this.currentRepo === this.loadViewTo.repo) { - if (this.loadViewTo.commitDetails && (this.expandedCommit === null || this.expandedCommit.commitHash !== this.loadViewTo.commitDetails.commitHash || this.expandedCommit.compareWithHash !== this.loadViewTo.commitDetails.compareWithHash)) { - const commitIndex = this.getCommitId(this.loadViewTo.commitDetails.commitHash); - const compareWithIndex = this.loadViewTo.commitDetails.compareWithHash !== null ? this.getCommitId(this.loadViewTo.commitDetails.compareWithHash) : null; - const commitElems = getCommitElems(); - const commitElem = findCommitElemWithId(commitElems, commitIndex); - const compareWithElem = findCommitElemWithId(commitElems, compareWithIndex); - - if (commitElem !== null && (this.loadViewTo.commitDetails.compareWithHash === null || compareWithElem !== null)) { - if (compareWithElem !== null) { - this.loadCommitComparison(commitElem, compareWithElem); - } else { - this.loadCommitDetails(commitElem); - } - } else { - showErrorMessage('Unable to resume Code Review, it could not be found in the latest ' + this.maxCommits + ' commits that were loaded in this repository.'); - } - } else if (this.loadViewTo.runCommandOnLoad) { - switch (this.loadViewTo.runCommandOnLoad) { - case 'fetch': - this.fetchFromRemotesAction(); - break; - } - } - } - this.loadViewTo = null; - - if (this.gitConfig === null || (didLoadRepoData && this.currentRepoRefreshState.configChanges)) { - this.requestLoadConfig(); - } - } - - private clearCommits() { - closeDialogAndContextMenu(); - this.moreCommitsAvailable = false; - this.commits = []; - this.commitHead = null; - this.commitLookup = {}; - this.renderedGitBranchHead = null; - this.closeCommitDetails(false); - this.saveState(); - this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); - this.tableElem.innerHTML = ''; - this.footerElem.innerHTML = ''; - this.renderGraph(); - this.findWidget.refresh(); - } - - public processLoadRepoInfoResponse(msg: GG.ResponseLoadRepoInfo) { - if (msg.error === null) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress && refreshState.loadRepoInfoRefreshId === msg.refreshId) { - this.loadRepoInfo(msg.branches, msg.head, msg.remotes, msg.stashes, msg.isRepo); - } - } else { - this.displayLoadDataError('Unable to load Repository Info', msg.error); - } - } - - public processLoadCommitsResponse(msg: GG.ResponseLoadCommits) { - if (msg.error === null) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress && refreshState.loadCommitsRefreshId === msg.refreshId) { - this.loadCommits(msg.commits, msg.head, msg.tags, msg.moreCommitsAvailable, msg.onlyFollowFirstParent); - } - } else { - const error = this.gitBranches.length === 0 && msg.error.indexOf('bad revision \'HEAD\'') > -1 - ? 'There are no commits in this repository.' - : msg.error; - this.displayLoadDataError('Unable to load Commits', error); - } - } - - public processLoadConfig(msg: GG.ResponseLoadConfig) { - this.currentRepoRefreshState.requestingConfig = false; - if (msg.config !== null && this.currentRepo === msg.repo) { - this.gitConfig = msg.config; - this.saveState(); - - this.renderCdvExternalDiffBtn(); - } - this.settingsWidget.refresh(); - this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); - } - - private displayLoadDataError(message: string, reason: string) { - this.clearCommits(); - this.currentRepoRefreshState.inProgress = false; - this.loadViewTo = null; - this.renderRefreshButton(); - dialog.showError(message, reason, 'Retry', () => { - this.refresh(true); - }); - } - - public loadAvatar(email: string, image: string) { - this.avatars[email] = image; - this.saveState(); - let avatarsElems = >document.getElementsByClassName('avatar'), escapedEmail = escapeHtml(email); - for (let i = 0; i < avatarsElems.length; i++) { - if (avatarsElems[i].dataset.email === escapedEmail) { - avatarsElems[i].innerHTML = ''; - } - } - } - - - /* Getters */ - - public getBranches(): ReadonlyArray { - return this.gitBranches; - } - - public getBranchOptions(includeShowAll?: boolean): ReadonlyArray { - const options: DialogSelectInputOption[] = []; - if (includeShowAll) { - options.push({ name: 'Show All', value: SHOW_ALL_BRANCHES }); - } - options.push({ name: 'HEAD', value: 'HEAD' }); - for (let i = 0; i < this.config.customBranchGlobPatterns.length; i++) { - options.push({ name: 'Glob: ' + this.config.customBranchGlobPatterns[i].name, value: this.config.customBranchGlobPatterns[i].glob }); - } - for (let i = 0; i < this.gitBranches.length; i++) { - options.push({ name: this.gitBranches[i].indexOf('remotes/') === 0 ? this.gitBranches[i].substring(8) : this.gitBranches[i], value: this.gitBranches[i] }); - } - return options; - } - public getAuthorOptions(): ReadonlyArray { - const options: DialogSelectInputOption[] = []; - options.push({ name: 'All', value: SHOW_ALL_BRANCHES }); - if (this.gitConfig && this.gitConfig.authors) { - for (let i = 0; i < this!.gitConfig!.authors.length; i++) { - const author = this!.gitConfig!.authors[i]; - options.push({ name: author.name, value: author.name }); - } - } - return options; - } - public getCommitId(hash: string) { - return typeof this.commitLookup[hash] === 'number' ? this.commitLookup[hash] : null; - } - - private getCommitOfElem(elem: HTMLElement) { - let id = parseInt(elem.dataset.id!); - return id < this.commits.length ? this.commits[id] : null; - } - - public getCommits(): ReadonlyArray { - return this.commits; - } - - private getPushRemote(branch: string | null = null) { - const possibleRemotes = []; - if (this.gitConfig !== null) { - if (branch !== null && typeof this.gitConfig.branches[branch] !== 'undefined') { - possibleRemotes.push(this.gitConfig.branches[branch].pushRemote, this.gitConfig.branches[branch].remote); - } - possibleRemotes.push(this.gitConfig.pushDefault); - } - possibleRemotes.push('origin'); - return possibleRemotes.find((remote) => remote !== null && this.gitRemotes.includes(remote)) || this.gitRemotes[0]; - } - - public getRepoConfig(): Readonly | null { - return this.gitConfig; - } - - public getRepoState(repo: string): Readonly | null { - return typeof this.gitRepos[repo] !== 'undefined' - ? this.gitRepos[repo] - : null; - } - - public isConfigLoading(): boolean { - return this.currentRepoRefreshState.requestingConfig; - } - - - /* Refresh */ - - public refresh(hard: boolean, configChanges: boolean = false) { - if (hard) { - this.clearCommits(); - } - this.requestLoadRepoInfoAndCommits(hard, false, configChanges); - } - - - /* Requests */ - - private requestLoadRepoInfo() { - const repoState = this.gitRepos[this.currentRepo]; - sendMessage({ - command: 'loadRepoInfo', - repo: this.currentRepo, - refreshId: ++this.currentRepoRefreshState.loadRepoInfoRefreshId, - showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), - simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), - showStashes: getShowStashes(repoState.showStashes), - hideRemotes: repoState.hideRemotes - }); - } - - private requestLoadCommits() { - const repoState = this.gitRepos[this.currentRepo]; - sendMessage({ - command: 'loadCommits', - repo: this.currentRepo, - refreshId: ++this.currentRepoRefreshState.loadCommitsRefreshId, - branches: this.currentBranches === null || (this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES) ? null : this.currentBranches, - authors: this.currentAuthors === null || (this.currentAuthors.length === 1 && this.currentAuthors[0] === SHOW_ALL_BRANCHES) ? null : this.currentAuthors, - maxCommits: this.maxCommits, - showTags: getShowTags(repoState.showTags), - showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), - simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), - includeCommitsMentionedByReflogs: getIncludeCommitsMentionedByReflogs(repoState.includeCommitsMentionedByReflogs), - onlyFollowFirstParent: getOnlyFollowFirstParent(repoState.onlyFollowFirstParent), - commitOrdering: getCommitOrdering(repoState.commitOrdering), - remotes: this.gitRemotes, - hideRemotes: repoState.hideRemotes, - stashes: this.gitStashes - }); - } - - private requestLoadRepoInfoAndCommits(hard: boolean, skipRepoInfo: boolean, configChanges: boolean = false) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - refreshState.hard = refreshState.hard || hard; - refreshState.configChanges = refreshState.configChanges || configChanges; - if (!skipRepoInfo) { - // This request will trigger a loadCommit request after the loadRepoInfo request has completed. - // Invalidate any previous commit requests in progress. - refreshState.loadCommitsRefreshId++; - } - } else { - refreshState.hard = hard; - refreshState.inProgress = true; - refreshState.repoInfoChanges = false; - refreshState.configChanges = configChanges; - refreshState.requestingRepoInfo = false; - } - - this.renderRefreshButton(); - if (this.commits.length === 0) { - this.tableElem.innerHTML = '

' + SVG_ICONS.loading + 'Loading ...

'; - } - - if (skipRepoInfo) { - if (!refreshState.requestingRepoInfo) { - this.requestLoadCommits(); - } - } else { - refreshState.requestingRepoInfo = true; - this.requestLoadRepoInfo(); - } - } - - public requestLoadConfig() { - this.currentRepoRefreshState.requestingConfig = true; - sendMessage({ command: 'loadConfig', repo: this.currentRepo, remotes: this.gitRemotes }); - this.settingsWidget.refresh(); - } - - public requestCommitDetails(hash: string, refresh: boolean) { - let commit = this.commits[this.commitLookup[hash]]; - sendMessage({ - command: 'commitDetails', - repo: this.currentRepo, - commitHash: hash, - hasParents: commit.parents.length > 0, - stash: commit.stash, - avatarEmail: this.config.fetchAvatars && hash !== UNCOMMITTED ? commit.email : null, - refresh: refresh - }); - } - - public requestCommitComparison(hash: string, compareWithHash: string, refresh: boolean) { - let commitOrder = this.getCommitOrder(hash, compareWithHash); - sendMessage({ - command: 'compareCommits', - repo: this.currentRepo, - commitHash: hash, compareWithHash: compareWithHash, - fromHash: commitOrder.from, toHash: commitOrder.to, - refresh: refresh - }); - } - - private requestAvatars(avatars: { [email: string]: string[] }) { - let emails = Object.keys(avatars), remote = this.gitRemotes.length > 0 ? this.gitRemotes.includes('origin') ? 'origin' : this.gitRemotes[0] : null; - for (let i = 0; i < emails.length; i++) { - sendMessage({ command: 'fetchAvatar', repo: this.currentRepo, remote: remote, email: emails[i], commits: avatars[emails[i]] }); - } - } - - - /* State */ - - public saveState() { - let expandedCommit; - if (this.expandedCommit !== null) { - expandedCommit = Object.assign({}, this.expandedCommit); - expandedCommit.commitElem = null; - expandedCommit.compareWithElem = null; - expandedCommit.contextMenuOpen = { - summary: false, - fileView: -1 - }; - } else { - expandedCommit = null; - } - - VSCODE_API.setState({ - currentRepo: this.currentRepo, - currentRepoLoading: this.currentRepoLoading, - gitRepos: this.gitRepos, - gitBranches: this.gitBranches, - gitBranchHead: this.gitBranchHead, - gitConfig: this.gitConfig, - gitRemotes: this.gitRemotes, - gitStashes: this.gitStashes, - gitTags: this.gitTags, - commits: this.commits, - commitHead: this.commitHead, - avatars: this.avatars, - currentBranches: this.currentBranches, - currentAuthors: this.currentAuthors, - moreCommitsAvailable: this.moreCommitsAvailable, - maxCommits: this.maxCommits, - onlyFollowFirstParent: this.onlyFollowFirstParent, - expandedCommit: expandedCommit, - scrollTop: this.scrollTop, - findWidget: this.findWidget.getState(), - settingsWidget: this.settingsWidget.getState() - }); - } - - public saveRepoState() { - sendMessage({ command: 'setRepoState', repo: this.currentRepo, state: this.gitRepos[this.currentRepo] }); - } - - private saveColumnWidths(columnWidths: GG.ColumnWidth[]) { - this.gitRepos[this.currentRepo].columnWidths = [columnWidths[0], columnWidths[2], columnWidths[3], columnWidths[4]]; - this.saveRepoState(); - } - - private saveExpandedCommitLoading(index: number, commitHash: string, commitElem: HTMLElement, compareWithHash: string | null, compareWithElem: HTMLElement | null) { - this.expandedCommit = { - index: index, - commitHash: commitHash, - commitElem: commitElem, - compareWithHash: compareWithHash, - compareWithElem: compareWithElem, - commitDetails: null, - fileChanges: null, - fileTree: null, - avatar: null, - codeReview: null, - lastViewedFile: null, - loading: true, - scrollTop: { - summary: 0, - fileView: 0 - }, - contextMenuOpen: { - summary: false, - fileView: -1 - } - }; - this.saveState(); - } - - public saveRepoStateValue(repo: string, key: K, value: GG.GitRepoState[K]) { - if (repo === this.currentRepo) { - this.gitRepos[this.currentRepo][key] = value; - this.saveRepoState(); - } - } - - - /* Renderers */ - - private render() { - this.renderTable(); - this.renderGraph(); - } - - private renderGraph() { - if (typeof this.currentRepo === 'undefined') { - // Only render the graph if a repo is loaded (or a repo is currently being loaded) - return; - } - - const colHeadersElem = document.getElementById('tableColHeaders'); - const cdvHeight = this.gitRepos[this.currentRepo].isCdvSummaryHidden ? 0 : this.gitRepos[this.currentRepo].cdvHeight; - const headerHeight = colHeadersElem !== null ? colHeadersElem.clientHeight + 1 : 0; - const expandedCommit = this.isCdvDocked() ? null : this.expandedCommit; - const expandedCommitElem = expandedCommit !== null ? document.getElementById('cdv') : null; - - // Update the graphs grid dimensions - this.config.graph.grid.expandY = expandedCommitElem !== null - ? expandedCommitElem.getBoundingClientRect().height - : cdvHeight; - this.config.graph.grid.y = this.commits.length > 0 && this.tableElem.children.length > 0 - ? (this.tableElem.children[0].clientHeight - headerHeight - (expandedCommit !== null ? cdvHeight : 0)) / this.commits.length - : this.config.graph.grid.y; - this.config.graph.grid.offsetY = headerHeight + this.config.graph.grid.y / 2; - - this.graph.render(expandedCommit); - } - - private renderTable() { - const colVisibility = this.getColumnVisibility(); - const currentHash = this.commits.length > 0 && this.commits[0].hash === UNCOMMITTED ? UNCOMMITTED : this.commitHead; - const vertexColours = this.graph.getVertexColours(); - const widthsAtVertices = this.config.referenceLabels.branchLabelsAlignedToGraph ? this.graph.getWidthsAtVertices() : []; - const mutedCommits = this.graph.getMutedCommits(currentHash); - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - emoji: true, - issueLinking: true, - markdown: this.config.markdown - }); - - let html = 'GraphDescription' + - (colVisibility.date ? 'Date' : '') + - (colVisibility.author ? 'Author' : '') + - (colVisibility.commit ? 'Commit' : '') + - ''; - - for (let i = 0; i < this.commits.length; i++) { - let commit = this.commits[i]; - let message = '' + textFormatter.format(commit.message) + ''; - let date = formatShortDate(commit.date); - let branchLabels = getBranchLabels(commit.heads, commit.remotes); - let refBranches = '', refTags = '', j, k, refName, remoteName, refActive, refHtml, branchCheckedOutAtCommit: string | null = null; - - for (j = 0; j < branchLabels.heads.length; j++) { - refName = escapeHtml(branchLabels.heads[j].name); - refActive = branchLabels.heads[j].name === this.gitBranchHead; - refHtml = '' + SVG_ICONS.branch + '' + refName + ''; - for (k = 0; k < branchLabels.heads[j].remotes.length; k++) { - remoteName = escapeHtml(branchLabels.heads[j].remotes[k]); - refHtml += '' + remoteName + ''; - } - refHtml += ''; - refBranches = refActive ? refHtml + refBranches : refBranches + refHtml; - if (refActive) branchCheckedOutAtCommit = this.gitBranchHead; - } - for (j = 0; j < branchLabels.remotes.length; j++) { - refName = escapeHtml(branchLabels.remotes[j].name); - refBranches += '' + SVG_ICONS.branch + '' + refName + ''; - } - - for (j = 0; j < commit.tags.length; j++) { - refName = escapeHtml(commit.tags[j].name); - refTags += '' + SVG_ICONS.tag + '' + refName + ''; - } - - if (commit.stash !== null) { - refName = escapeHtml(commit.stash.selector); - refBranches = '' + SVG_ICONS.stash + '' + escapeHtml(commit.stash.selector.substring(5)) + '' + refBranches; - } - - const commitDot = commit.hash === this.commitHead - ? '' - : ''; - - html += '' + - (this.config.referenceLabels.branchLabelsAlignedToGraph ? '' + getResizeColHtml(0) + (refBranches !== '' ? '' + getResizeColHtml(1) + '' + commitDot : '' + getResizeColHtml(0) + '' + getResizeColHtml(1) + '' + commitDot + refBranches) + (this.config.referenceLabels.tagLabelsOnRight ? message + refTags : refTags + message) + '' + - (colVisibility.date ? '' + getResizeColHtml(2) + date.formatted + '' : '') + - (colVisibility.author ? '' + getResizeColHtml(3) + (this.config.fetchAvatars ? '' + (typeof this.avatars[commit.email] === 'string' ? '' : '') + '' : '') + escapeHtml(commit.author) + '' : '') + - (colVisibility.commit ? '' + getResizeColHtml(4) + abbrevCommit(commit.hash) + '' : '') + - ''; - - - } - function getResizeColHtml(col: number) { - return (col > 0 ? '' : '') + (col < 4 ? '' : ''); - } - this.tableElem.innerHTML = '' + html + '
'; - this.footerElem.innerHTML = this.moreCommitsAvailable ? '
Load More Commits
' : ''; - this.makeTableResizable(); - this.findWidget.refresh(); - this.renderedGitBranchHead = this.gitBranchHead; - - if (this.moreCommitsAvailable) { - document.getElementById('loadMoreCommitsBtn')!.addEventListener('click', () => { - this.loadMoreCommits(); - }); - } - - if (this.expandedCommit !== null) { - const expandedCommit = this.expandedCommit, elems = getCommitElems(); - const commitElem = findCommitElemWithId(elems, this.getCommitId(expandedCommit.commitHash)); - const compareWithElem = expandedCommit.compareWithHash !== null ? findCommitElemWithId(elems, this.getCommitId(expandedCommit.compareWithHash)) : null; - - if (commitElem === null || (expandedCommit.compareWithHash !== null && compareWithElem === null)) { - this.closeCommitDetails(false); - this.saveState(); - } else { - expandedCommit.index = parseInt(commitElem.dataset.id!); - expandedCommit.commitElem = commitElem; - expandedCommit.compareWithElem = compareWithElem; - this.saveState(); - if (expandedCommit.compareWithHash === null) { - // Commit Details View is open - if (!expandedCommit.loading && expandedCommit.commitDetails !== null && expandedCommit.fileTree !== null) { - this.showCommitDetails(expandedCommit.commitDetails, expandedCommit.fileTree, expandedCommit.avatar, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); - if (expandedCommit.commitHash === UNCOMMITTED) { - this.requestCommitDetails(expandedCommit.commitHash, true); - } - } else { - this.loadCommitDetails(commitElem); - } - } else { - // Commit Comparison is open - if (!expandedCommit.loading && expandedCommit.fileChanges !== null && expandedCommit.fileTree !== null) { - this.showCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, expandedCommit.fileChanges, expandedCommit.fileTree, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); - if (expandedCommit.commitHash === UNCOMMITTED || expandedCommit.compareWithHash === UNCOMMITTED) { - this.requestCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, true); - } - } else { - this.loadCommitComparison(commitElem, compareWithElem!); - } - } - } - } - - if (this.config.stickyHeader) { - this.tableColHeadersElem = document.getElementById('tableColHeaders'); - this.alignTableHeaderToControls(); - } - } - - private renderUncommittedChanges() { - const colVisibility = this.getColumnVisibility(), date = formatShortDate(this.commits[0].date); - document.getElementById('uncommittedChanges')!.innerHTML = '' + escapeHtml(this.commits[0].message) + '' + - (colVisibility.date ? '' + date.formatted + '' : '') + - (colVisibility.author ? '*' : '') + - (colVisibility.commit ? '*' : ''); - } - - private renderFetchButton() { - alterClass(this.controlsElem, CLASS_FETCH_SUPPORTED, this.gitRemotes.length > 0); - } - - public renderRefreshButton() { - const enabled = !this.currentRepoRefreshState.inProgress; - this.refreshBtnElem.title = enabled ? 'Refresh' : 'Refreshing'; - this.refreshBtnElem.innerHTML = enabled ? SVG_ICONS.refresh : SVG_ICONS.loading; - alterClass(this.refreshBtnElem, CLASS_REFRESHING, !enabled); - } - - public renderTagDetails(tagName: string, commitHash: string, details: GG.GitTagDetails) { - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - commits: true, - emoji: true, - issueLinking: true, - markdown: this.config.markdown, - multiline: true, - urls: true - }); - dialog.showMessage( - 'Tag ' + escapeHtml(tagName) + '
' + - 'Object: ' + escapeHtml(details.hash) + '
' + - 'Commit: ' + escapeHtml(commitHash) + '
' + - 'Tagger: ' + escapeHtml(details.taggerName) + ' <' + escapeHtml(details.taggerEmail) + '>' + (details.signature !== null ? generateSignatureHtml(details.signature) : '') + '
' + - 'Date: ' + formatLongDate(details.taggerDate) + '

' + - textFormatter.format(details.message) + - '
' - ); - } - - public renderRepoDropdownOptions(repo?: string) { - this.repoDropdown.setOptions(getRepoDropdownOptions(this.gitRepos), [repo || this.currentRepo]); - } - - - /* Context Menu Generation */ - - private getBranchContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { - const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.branch; - const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(refName); - - return [[ - { - title: 'Checkout Branch', - visible: visibility.checkout && this.gitBranchHead !== refName, - onClick: () => this.checkoutBranchAction(refName, null, null, target) - }, { - title: 'Rename Branch' + ELLIPSIS, - visible: visibility.rename, - onClick: () => { - dialog.showRefInput('Enter the new name for branch ' + escapeHtml(refName) + ':', refName, 'Rename Branch', (newName) => { - runAction({ command: 'renameBranch', repo: this.currentRepo, oldName: refName, newName: newName }, 'Renaming Branch'); - }, target); - } - }, { - title: 'Delete Branch' + ELLIPSIS, - visible: visibility.delete && this.gitBranchHead !== refName, - onClick: () => { - let remotesWithBranch = this.gitRemotes.filter(remote => this.gitBranches.includes('remotes/' + remote + '/' + refName)); - let inputs: DialogInput[] = [{ type: DialogInputType.Checkbox, name: 'Force Delete', value: this.config.dialogDefaults.deleteBranch.forceDelete }]; - if (remotesWithBranch.length > 0) { - inputs.push({ - type: DialogInputType.Checkbox, - name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : ''), - value: false, - info: 'This branch is on the remote' + (remotesWithBranch.length > 1 ? 's: ' : ' ') + formatCommaSeparatedList(remotesWithBranch.map((remote) => '"' + remote + '"')) - }); - } - dialog.showForm('Are you sure you want to delete the branch ' + escapeHtml(refName) + '?', inputs, 'Yes, delete', (values) => { - runAction({ command: 'deleteBranch', repo: this.currentRepo, branchName: refName, forceDelete: values[0], deleteOnRemotes: remotesWithBranch.length > 0 && values[1] ? remotesWithBranch : [] }, 'Deleting Branch'); - }, target); - } - }, { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge && this.gitBranchHead !== refName, - onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.Branch, target) - }, { - title: 'Rebase current Branch on Branch' + ELLIPSIS, - visible: visibility.rebase && this.gitBranchHead !== refName, - onClick: () => this.rebaseAction(refName, refName, GG.RebaseActionOn.Branch, target) - }, { - title: 'Push Branch' + ELLIPSIS, - visible: visibility.push && this.gitRemotes.length > 0, - onClick: () => { - const multipleRemotes = this.gitRemotes.length > 1; - const inputs: DialogInput[] = [ - { type: DialogInputType.Checkbox, name: 'Set Upstream', value: true }, - { - type: DialogInputType.Radio, - name: 'Push Mode', - options: [ - { name: 'Normal', value: GG.GitPushBranchMode.Normal }, - { name: 'Force With Lease', value: GG.GitPushBranchMode.ForceWithLease }, - { name: 'Force', value: GG.GitPushBranchMode.Force } - ], - default: GG.GitPushBranchMode.Normal - } - ]; - - if (multipleRemotes) { - inputs.unshift({ - type: DialogInputType.Select, - name: 'Push to Remote(s)', - defaults: [this.getPushRemote(refName)], - options: this.gitRemotes.map((remote) => ({ name: remote, value: remote })), - multiple: true - }); - } - - dialog.showForm('Are you sure you want to push the branch ' + escapeHtml(refName) + '' + (multipleRemotes ? '' : ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '') + '?', inputs, 'Yes, push', (values) => { - const remotes = multipleRemotes ? values.shift() : [this.gitRemotes[0]]; - const setUpstream = values[0]; - runAction({ - command: 'pushBranch', - repo: this.currentRepo, - branchName: refName, - remotes: remotes, - setUpstream: setUpstream, - mode: values[1], - willUpdateBranchConfig: setUpstream && remotes.length > 0 && (this.gitConfig === null || typeof this.gitConfig.branches[refName] === 'undefined' || this.gitConfig.branches[refName].remote !== remotes[remotes.length - 1]) - }, 'Pushing Branch'); - }, target); - } - } - ], [ - this.getViewIssueAction(refName, visibility.viewIssue, target), - { - title: 'Create Pull Request' + ELLIPSIS, - visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null, - onClick: () => { - const config = this.gitRepos[this.currentRepo].pullRequestConfig; - if (config === null) return; - dialog.showCheckbox('Are you sure you want to create a Pull Request for branch ' + escapeHtml(refName) + '?', 'Push branch before creating the Pull Request', true, 'Yes, create Pull Request', (push) => { - runAction({ command: 'createPullRequest', repo: this.currentRepo, config: config, sourceRemote: config.sourceRemote, sourceOwner: config.sourceOwner, sourceRepo: config.sourceRepo, sourceBranch: refName, push: push }, 'Creating Pull Request'); - }, target); - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); - } - }, - { - title: 'Select in Branches Dropdown', - visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.selectOption(refName) - }, - { - title: 'Unselect in Branches Dropdown', - visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.unselectOption(refName) - } - ], [ - { - title: 'Copy Branch Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); - } - } - ]]; - } - - private getCommitContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { - const hash = target.hash, visibility = this.config.contextMenuActionsVisibility.commit; - const commit = this.commits[this.commitLookup[hash]]; - return [[ - { - title: 'Add Tag' + ELLIPSIS, - visible: visibility.addTag, - onClick: () => this.addTagAction(hash, '', this.config.dialogDefaults.addTag.type, '', null, target) - }, { - title: 'Create Branch' + ELLIPSIS, - visible: visibility.createBranch, - onClick: () => this.createBranchAction(hash, '', this.config.dialogDefaults.createBranch.checkout, target) - } - ], [ - { - title: 'Checkout' + (globalState.alwaysAcceptCheckoutCommit ? '' : ELLIPSIS), - visible: visibility.checkout, - onClick: () => { - const checkoutCommit = () => runAction({ command: 'checkoutCommit', repo: this.currentRepo, commitHash: hash }, 'Checking out Commit'); - if (globalState.alwaysAcceptCheckoutCommit) { - checkoutCommit(); - } else { - dialog.showCheckbox('Are you sure you want to checkout commit ' + abbrevCommit(hash) + '? This will result in a \'detached HEAD\' state.', 'Always Accept', false, 'Yes, checkout', (alwaysAccept) => { - if (alwaysAccept) { - updateGlobalViewState('alwaysAcceptCheckoutCommit', true); - } - checkoutCommit(); - }, target); - } - } - }, { - title: 'Cherry Pick' + ELLIPSIS, - visible: visibility.cherrypick, - onClick: () => { - const isMerge = commit.parents.length > 1; - let inputs: DialogInput[] = []; - if (isMerge) { - let options = commit.parents.map((hash, index) => ({ - name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), - value: (index + 1).toString() - })); - inputs.push({ - type: DialogInputType.Select, - name: 'Parent Hash', - options: options, - default: '1', - info: 'Choose the parent hash on the main branch, to cherry pick the commit relative to.' - }); - } - inputs.push({ - type: DialogInputType.Checkbox, - name: 'Record Origin', - value: this.config.dialogDefaults.cherryPick.recordOrigin, - info: 'Record that this commit was the origin of the cherry pick by appending a line to the original commit message that states "(cherry picked from commit ...​)".' - }, { - type: DialogInputType.Checkbox, - name: 'No Commit', - value: this.config.dialogDefaults.cherryPick.noCommit, - info: 'Cherry picked changes will be staged but not committed, so that you can select and commit specific parts of this commit.' - }); - - dialog.showForm('Are you sure you want to cherry pick commit ' + abbrevCommit(hash) + '?', inputs, 'Yes, cherry pick', (values) => { - let parentIndex = isMerge ? parseInt(values.shift()) : 0; - runAction({ - command: 'cherrypickCommit', - repo: this.currentRepo, - commitHash: hash, - parentIndex: parentIndex, - recordOrigin: values[0], - noCommit: values[1] - }, 'Cherry picking Commit'); - }, target); - } - }, { - title: 'Revert' + ELLIPSIS, - visible: visibility.revert, - onClick: () => { - if (commit.parents.length > 1) { - let options = commit.parents.map((hash, index) => ({ - name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), - value: (index + 1).toString() - })); - dialog.showSelect('Are you sure you want to revert merge commit ' + abbrevCommit(hash) + '? Choose the parent hash on the main branch, to revert the commit relative to:', '1', options, 'Yes, revert', (parentIndex) => { - runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: parseInt(parentIndex) }, 'Reverting Commit'); - }, target); - } else { - dialog.showConfirmation('Are you sure you want to revert commit ' + abbrevCommit(hash) + '?', 'Yes, revert', () => { - runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: 0 }, 'Reverting Commit'); - }, target); - } - } - }, { - title: 'Drop' + ELLIPSIS, - visible: visibility.drop && this.graph.dropCommitPossible(this.commitLookup[hash]), - onClick: () => { - dialog.showConfirmation('Are you sure you want to permanently drop commit ' + abbrevCommit(hash) + '?' + (this.onlyFollowFirstParent ? '
Note: By enabling "Only follow the first parent of commits", some commits may have been hidden from the Git Graph View that could affect the outcome of performing this action.' : ''), 'Yes, drop', () => { - runAction({ command: 'dropCommit', repo: this.currentRepo, commitHash: hash }, 'Dropping Commit'); - }, target); - } - } - ], [ - { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge, - onClick: () => this.mergeAction(hash, abbrevCommit(hash), GG.MergeActionOn.Commit, target) - }, { - title: 'Rebase current Branch on this Commit' + ELLIPSIS, - visible: visibility.rebase, - onClick: () => this.rebaseAction(hash, abbrevCommit(hash), GG.RebaseActionOn.Commit, target) - }, { - title: 'Reset current branch to this Commit' + ELLIPSIS, - visible: visibility.reset, - onClick: () => { - dialog.showSelect('Are you sure you want to reset ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' to commit ' + abbrevCommit(hash) + '?', this.config.dialogDefaults.resetCommit.mode, [ - { name: 'Soft - Keep all changes, but reset head', value: GG.GitResetMode.Soft }, - { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, - { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } - ], 'Yes, reset', (mode) => { - runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: hash, resetMode: mode }, 'Resetting to Commit'); - }, target); - } - } - ], [ - { - title: 'Copy Commit Hash to Clipboard', - visible: visibility.copyHash, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Commit Hash', data: hash }); - } - }, - { - title: 'Copy Commit Subject to Clipboard', - visible: visibility.copySubject, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Commit Subject', data: commit.message }); - } - } - ]]; - } - - private getRemoteBranchContextMenuActions(remote: string, target: DialogTarget & RefTarget): ContextMenuActions { - const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.remoteBranch; - const branchName = remote !== '' ? refName.substring(remote.length + 1) : ''; - const prefixedRefName = 'remotes/' + refName; - const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(prefixedRefName); - return [[ - { - title: 'Checkout Branch' + ELLIPSIS, - visible: visibility.checkout, - onClick: () => this.checkoutBranchAction(refName, remote, null, target) - }, { - title: 'Delete Remote Branch' + ELLIPSIS, - visible: visibility.delete && remote !== '', - onClick: () => { - dialog.showConfirmation('Are you sure you want to delete the remote branch ' + escapeHtml(refName) + '?', 'Yes, delete', () => { - runAction({ command: 'deleteRemoteBranch', repo: this.currentRepo, branchName: branchName, remote: remote }, 'Deleting Remote Branch'); - }, target); - } - }, { - title: 'Fetch into local branch' + ELLIPSIS, - visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, - onClick: () => { - dialog.showForm('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Force Fetch', - value: this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, - info: 'Force the local branch to be reset to this remote branch.' - }], 'Yes, fetch', (values) => { - runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: values[0] }, 'Fetching Branch'); - }, target); - } - }, { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge, - onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.RemoteTrackingBranch, target) - }, { - title: 'Pull into current branch' + ELLIPSIS, - visible: visibility.pull && remote !== '', - onClick: () => { - dialog.showForm('Are you sure you want to pull the remote branch ' + escapeHtml(refName) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '? If a merge is required:', [ - { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.pullBranch.noFastForward }, - { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.pullBranch.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this remote branch.' } - ], 'Yes, pull', (values) => { - runAction({ command: 'pullBranch', repo: this.currentRepo, branchName: branchName, remote: remote, createNewCommit: values[0], squash: values[1] }, 'Pulling Branch'); - }, target); - } - } - ], [ - this.getViewIssueAction(refName, visibility.viewIssue, target), - { - title: 'Create Pull Request', - visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' && - (this.gitRepos[this.currentRepo].pullRequestConfig!.sourceRemote === remote || this.gitRepos[this.currentRepo].pullRequestConfig!.destRemote === remote), - onClick: () => { - const config = this.gitRepos[this.currentRepo].pullRequestConfig; - if (config === null) return; - const isDestRemote = config.destRemote === remote; - runAction({ - command: 'createPullRequest', - repo: this.currentRepo, - config: config, - sourceRemote: isDestRemote ? config.destRemote! : config.sourceRemote, - sourceOwner: isDestRemote ? config.destOwner : config.sourceOwner, - sourceRepo: isDestRemote ? config.destRepo : config.sourceRepo, - sourceBranch: branchName, - push: false - }, 'Creating Pull Request'); - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); - } - }, - { - title: 'Select in Branches Dropdown', - visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.selectOption(prefixedRefName) - }, - { - title: 'Unselect in Branches Dropdown', - visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.unselectOption(prefixedRefName) - } - ], [ - { - title: 'Copy Branch Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); - } - } - ]]; - } - - private getStashContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { - const hash = target.hash, selector = target.ref, visibility = this.config.contextMenuActionsVisibility.stash; - return [[ - { - title: 'Apply Stash' + ELLIPSIS, - visible: visibility.apply, - onClick: () => { - dialog.showForm('Are you sure you want to apply the stash ' + escapeHtml(selector.substring(5)) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Reinstate Index', - value: this.config.dialogDefaults.applyStash.reinstateIndex, - info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' - }], 'Yes, apply stash', (values) => { - runAction({ command: 'applyStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Applying Stash'); - }, target); - } - }, { - title: 'Create Branch from Stash' + ELLIPSIS, - visible: visibility.createBranch, - onClick: () => { - dialog.showRefInput('Create a branch from stash ' + escapeHtml(selector.substring(5)) + ' with the name:', '', 'Create Branch', (branchName) => { - runAction({ command: 'branchFromStash', repo: this.currentRepo, selector: selector, branchName: branchName }, 'Creating Branch'); - }, target); - } - }, { - title: 'Pop Stash' + ELLIPSIS, - visible: visibility.pop, - onClick: () => { - dialog.showForm('Are you sure you want to pop the stash ' + escapeHtml(selector.substring(5)) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Reinstate Index', - value: this.config.dialogDefaults.popStash.reinstateIndex, - info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' - }], 'Yes, pop stash', (values) => { - runAction({ command: 'popStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Popping Stash'); - }, target); - } - }, { - title: 'Drop Stash' + ELLIPSIS, - visible: visibility.drop, - onClick: () => { - dialog.showConfirmation('Are you sure you want to drop the stash ' + escapeHtml(selector.substring(5)) + '?', 'Yes, drop', () => { - runAction({ command: 'dropStash', repo: this.currentRepo, selector: selector }, 'Dropping Stash'); - }, target); - } - } - ], [ - { - title: 'Copy Stash Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Stash Name', data: selector }); - } - }, { - title: 'Copy Stash Hash to Clipboard', - visible: visibility.copyHash, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Stash Hash', data: hash }); - } - } - ]]; - } - - private getTagContextMenuActions(isAnnotated: boolean, target: DialogTarget & RefTarget): ContextMenuActions { - const hash = target.hash, tagName = target.ref, visibility = this.config.contextMenuActionsVisibility.tag; - return [[ - { - title: 'View Details', - visible: visibility.viewDetails && isAnnotated, - onClick: () => { - runAction({ command: 'tagDetails', repo: this.currentRepo, tagName: tagName, commitHash: hash }, 'Retrieving Tag Details'); - } - }, { - title: 'Delete Tag' + ELLIPSIS, - visible: visibility.delete, - onClick: () => { - let message = 'Are you sure you want to delete the tag ' + escapeHtml(tagName) + '?'; - if (this.gitRemotes.length > 1) { - let options = [{ name: 'Don\'t delete on any remote', value: '-1' }]; - this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); - dialog.showSelect(message + '
Do you also want to delete the tag on a remote:', '-1', options, 'Yes, delete', remoteIndex => { - this.deleteTagAction(tagName, remoteIndex !== '-1' ? this.gitRemotes[parseInt(remoteIndex)] : null); - }, target); - } else if (this.gitRemotes.length === 1) { - dialog.showCheckbox(message, 'Also delete on remote', false, 'Yes, delete', deleteOnRemote => { - this.deleteTagAction(tagName, deleteOnRemote ? this.gitRemotes[0] : null); - }, target); - } else { - dialog.showConfirmation(message, 'Yes, delete', () => { - this.deleteTagAction(tagName, null); - }, target); - } - } - }, { - title: 'Push Tag' + ELLIPSIS, - visible: visibility.push && this.gitRemotes.length > 0, - onClick: () => { - const runPushTagAction = (remotes: string[]) => { - runAction({ - command: 'pushTag', - repo: this.currentRepo, - tagName: tagName, - remotes: remotes, - commitHash: hash, - skipRemoteCheck: globalState.pushTagSkipRemoteCheck - }, 'Pushing Tag'); - }; - - if (this.gitRemotes.length === 1) { - dialog.showConfirmation('Are you sure you want to push the tag ' + escapeHtml(tagName) + ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '?', 'Yes, push', () => { - runPushTagAction([this.gitRemotes[0]]); - }, target); - } else if (this.gitRemotes.length > 1) { - const defaults = [this.getPushRemote()]; - const options = this.gitRemotes.map((remote) => ({ name: remote, value: remote })); - dialog.showMultiSelect('Are you sure you want to push the tag ' + escapeHtml(tagName) + '? Select the remote(s) to push the tag to:', defaults, options, 'Yes, push', (remotes) => { - runPushTagAction(remotes); - }, target); - } - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: tagName }, 'Creating Archive'); - } - }, - { - title: 'Copy Tag Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Tag Name', data: tagName }); - } - } - ]]; - } - - private getUncommittedChangesContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { - let visibility = this.config.contextMenuActionsVisibility.uncommittedChanges; - return [[ - { - title: 'Stash uncommitted changes' + ELLIPSIS, - visible: visibility.stash, - onClick: () => { - dialog.showForm('Are you sure you want to stash the uncommitted changes?', [ - { type: DialogInputType.Text, name: 'Message', default: '', placeholder: 'Optional' }, - { type: DialogInputType.Checkbox, name: 'Include Untracked', value: this.config.dialogDefaults.stashUncommittedChanges.includeUntracked, info: 'Include all untracked files in the stash, and then clean them from the working directory.' } - ], 'Yes, stash', (values) => { - runAction({ command: 'pushStash', repo: this.currentRepo, message: values[0], includeUntracked: values[1] }, 'Stashing uncommitted changes'); - }, target); - } - } - ], [ - { - title: 'Reset uncommitted changes' + ELLIPSIS, - visible: visibility.reset, - onClick: () => { - dialog.showSelect('Are you sure you want to reset the uncommitted changes to HEAD?', this.config.dialogDefaults.resetUncommitted.mode, [ - { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, - { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } - ], 'Yes, reset', (mode) => { - runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: 'HEAD', resetMode: mode }, 'Resetting uncommitted changes'); - }, target); - } - }, { - title: 'Clean untracked files' + ELLIPSIS, - visible: visibility.clean, - onClick: () => { - dialog.showCheckbox('Are you sure you want to clean all untracked files?', 'Clean untracked directories', true, 'Yes, clean', directories => { - runAction({ command: 'cleanUntrackedFiles', repo: this.currentRepo, directories: directories }, 'Cleaning untracked files'); - }, target); - } - } - ], [ - { - title: 'Open Source Control View', - visible: visibility.openSourceControlView, - onClick: () => { - sendMessage({ command: 'viewScm' }); - } - } - ]]; - } - - private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction { - const issueLinks: { url: string, displayText: string }[] = []; - - let issueLinking: IssueLinking | null, match: RegExpExecArray | null; - if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) { - issueLinking.regexp.lastIndex = 0; - while (match = issueLinking.regexp.exec(refName)) { - if (match[0].length === 0) break; - issueLinks.push({ - url: generateIssueLinkFromMatch(match, issueLinking), - displayText: match[0] - }); - } - } - - return { - title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''), - visible: issueLinks.length > 0, - onClick: () => { - if (issueLinks.length > 1) { - dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => { - sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url }); - }, target); - } else if (issueLinks.length === 1) { - sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url }); - } - } - }; - } - - - /* Actions */ - - private addTagAction(hash: string, initialName: string, initialType: GG.TagType, initialMessage: string, initialPushToRemote: string | null, target: DialogTarget & CommitTarget, isInitialLoad: boolean = true) { - let mostRecentTagsIndex = -1; - for (let i = 0; i < this.commits.length; i++) { - if (this.commits[i].tags.length > 0 && (mostRecentTagsIndex === -1 || this.commits[i].date > this.commits[mostRecentTagsIndex].date)) { - mostRecentTagsIndex = i; - } - } - const mostRecentTags = mostRecentTagsIndex > -1 ? this.commits[mostRecentTagsIndex].tags.map((tag) => '"' + tag.name + '"') : []; - - const inputs: DialogInput[] = [ - { type: DialogInputType.TextRef, name: 'Name', default: initialName, info: mostRecentTags.length > 0 ? 'The most recent tag' + (mostRecentTags.length > 1 ? 's' : '') + ' in the loaded commits ' + (mostRecentTags.length > 1 ? 'are' : 'is') + ' ' + formatCommaSeparatedList(mostRecentTags) + '.' : undefined }, - { type: DialogInputType.Select, name: 'Type', default: initialType === GG.TagType.Annotated ? 'annotated' : 'lightweight', options: [{ name: 'Annotated', value: 'annotated' }, { name: 'Lightweight', value: 'lightweight' }] }, - { type: DialogInputType.Text, name: 'Message', default: initialMessage, placeholder: 'Optional', info: 'A message can only be added to an annotated tag.' } - ]; - if (this.gitRemotes.length > 1) { - const options = [{ name: 'Don\'t push', value: '-1' }]; - this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); - const defaultOption = initialPushToRemote !== null - ? this.gitRemotes.indexOf(initialPushToRemote) - : isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote - ? this.gitRemotes.indexOf(this.getPushRemote()) - : -1; - inputs.push({ type: DialogInputType.Select, name: 'Push to remote', options: options, default: defaultOption.toString(), info: 'Once this tag has been added, push it to this remote.' }); - } else if (this.gitRemotes.length === 1) { - const defaultValue = initialPushToRemote !== null || (isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote); - inputs.push({ type: DialogInputType.Checkbox, name: 'Push to remote', value: defaultValue, info: 'Once this tag has been added, push it to the repositories remote.' }); - } - - dialog.showForm('Add tag to commit ' + abbrevCommit(hash) + ':', inputs, 'Add Tag', (values) => { - const tagName = values[0]; - const type = values[1] === 'annotated' ? GG.TagType.Annotated : GG.TagType.Lightweight; - const message = values[2]; - const pushToRemote = this.gitRemotes.length > 1 && values[3] !== '-1' - ? this.gitRemotes[parseInt(values[3])] - : this.gitRemotes.length === 1 && values[3] - ? this.gitRemotes[0] - : null; - - const runAddTagAction = (force: boolean) => { - runAction({ - command: 'addTag', - repo: this.currentRepo, - tagName: tagName, - commitHash: hash, - type: type, - message: message, - pushToRemote: pushToRemote, - pushSkipRemoteCheck: globalState.pushTagSkipRemoteCheck, - force: force - }, 'Adding Tag'); - }; - - if (this.gitTags.includes(tagName)) { - dialog.showTwoButtons('A tag named ' + escapeHtml(tagName) + ' already exists, do you want to replace it with this new tag?', 'Yes, replace the existing tag', () => { - runAddTagAction(true); - }, 'No, choose another tag name', () => { - this.addTagAction(hash, tagName, type, message, pushToRemote, target, false); - }, target); - } else { - runAddTagAction(false); - } - }, target); - } - - private checkoutBranchAction(refName: string, remote: string | null, prefillName: string | null, target: DialogTarget & (CommitTarget | RefTarget)) { - if (remote !== null) { - dialog.showRefInput('Enter the name of the new branch you would like to create when checking out ' + escapeHtml(refName) + ':', (prefillName !== null ? prefillName : (remote !== '' ? refName.substring(remote.length + 1) : refName)), 'Checkout Branch', newBranch => { - if (this.gitBranches.includes(newBranch)) { - const canPullFromRemote = remote !== ''; - dialog.showTwoButtons('The name ' + escapeHtml(newBranch) + ' is already used by another branch:', 'Choose another branch name', () => { - this.checkoutBranchAction(refName, remote, newBranch, target); - }, 'Checkout the existing branch' + (canPullFromRemote ? ' & pull changes' : ''), () => { - runAction({ - command: 'checkoutBranch', - repo: this.currentRepo, - branchName: newBranch, - remoteBranch: null, - pullAfterwards: canPullFromRemote - ? { - branchName: refName.substring(remote.length + 1), - remote: remote, - createNewCommit: this.config.dialogDefaults.pullBranch.noFastForward, - squash: this.config.dialogDefaults.pullBranch.squash - } - : null - }, 'Checking out Branch' + (canPullFromRemote ? ' & Pulling Changes' : '')); - }, target); - } else { - runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: newBranch, remoteBranch: refName, pullAfterwards: null }, 'Checking out Branch'); - } - }, target); - } else { - runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: refName, remoteBranch: null, pullAfterwards: null }, 'Checking out Branch'); - } - } - - private createBranchAction(hash: string, initialName: string, initialCheckOut: boolean, target: DialogTarget & CommitTarget) { - dialog.showForm('Create branch at commit ' + abbrevCommit(hash) + ':', [ - { type: DialogInputType.TextRef, name: 'Name', default: initialName }, - { type: DialogInputType.Checkbox, name: 'Check out', value: initialCheckOut } - ], 'Create Branch', (values) => { - const branchName = values[0], checkOut = values[1]; - if (this.gitBranches.includes(branchName)) { - dialog.showTwoButtons('A branch named ' + escapeHtml(branchName) + ' already exists, do you want to replace it with this new branch?', 'Yes, replace the existing branch', () => { - runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: true }, 'Creating Branch'); - }, 'No, choose another branch name', () => { - this.createBranchAction(hash, branchName, checkOut, target); - }, target); - } else { - runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: false }, 'Creating Branch'); - } - }, target); - } - - private deleteTagAction(refName: string, deleteOnRemote: string | null) { - runAction({ command: 'deleteTag', repo: this.currentRepo, tagName: refName, deleteOnRemote: deleteOnRemote }, 'Deleting Tag'); - } - - private fetchFromRemotesAction() { - runAction({ command: 'fetch', repo: this.currentRepo, name: null, prune: this.config.fetchAndPrune, pruneTags: this.config.fetchAndPruneTags }, 'Fetching from Remote(s)'); - } - - private mergeAction(obj: string, name: string, actionOn: GG.MergeActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { - dialog.showForm('Are you sure you want to merge ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '?', [ - { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.merge.noFastForward }, - { type: DialogInputType.Checkbox, name: 'Allow unrelated histories', value: this.config.dialogDefaults.merge.allowUnrelatedHistories, info: 'Allow merging branches from two completely different repositories or branches.' }, - { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.merge.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this ' + actionOn.toLowerCase() + '.' }, - { type: DialogInputType.Checkbox, name: 'No Commit', value: this.config.dialogDefaults.merge.noCommit, info: 'The changes of the merge will be staged but not committed, so that you can review and/or modify the merge result before committing.' } - ], 'Yes, merge', (values) => { - runAction({ command: 'merge', repo: this.currentRepo, obj: obj, actionOn: actionOn, createNewCommit: values[0], allowUnrelatedHistories: values[1], squash: values[2], noCommit: values[3] }, 'Merging ' + actionOn); - }, target); - } - - private rebaseAction(obj: string, name: string, actionOn: GG.RebaseActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { - dialog.showForm('Are you sure you want to rebase ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' on ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + '?', [ - { type: DialogInputType.Checkbox, name: 'Interactive Rebase (launch in new Terminal)', value: this.config.dialogDefaults.rebase.interactive }, - { type: DialogInputType.Checkbox, name: 'Ignore Date', value: this.config.dialogDefaults.rebase.ignoreDate, info: 'Only applicable to a non-interactive rebase.' } - ], 'Yes, rebase', (values) => { - let interactive = values[0]; - runAction({ command: 'rebase', repo: this.currentRepo, obj: obj, actionOn: actionOn, ignoreDate: values[1], interactive: interactive }, interactive ? 'Launching Interactive Rebase' : 'Rebasing on ' + actionOn); - }, target); - } - - - /* Table Utils */ - - private makeTableResizable() { - let colHeadersElem = document.getElementById('tableColHeaders')!, cols = >document.getElementsByClassName('tableColHeader'); - let columnWidths: GG.ColumnWidth[], mouseX = -1, col = -1, colIndex = -1; - - const makeTableFixedLayout = () => { - cols[0].style.width = columnWidths[0] + 'px'; - cols[0].style.padding = ''; - for (let i = 2; i < cols.length; i++) { - cols[i].style.width = columnWidths[parseInt(cols[i].dataset.col!)] + 'px'; - } - this.tableElem.className = 'fixedLayout'; - this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); - this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); - }; - - for (let i = 0; i < cols.length; i++) { - let col = parseInt(cols[i].dataset.col!); - cols[i].innerHTML += (i > 0 ? '' : '') + (i < cols.length - 1 ? '' : ''); - } - - let cWidths = this.gitRepos[this.currentRepo].columnWidths; - if (cWidths === null) { // Initialise auto column layout if it is the first time viewing the repo. - let defaults = this.config.defaultColumnVisibility; - columnWidths = [COLUMN_AUTO, COLUMN_AUTO, defaults.date ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.author ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.commit ? COLUMN_AUTO : COLUMN_HIDDEN]; - this.saveColumnWidths(columnWidths); - } else { - columnWidths = [cWidths[0], COLUMN_AUTO, cWidths[1], cWidths[2], cWidths[3]]; - } - - if (columnWidths[0] !== COLUMN_AUTO) { - // Table should have fixed layout - makeTableFixedLayout(); - } else { - // Table should have automatic layout - this.tableElem.className = 'autoLayout'; - - let colWidth = cols[0].offsetWidth, graphWidth = this.graph.getContentWidth(); - let maxWidth = Math.round(this.viewElem.clientWidth * 0.333); - if (Math.max(graphWidth, colWidth) > maxWidth) { - this.graph.limitMaxWidth(maxWidth); - graphWidth = maxWidth; - this.tableElem.className += ' limitGraphWidth'; - this.tableElem.style.setProperty(CSS_PROP_LIMIT_GRAPH_WIDTH, maxWidth + 'px'); - } else { - this.graph.limitMaxWidth(-1); - this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); - } - - if (colWidth < Math.max(graphWidth, 64)) { - cols[0].style.padding = '6px ' + Math.floor((Math.max(graphWidth, 64) - (colWidth - COLUMN_LEFT_RIGHT_PADDING)) / 2) + 'px'; - } - } - - const processResizingColumn: EventListener = (e) => { - if (col > -1) { - let mouseEvent = e; - let mouseDeltaX = mouseEvent.clientX - mouseX; - - if (col === 0) { - if (columnWidths[0] + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -columnWidths[0] + COLUMN_MIN_WIDTH; - if (cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - COLUMN_MIN_WIDTH; - columnWidths[0] += mouseDeltaX; - cols[0].style.width = columnWidths[0] + 'px'; - this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); - } else { - let colWidth = col !== 1 ? columnWidths[col] : cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING; - let nextCol = col + 1; - while (columnWidths[nextCol] === COLUMN_HIDDEN) nextCol++; - - if (colWidth + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -colWidth + COLUMN_MIN_WIDTH; - if (columnWidths[nextCol] - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = columnWidths[nextCol] - COLUMN_MIN_WIDTH; - if (col !== 1) { - columnWidths[col] += mouseDeltaX; - cols[colIndex].style.width = columnWidths[col] + 'px'; - } - columnWidths[nextCol] -= mouseDeltaX; - cols[colIndex + 1].style.width = columnWidths[nextCol] + 'px'; - } - mouseX = mouseEvent.clientX; - } - }; - const stopResizingColumn: EventListener = () => { - if (col > -1) { - col = -1; - colIndex = -1; - mouseX = -1; - eventOverlay.remove(); - this.saveColumnWidths(columnWidths); - } - }; - - addListenerToClass('resizeCol', 'mousedown', (e) => { - if (e.target === null) return; - col = parseInt((e.target).dataset.col!); - while (columnWidths[col] === COLUMN_HIDDEN) col--; - mouseX = (e).clientX; - - let isAuto = columnWidths[0] === COLUMN_AUTO; - for (let i = 0; i < cols.length; i++) { - let curCol = parseInt(cols[i].dataset.col!); - if (isAuto && curCol !== 1) columnWidths[curCol] = cols[i].clientWidth - COLUMN_LEFT_RIGHT_PADDING; - if (curCol === col) colIndex = i; - } - if (isAuto) makeTableFixedLayout(); - eventOverlay.create('colResize', processResizingColumn, stopResizingColumn); - }); - - colHeadersElem.addEventListener('contextmenu', (e: MouseEvent) => { - handledEvent(e); - - const toggleColumnState = (col: number, defaultWidth: number) => { - columnWidths[col] = columnWidths[col] !== COLUMN_HIDDEN ? COLUMN_HIDDEN : columnWidths[0] === COLUMN_AUTO ? COLUMN_AUTO : defaultWidth - COLUMN_LEFT_RIGHT_PADDING; - this.saveColumnWidths(columnWidths); - this.render(); - }; - - const commitOrdering = getCommitOrdering(this.gitRepos[this.currentRepo].commitOrdering); - const changeCommitOrdering = (repoCommitOrdering: GG.RepoCommitOrdering) => { - this.saveRepoStateValue(this.currentRepo, 'commitOrdering', repoCommitOrdering); - this.refresh(true); - }; - - contextMenu.show([ - [ - { - title: 'Date', - visible: true, - checked: columnWidths[2] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(2, 128) - }, - { - title: 'Author', - visible: true, - checked: columnWidths[3] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(3, 128) - }, - { - title: 'Commit', - visible: true, - checked: columnWidths[4] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(4, 80) - } - ], - [ - { - title: 'Commit Timestamp Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.Date, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Date) - }, - { - title: 'Author Timestamp Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.AuthorDate, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.AuthorDate) - }, - { - title: 'Topological Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.Topological, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Topological) - } - ] - ], true, null, e, this.viewElem); - }); - } - - public getColumnVisibility() { - let colWidths = this.gitRepos[this.currentRepo].columnWidths; - if (colWidths !== null) { - return { date: colWidths[1] !== COLUMN_HIDDEN, author: colWidths[2] !== COLUMN_HIDDEN, commit: colWidths[3] !== COLUMN_HIDDEN }; - } else { - let defaults = this.config.defaultColumnVisibility; - return { date: defaults.date, author: defaults.author, commit: defaults.commit }; - } - } - - private getNumColumns() { - let colVisibility = this.getColumnVisibility(); - return 2 + (colVisibility.date ? 1 : 0) + (colVisibility.author ? 1 : 0) + (colVisibility.commit ? 1 : 0); - } - - /** - * Scroll the view to the previous or next stash. - * @param next TRUE => Jump to the next stash, FALSE => Jump to the previous stash. - */ - private scrollToStash(next: boolean) { - const stashCommits = this.commits.filter((commit) => commit.stash !== null); - if (stashCommits.length > 0) { - const curTime = (new Date()).getTime(); - if (this.lastScrollToStash.time < curTime - 5000) { - // Reset the lastScrollToStash hash if it was more than 5 seconds ago - this.lastScrollToStash.hash = null; - } - - const lastScrollToStashCommitIndex = this.lastScrollToStash.hash !== null - ? stashCommits.findIndex((commit) => commit.hash === this.lastScrollToStash.hash) - : -1; - let scrollToStashCommitIndex = lastScrollToStashCommitIndex + (next ? 1 : -1); - if (scrollToStashCommitIndex >= stashCommits.length) { - scrollToStashCommitIndex = 0; - } else if (scrollToStashCommitIndex < 0) { - scrollToStashCommitIndex = stashCommits.length - 1; - } - this.scrollToCommit(stashCommits[scrollToStashCommitIndex].hash, true, true); - this.lastScrollToStash.time = curTime; - this.lastScrollToStash.hash = stashCommits[scrollToStashCommitIndex].hash; - } - } - - /** - * Scroll the view to a commit (if it exists). - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - */ - public scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false) { - const elem = findCommitElemWithId(getCommitElems(), this.getCommitId(hash)); - if (elem === null) return; - - let elemTop = this.controlsElem.clientHeight + elem.offsetTop; - if (alwaysCenterCommit || elemTop - 8 < this.viewElem.scrollTop || elemTop + 32 - this.viewElem.clientHeight > this.viewElem.scrollTop) { - this.viewElem.scroll(0, this.controlsElem.clientHeight + elem.offsetTop + 12 - this.viewElem.clientHeight / 2); - } - - if (flash && !elem.classList.contains('flash')) { - elem.classList.add('flash'); - setTimeout(() => { - elem.classList.remove('flash'); - }, 850); - } - } - - private loadMoreCommits() { - this.footerElem.innerHTML = '

' + SVG_ICONS.loading + 'Loading ...

'; - this.maxCommits += this.config.loadMoreCommits; - this.saveState(); - this.requestLoadRepoInfoAndCommits(false, true); - } - - private alignTableHeaderToControls() { - if (!this.tableColHeadersElem) { - return; - } - } - - - /* Observers */ - - private observeWindowSizeChanges() { - let windowWidth = window.outerWidth, windowHeight = window.outerHeight; - window.addEventListener('resize', () => { - if (windowWidth === window.outerWidth && windowHeight === window.outerHeight) { - this.renderGraph(); - } else { - windowWidth = window.outerWidth; - windowHeight = window.outerHeight; - } - - if (this.config.stickyHeader) { - this.alignTableHeaderToControls(); - } - }); - } - - private observeWebviewStyleChanges() { - let fontFamily = getVSCodeStyle(CSS_PROP_FONT_FAMILY), - editorFontFamily = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), - findMatchColour = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), - selectionBackgroundColor = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); - - const setFlashColour = (colour: string) => { - document.body.style.setProperty('--git-graph-flashPrimary', modifyColourOpacity(colour, 0.7)); - document.body.style.setProperty('--git-graph-flashSecondary', modifyColourOpacity(colour, 0.5)); - }; - const setSelectionBackgroundColorExists = () => { - alterClass(document.body, 'selection-background-color-exists', selectionBackgroundColor); - }; - - this.findWidget.setColour(findMatchColour); - setFlashColour(findMatchColour); - setSelectionBackgroundColorExists(); - - (new MutationObserver(() => { - let ff = getVSCodeStyle(CSS_PROP_FONT_FAMILY), - eff = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), - fmc = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), - sbc = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); - - if (ff !== fontFamily || eff !== editorFontFamily) { - fontFamily = ff; - editorFontFamily = eff; - this.repoDropdown.refresh(); - this.branchDropdown.refresh(); - this.authorDropdown.refresh(); - } - if (fmc !== findMatchColour) { - findMatchColour = fmc; - this.findWidget.setColour(findMatchColour); - setFlashColour(findMatchColour); - } - if (selectionBackgroundColor !== sbc) { - selectionBackgroundColor = sbc; - setSelectionBackgroundColorExists(); - } - })).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); - } - - private observeViewScroll() { - let active = this.viewElem.scrollTop > 0, timeout: NodeJS.Timer | null = null; - this.viewElem.addEventListener('scroll', () => { - const scrollTop = this.viewElem.scrollTop; - if (active !== scrollTop > 0) { - active = scrollTop > 0; - } - - if (this.config.loadMoreCommitsAutomatically && this.moreCommitsAvailable && !this.currentRepoRefreshState.inProgress) { - const viewHeight = this.viewElem.clientHeight, contentHeight = this.viewElem.scrollHeight; - if (scrollTop > 0 && viewHeight > 0 && contentHeight > 0 && (scrollTop + viewHeight) >= contentHeight - 25) { - // If the user has scrolled such that the bottom of the visible view is within 25px of the end of the content, load more commits. - this.loadMoreCommits(); - } - } - - if (timeout !== null) clearTimeout(timeout); - timeout = setTimeout(() => { - this.scrollTop = scrollTop; - this.saveState(); - timeout = null; - }, 250); - }); - } - - private observeKeyboardEvents() { - document.addEventListener('keydown', (e) => { - if (contextMenu.isOpen()) { - if (e.key === 'Escape') { - contextMenu.close(); - handledEvent(e); - } - } else if (dialog.isOpen()) { - if (e.key === 'Escape') { - dialog.close(); - handledEvent(e); - } else if (e.keyCode ? e.keyCode === 13 : e.key === 'Enter') { - // Use keyCode === 13 to detect 'Enter' events if available (for compatibility with IME Keyboards used by Chinese / Japanese / Korean users) - dialog.submit(); - handledEvent(e); - } - } else if (this.expandedCommit !== null && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - const curHashIndex = this.commitLookup[this.expandedCommit.commitHash]; - let newHashIndex = -1; - - if (e.ctrlKey || e.metaKey) { - // Up / Down navigates according to the order of commits on the branch - if (e.shiftKey) { - // Follow commits on alternative branches when possible - if (e.key === 'ArrowUp') { - newHashIndex = this.graph.getAlternativeChildIndex(curHashIndex); - } else if (e.key === 'ArrowDown') { - newHashIndex = this.graph.getAlternativeParentIndex(curHashIndex); - } - } else { - // Follow commits on the same branch - if (e.key === 'ArrowUp') { - newHashIndex = this.graph.getFirstChildIndex(curHashIndex); - } else if (e.key === 'ArrowDown') { - newHashIndex = this.graph.getFirstParentIndex(curHashIndex); - } - } - } else { - // Up / Down navigates according to the order of commits in the table - if (e.key === 'ArrowUp' && curHashIndex > 0) { - newHashIndex = curHashIndex - 1; - } else if (e.key === 'ArrowDown' && curHashIndex < this.commits.length - 1) { - newHashIndex = curHashIndex + 1; - } - } - - if (newHashIndex > -1) { - handledEvent(e); - const elem = findCommitElemWithId(getCommitElems(), newHashIndex); - if (elem !== null) this.loadCommitDetails(elem); - } - } else if (e.key && (e.ctrlKey || e.metaKey)) { - const key = e.key.toLowerCase(), keybindings = this.config.keybindings; - if (key === keybindings.scrollToStash) { - this.scrollToStash(!e.shiftKey); - handledEvent(e); - } else if (!e.shiftKey) { - if (key === keybindings.refresh) { - this.refresh(true, true); - handledEvent(e); - } else if (key === keybindings.find) { - this.findWidget.show(true); - handledEvent(e); - } else if (key === keybindings.scrollToHead && this.commitHead !== null) { - this.scrollToCommit(this.commitHead, true, true); - handledEvent(e); - } - } - } else if (e.key === 'Escape') { - if (this.repoDropdown.isOpen()) { - this.repoDropdown.close(); - handledEvent(e); - } else if (this.branchDropdown.isOpen()) { - this.branchDropdown.close(); - handledEvent(e); - } else if (this.authorDropdown.isOpen()) { - this.authorDropdown.close(); - handledEvent(e); - } else if (this.settingsWidget.isVisible()) { - this.settingsWidget.close(); - handledEvent(e); - } else if (this.findWidget.isVisible()) { - this.findWidget.close(); - handledEvent(e); - } else if (this.expandedCommit !== null) { - this.closeCommitDetails(true); - handledEvent(e); - } - } - }); - } - - private observeUrls() { - const followInternalLink = (e: MouseEvent) => { - if (e.target !== null && isInternalUrlElem(e.target)) { - const value = unescapeHtml((e.target).dataset.value!); - switch ((e.target).dataset.type!) { - case 'commit': - if (typeof this.commitLookup[value] === 'number' && (this.expandedCommit === null || this.expandedCommit.commitHash !== value || this.expandedCommit.compareWithHash !== null)) { - const elem = findCommitElemWithId(getCommitElems(), this.commitLookup[value]); - if (elem !== null) this.loadCommitDetails(elem); - } - break; - } - } - }; - - document.body.addEventListener('click', followInternalLink); - - document.body.addEventListener('contextmenu', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - - const isExternalUrl = isExternalUrlElem(eventTarget), isInternalUrl = isInternalUrlElem(eventTarget); - if (isExternalUrl || isInternalUrl) { - const viewElem: HTMLElement | null = eventTarget.closest('#view'); - let eventElem: HTMLElement | null; - - let target: (ContextMenuTarget & CommitTarget) | RepoTarget, isInDialog = false; - if (this.expandedCommit !== null && eventTarget.closest('#cdv') !== null) { - // URL is in the Commit Details View - target = { - type: TargetType.CommitDetailsView, - hash: this.expandedCommit.commitHash, - index: this.commitLookup[this.expandedCommit.commitHash], - elem: eventTarget - }; - GitGraphView.closeCdvContextMenuIfOpen(this.expandedCommit); - this.expandedCommit.contextMenuOpen.summary = true; - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // URL is in the Commits - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - target = { - type: TargetType.Commit, - hash: commit.hash, - index: parseInt(eventElem.dataset.id!), - elem: eventTarget - }; - } else { - // URL is in a dialog - target = { - type: TargetType.Repo - }; - isInDialog = true; - } - - handledEvent(e); - contextMenu.show([ - [ - { - title: 'Open URL', - visible: isExternalUrl, - onClick: () => { - sendMessage({ command: 'openExternalUrl', url: (eventTarget).href }); - } - }, - { - title: 'Follow Internal Link', - visible: isInternalUrl, - onClick: () => followInternalLink(e) - }, - { - title: 'Copy URL to Clipboard', - visible: isExternalUrl, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'External URL', data: (eventTarget).href }); - } - } - ] - ], false, target, e, viewElem || document.body, () => { - if (target.type === TargetType.CommitDetailsView && this.expandedCommit !== null) { - this.expandedCommit.contextMenuOpen.summary = false; - } - }, isInDialog ? 'dialogContextMenu' : null); - } - }); - } - - private observeTableEvents() { - - // Register Click Event Handler - this.tableElem.addEventListener('click', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was clicked - e.stopPropagation(); - if (contextMenu.isOpen()) { - contextMenu.close(); - } - - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // .commit was clicked - if (this.expandedCommit !== null) { - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - - if (this.expandedCommit.commitHash === commit.hash) { - this.closeCommitDetails(true); - } else if ((e).ctrlKey || (e).metaKey) { - if (this.expandedCommit.compareWithHash === commit.hash) { - this.closeCommitComparison(true); - } else if (this.expandedCommit.commitElem !== null) { - this.loadCommitComparison(this.expandedCommit.commitElem, eventElem); - } - } else { - this.loadCommitDetails(eventElem); - } - } else { - this.loadCommitDetails(eventElem); - } - } - }); - - // Register Double Click Event Handler - this.tableElem.addEventListener('dblclick', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was double clicked - e.stopPropagation(); - closeDialogAndContextMenu(); - const commitElem = eventElem.closest('.commit')!; - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - if (eventElem.classList.contains(CLASS_REF_HEAD) || eventElem.classList.contains(CLASS_REF_REMOTE)) { - let sourceElem = eventElem.children[1]; - let refName = unescapeHtml(eventElem.dataset.name!), isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); - if (isHead && isRemoteCombinedWithHead) { - refName = unescapeHtml((eventTarget).dataset.fullref!); - sourceElem = eventTarget; - isHead = false; - } - - const target: ContextMenuTarget & DialogTarget & RefTarget = { - type: TargetType.Ref, - hash: commit.hash, - index: parseInt(commitElem.dataset.id!), - ref: refName, - elem: sourceElem - }; - - this.checkoutBranchAction(refName, isHead ? null : unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!), null, target); - } - } - }); - - // Register ContextMenu Event Handler - this.tableElem.addEventListener('contextmenu', (e: Event) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was right clicked - handledEvent(e); - const commitElem = eventElem.closest('.commit')!; - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - const target: ContextMenuTarget & DialogTarget & RefTarget = { - type: TargetType.Ref, - hash: commit.hash, - index: parseInt(commitElem.dataset.id!), - ref: unescapeHtml(eventElem.dataset.name!), - elem: eventElem.children[1] - }; - - let actions: ContextMenuActions; - if (eventElem.classList.contains(CLASS_REF_STASH)) { - actions = this.getStashContextMenuActions(target); - } else if (eventElem.classList.contains(CLASS_REF_TAG)) { - actions = this.getTagContextMenuActions(eventElem.dataset.tagtype === 'annotated', target); - } else { - let isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); - if (isHead && isRemoteCombinedWithHead) { - target.ref = unescapeHtml((eventTarget).dataset.fullref!); - target.elem = eventTarget; - isHead = false; - } - if (isHead) { - actions = this.getBranchContextMenuActions(target); - } else { - const remote = unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!); - actions = this.getRemoteBranchContextMenuActions(remote, target); - } - } - - contextMenu.show(actions, false, target, e, this.viewElem); - - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // .commit was right clicked - handledEvent(e); - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - - const target: ContextMenuTarget & DialogTarget & CommitTarget = { - type: TargetType.Commit, - hash: commit.hash, - index: parseInt(eventElem.dataset.id!), - elem: eventElem - }; - - let actions: ContextMenuActions; - if (commit.hash === UNCOMMITTED) { - actions = this.getUncommittedChangesContextMenuActions(target); - } else if (commit.stash !== null) { - target.ref = commit.stash.selector; - actions = this.getStashContextMenuActions(target); - } else { - actions = this.getCommitContextMenuActions(target); - } - - contextMenu.show(actions, false, target, e, this.viewElem); - } - }); - } - - - /* Commit Details View */ - - public loadCommitDetails(commitElem: HTMLElement) { - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - this.closeCommitDetails(false); - this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, null, null); - commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - this.renderCommitDetailsView(false); - this.requestCommitDetails(commit.hash, false); - } - - public closeCommitDetails(saveAndRender: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - const elem = document.getElementById('cdv'), isDocked = this.isCdvDocked(); - if (elem !== null) { - elem.remove(); - } - if (isDocked) { - this.viewElem.style.bottom = '0px'; - } - if (expandedCommit.commitElem !== null) { - expandedCommit.commitElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - if (expandedCommit.compareWithElem !== null) { - expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - this.expandedCommit = null; - if (saveAndRender) { - this.saveState(); - if (!isDocked) { - this.renderGraph(); - } - } - } - - public showCommitDetails(commitDetails: GG.GitCommitDetails, fileTree: FileTreeFolder, avatar: string | null, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.commitHash !== commitDetails.hash || expandedCommit.compareWithHash !== null) return; - - if (!this.isCdvDocked()) { - const elem = document.getElementById('cdv'); - if (elem !== null) elem.remove(); - } - - expandedCommit.commitDetails = commitDetails; - if (haveFilesChanged(expandedCommit.fileChanges, commitDetails.fileChanges)) { - expandedCommit.fileChanges = commitDetails.fileChanges; - expandedCommit.fileTree = fileTree; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - } - expandedCommit.avatar = avatar; - expandedCommit.codeReview = codeReview; - if (!refresh) { - expandedCommit.lastViewedFile = lastViewedFile; - } - expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.loading = false; - this.saveState(); - - this.renderCommitDetailsView(refresh); - } - - public createFileTree(gitFiles: ReadonlyArray, codeReview: GG.CodeReview | null) { - let contents: FileTreeFolderContents = {}, i, j, path, absPath, cur: FileTreeFolder; - let files: FileTreeFolder = { type: 'folder', name: '', folderPath: '', contents: contents, open: true, reviewed: true }; - - for (i = 0; i < gitFiles.length; i++) { - cur = files; - path = gitFiles[i].newFilePath.split('/'); - absPath = this.currentRepo; - for (j = 0; j < path.length; j++) { - absPath += '/' + path[j]; - if (typeof this.gitRepos[absPath] !== 'undefined') { - if (typeof cur.contents[path[j]] === 'undefined') { - cur.contents[path[j]] = { type: 'repo', name: path[j], path: absPath }; - } - break; - } else if (j < path.length - 1) { - if (typeof cur.contents[path[j]] === 'undefined') { - contents = {}; - cur.contents[path[j]] = { type: 'folder', name: path[j], folderPath: absPath.substring(this.currentRepo.length + 1), contents: contents, open: true, reviewed: true }; - } - cur = cur.contents[path[j]]; - } else if (path[j] !== '') { - cur.contents[path[j]] = { type: 'file', name: path[j], index: i, reviewed: codeReview === null || !codeReview.remainingFiles.includes(gitFiles[i].newFilePath) }; - } - } - } - if (codeReview !== null) calcFileTreeFoldersReviewed(files); - return files; - } - - - /* Commit Comparison View */ - - private loadCommitComparison(commitElem: HTMLElement, compareWithElem: HTMLElement) { - const commit = this.getCommitOfElem(commitElem); - const compareWithCommit = this.getCommitOfElem(compareWithElem); - - if (commit !== null && compareWithCommit !== null) { - if (this.expandedCommit !== null) { - if (this.expandedCommit.commitHash !== commit.hash) { - this.closeCommitDetails(false); - } else if (this.expandedCommit.compareWithHash !== compareWithCommit.hash) { - this.closeCommitComparison(false); - } - } - - this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, compareWithCommit.hash, compareWithElem); - commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - this.renderCommitDetailsView(false); - this.requestCommitComparison(commit.hash, compareWithCommit.hash, false); - } - } - - public closeCommitComparison(saveAndRequestCommitDetails: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.compareWithHash === null) return; - - if (expandedCommit.compareWithElem !== null) { - expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - if (saveAndRequestCommitDetails) { - if (expandedCommit.commitElem !== null) { - this.saveExpandedCommitLoading(expandedCommit.index, expandedCommit.commitHash, expandedCommit.commitElem, null, null); - this.renderCommitDetailsView(false); - this.requestCommitDetails(expandedCommit.commitHash, false); - } else { - this.closeCommitDetails(true); - } - } - } - - public showCommitComparison(commitHash: string, compareWithHash: string, fileChanges: ReadonlyArray, fileTree: FileTreeFolder, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.compareWithElem === null || expandedCommit.commitHash !== commitHash || expandedCommit.compareWithHash !== compareWithHash) return; - - if (haveFilesChanged(expandedCommit.fileChanges, fileChanges)) { - expandedCommit.fileChanges = fileChanges; - expandedCommit.fileTree = fileTree; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - } - expandedCommit.codeReview = codeReview; - if (!refresh) { - expandedCommit.lastViewedFile = lastViewedFile; - } - expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.loading = false; - this.saveState(); - - this.renderCommitDetailsView(refresh); - } - - - /* Render Commit Details / Comparison View */ - - private renderCommitDetailsView(refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null) return; - - let elem = document.getElementById('cdv'), html = '
', isDocked = this.isCdvDocked(); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const codeReviewPossible = !expandedCommit.loading && commitOrder.to !== UNCOMMITTED; - const externalDiffPossible = !expandedCommit.loading && (expandedCommit.compareWithHash !== null || this.commits[this.commitLookup[expandedCommit.commitHash]].parents.length > 0); - - if (elem === null) { - elem = document.createElement(isDocked ? 'div' : 'tr'); - elem.id = 'cdv'; - elem.className = isDocked ? 'docked' : 'inline'; - this.setCdvHeight(elem, isDocked); - if (isDocked) { - document.body.appendChild(elem); - } else { - insertAfter(elem, expandedCommit.commitElem); - } - } - - if (expandedCommit.loading) { - html += '
' + SVG_ICONS.loading + ' Loading ' + (expandedCommit.compareWithHash === null ? expandedCommit.commitHash !== UNCOMMITTED ? 'Commit Details' : 'Uncommitted Changes' : 'Commit Comparison') + ' ...
'; - } else { - html += '
'; - if (expandedCommit.compareWithHash === null) { - // Commit details should be shown - if (expandedCommit.commitHash !== UNCOMMITTED) { - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - commits: true, - emoji: true, - issueLinking: true, - markdown: this.config.markdown, - multiline: true, - urls: true - }); - const commitDetails = expandedCommit.commitDetails!; - const parents = commitDetails.parents.length > 0 - ? commitDetails.parents.map((parent) => { - const escapedParent = escapeHtml(parent); - return typeof this.commitLookup[parent] === 'number' - ? '' + escapedParent + '' - : escapedParent; - }).join(', ') - : 'None'; - html += '' - + 'Commit: ' + escapeHtml(commitDetails.hash) + '
' - + 'Parents: ' + parents + '
' - + 'Author: ' + escapeHtml(commitDetails.author) + (commitDetails.authorEmail !== '' ? ' <' + escapeHtml(commitDetails.authorEmail) + '>' : '') + '
' - + (commitDetails.authorDate !== commitDetails.committerDate ? 'Author Date: ' + formatLongDate(commitDetails.authorDate) + '
' : '') - + 'Committer: ' + escapeHtml(commitDetails.committer) + (commitDetails.committerEmail !== '' ? ' <' + escapeHtml(commitDetails.committerEmail) + '>' : '') + (commitDetails.signature !== null ? generateSignatureHtml(commitDetails.signature) : '') + '
' - + '' + (commitDetails.authorDate !== commitDetails.committerDate ? 'Committer ' : '') + 'Date: ' + formatLongDate(commitDetails.committerDate) - + '
' - + (expandedCommit.avatar !== null ? '' : '') - + '


' + textFormatter.format(commitDetails.body); - } else { - html += 'Displaying all uncommitted changes.'; - } - } else { - // Commit comparison should be shown - html += 'Displaying all changes from ' + commitOrder.from + ' to ' + (commitOrder.to !== UNCOMMITTED ? commitOrder.to : 'Uncommitted Changes') + '.'; - } - html += '
' + (!isDocked ? '
' + SVG_ICONS.collapse + '
' : '') + '
' + generateFileViewHtml(expandedCommit.fileTree!, expandedCommit.fileChanges!, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, this.getFileViewType(), commitOrder.to === UNCOMMITTED) + '
'; - } - html += '
' + SVG_ICONS.close + '
' + - (codeReviewPossible ? '
' + SVG_ICONS.review + '
' : '') + - (!expandedCommit.loading ? '
' + SVG_ICONS.fileList + '
' + SVG_ICONS.fileTree + '
' + SVG_ICONS.collapseAll + '
' + SVG_ICONS.expandAll + '
' : '') + - (externalDiffPossible ? '
' + SVG_ICONS.linkExternal + '
' : '') + - '
'; - - elem.innerHTML = isDocked ? html : '
' + html + '
'; - this.setCdvDivider(); - this.setCdvHeight(elem, isDocked); - if (!isDocked) this.renderGraph(); - - if (!refresh) { - if (isDocked) { - let elemTop = this.controlsElem.clientHeight + expandedCommit.commitElem.offsetTop; - if (elemTop - 8 < this.viewElem.scrollTop) { - // Commit is above what is visible on screen - this.viewElem.scroll(0, elemTop - 8); - } else if (elemTop - this.viewElem.clientHeight + 32 > this.viewElem.scrollTop) { - // Commit is below what is visible on screen - this.viewElem.scroll(0, elemTop - this.viewElem.clientHeight + 32); - } - } else { - let elemTop = this.controlsElem.clientHeight + elem.offsetTop, cdvHeight = this.gitRepos[this.currentRepo].cdvHeight; - if (this.config.commitDetailsView.autoCenter) { - // Center Commit Detail View setting is enabled - // elemTop - commit height [24px] + (commit details view height + commit height [24px]) / 2 - (view height) / 2 - this.viewElem.scroll(0, elemTop - 12 + (cdvHeight - this.viewElem.clientHeight) / 2); - } else if (elemTop - 32 < this.viewElem.scrollTop) { - // Commit Detail View is opening above what is visible on screen - // elemTop - commit height [24px] - desired gap from top [8px] < view scroll offset - this.viewElem.scroll(0, elemTop - 32); - } else if (elemTop + cdvHeight - this.viewElem.clientHeight + 8 > this.viewElem.scrollTop) { - // Commit Detail View is opening below what is visible on screen - // elemTop + commit details view height + desired gap from bottom [8px] - view height > view scroll offset - this.viewElem.scroll(0, elemTop + cdvHeight - this.viewElem.clientHeight + 8); - } - } - } - - this.makeCdvResizable(); - document.getElementById('cdvClose')!.addEventListener('click', () => { - this.closeCommitDetails(true); - }); - - if (!expandedCommit.loading) { - this.makeCdvFileViewInteractive(); - this.renderCdvFileViewTypeBtns(); - this.renderCdvExternalDiffBtn(); - this.makeCdvDividerDraggable(); - - observeElemScroll('cdvSummary', expandedCommit.scrollTop.summary, (scrollTop) => { - if (this.expandedCommit === null) return; - this.expandedCommit.scrollTop.summary = scrollTop; - if (this.expandedCommit.contextMenuOpen.summary) { - this.expandedCommit.contextMenuOpen.summary = false; - contextMenu.close(); - } - }, () => this.saveState()); - - observeElemScroll('cdvFilesView', expandedCommit.scrollTop.fileView, (scrollTop) => { - if (this.expandedCommit === null) return; - this.expandedCommit.scrollTop.fileView = scrollTop; - if (this.expandedCommit.contextMenuOpen.fileView > -1) { - this.expandedCommit.contextMenuOpen.fileView = -1; - contextMenu.close(); - } - }, () => this.saveState()); - - document.getElementById('cdvFileViewTypeTree')!.addEventListener('click', () => { - this.changeFileViewType(GG.FileViewType.Tree); - }); - - document.getElementById('cdvFileViewTypeList')!.addEventListener('click', () => { - this.changeFileViewType(GG.FileViewType.List); - }); - document.getElementById('cdvCollapse')!.addEventListener('click', () => { - this.openFolders(false); - }); - document.getElementById('cdvExpand')!.addEventListener('click', () => { - this.openFolders(true); - }); - let cdvSummaryToggleBtn = document.getElementById('cdvSummaryToggleBtn'); - if (cdvSummaryToggleBtn !== null) cdvSummaryToggleBtn.addEventListener('click', () => { - this.gitRepos[this.currentRepo].isCdvSummaryHidden = !(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - this.saveRepoState(); - this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - }); - this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - - if (codeReviewPossible) { - this.renderCodeReviewBtn(); - document.getElementById('cdvCodeReview')!.addEventListener('click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || e.target === null) return; - let sourceElem = (e.target).closest('#cdvCodeReview')!; - if (sourceElem.classList.contains(CLASS_ACTIVE)) { - sendMessage({ command: 'endCodeReview', repo: this.currentRepo, id: expandedCommit.codeReview!.id }); - this.endCodeReview(); - } else { - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const id = expandedCommit.compareWithHash !== null ? commitOrder.from + '-' + commitOrder.to : expandedCommit.commitHash; - sendMessage({ - command: 'startCodeReview', - repo: this.currentRepo, - id: id, - commitHash: expandedCommit.commitHash, - compareWithHash: expandedCommit.compareWithHash, - files: getFilesInTree(expandedCommit.fileTree!, expandedCommit.fileChanges!), - lastViewedFile: expandedCommit.lastViewedFile - }); - } - }); - } - - if (externalDiffPossible) { - document.getElementById('cdvExternalDiff')!.addEventListener('click', () => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || this.gitConfig === null || (this.gitConfig.diffTool === null && this.gitConfig.guiDiffTool === null)) return; - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - runAction({ - command: 'openExternalDirDiff', - repo: this.currentRepo, - fromHash: commitOrder.from, - toHash: commitOrder.to, - isGui: this.gitConfig.guiDiffTool !== null - }, 'Opening External Directory Diff'); - }); - } - } - } - - private hideCdvSummary(hide: boolean) { - let btnIcon = document.getElementById('cdvSummaryToggleBtn')?.getElementsByTagName('svg')?.[0] ?? null; - let cdvSummary = document.getElementById('cdvSummary'); - if (hide && !this.isCdvDocked()) { - if (btnIcon) btnIcon.style.transform = 'rotate(90deg)'; - cdvSummary!.classList.add('hidden'); - } else { - if (btnIcon) btnIcon.style.transform = 'rotate(-90deg)'; - cdvSummary!.classList.remove('hidden'); - } - let elem = document.getElementById('cdv'); - if (elem !== null) this.setCdvHeight(elem, this.isCdvDocked()); - } - - private setCdvHeight(elem: HTMLElement, isDocked: boolean) { - let height = this.gitRepos[this.currentRepo].cdvHeight, windowHeight = window.innerHeight; - if (height > windowHeight - 40) { - height = Math.max(windowHeight - 40, 100); - if (height !== this.gitRepos[this.currentRepo].cdvHeight) { - this.gitRepos[this.currentRepo].cdvHeight = height; - this.saveRepoState(); - } - } - - let heightPx = height + 'px'; - if (isDocked) { - this.viewElem.style.bottom = heightPx; - elem.style.height = heightPx; - return; - } - let inlineElem = document.getElementById('cdvContentWrapper'); - if (!inlineElem) { - elem.style.height = heightPx; - return; - } - if (this.gitRepos[this.currentRepo].isCdvSummaryHidden) { - inlineElem.style.height = heightPx; - elem.style.height = '0px'; - } else { - inlineElem.style.removeProperty('height'); - elem.style.height = heightPx; - } - this.renderGraph(); - } - - private setCdvDivider() { - let percent = (this.gitRepos[this.currentRepo].cdvDivider * 100).toFixed(2) + '%'; - let summaryElem = document.getElementById('cdvSummary'), dividerElem = document.getElementById('cdvDivider'), filesElem = document.getElementById('cdvFiles'); - if (summaryElem !== null) summaryElem.style.width = percent; - if (dividerElem !== null) dividerElem.style.left = percent; - if (filesElem !== null) filesElem.style.left = percent; - } - - private makeCdvResizable() { - let prevY = -1; - - const processResizingCdvHeight: EventListener = (e) => { - if (prevY < 0) return; - let delta = (e).pageY - prevY, isDocked = this.isCdvDocked(), windowHeight = window.innerHeight; - prevY = (e).pageY; - let height = this.gitRepos[this.currentRepo].cdvHeight + (isDocked ? -delta : delta); - if (height < 100) height = 100; - else if (height > 600) height = 600; - if (height > windowHeight - 40) height = Math.max(windowHeight - 40, 100); - - if (this.gitRepos[this.currentRepo].cdvHeight !== height) { - this.gitRepos[this.currentRepo].cdvHeight = height; - let elem = document.getElementById('cdv'); - if (elem !== null) this.setCdvHeight(elem, isDocked); - if (!isDocked) this.renderGraph(); - } - }; - const stopResizingCdvHeight: EventListener = (e) => { - if (prevY < 0) return; - processResizingCdvHeight(e); - this.saveRepoState(); - prevY = -1; - eventOverlay.remove(); - }; - - addListenerToClass('cdvHeightResize', 'mousedown', (e) => { - prevY = (e).pageY; - eventOverlay.create('rowResize', processResizingCdvHeight, stopResizingCdvHeight); - }); - } - - private makeCdvDividerDraggable() { - let minX = -1, width = -1; - - const processDraggingCdvDivider: EventListener = (e) => { - if (minX < 0) return; - let percent = ((e).clientX - minX) / width; - if (percent < 0.2) percent = 0.2; - else if (percent > 0.8) percent = 0.8; - - if (this.gitRepos[this.currentRepo].cdvDivider !== percent) { - this.gitRepos[this.currentRepo].cdvDivider = percent; - this.setCdvDivider(); - } - }; - const stopDraggingCdvDivider: EventListener = (e) => { - if (minX < 0) return; - processDraggingCdvDivider(e); - this.saveRepoState(); - minX = -1; - eventOverlay.remove(); - }; - - document.getElementById('cdvDivider')!.addEventListener('mousedown', () => { - const contentElem = document.getElementById('cdvContent'); - if (contentElem === null) return; - - const bounds = contentElem.getBoundingClientRect(); - minX = bounds.left; - width = bounds.width; - eventOverlay.create('colResize', processDraggingCdvDivider, stopDraggingCdvDivider); - }); - } - - /** - * Updates the state of a file in the Commit Details View. - * @param file The file that was affected. - * @param fileElem The HTML Element of the file. - * @param isReviewed TRUE/FALSE => Set the files reviewed state accordingly, NULL => Don't update the files reviewed state. - * @param fileWasViewed Was the file viewed - if so, set it to be the last viewed file. - */ - private cdvUpdateFileState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean | null, fileWasViewed: boolean) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'), filePath = file.newFilePath; - if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; - - if (fileWasViewed) { - expandedCommit.lastViewedFile = filePath; - let lastViewedElem = document.getElementById('cdvLastFileViewed'); - if (lastViewedElem !== null) lastViewedElem.remove(); - lastViewedElem = document.createElement('span'); - lastViewedElem.id = 'cdvLastFileViewed'; - lastViewedElem.title = 'Last File Viewed'; - lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; - insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); - } - - if (expandedCommit.codeReview !== null) { - if (isReviewed !== null) { - if (isReviewed) { - expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); - } else { - expandedCommit.codeReview.remainingFiles.push(filePath); - } - - alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); - updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); - } - - sendMessage({ - command: 'updateCodeReview', - repo: this.currentRepo, - id: expandedCommit.codeReview.id, - remainingFiles: expandedCommit.codeReview.remainingFiles, - lastViewedFile: expandedCommit.lastViewedFile - }); - - if (expandedCommit.codeReview.remainingFiles.length === 0) { - expandedCommit.codeReview = null; - this.renderCodeReviewBtn(); - } - } - - this.saveState(); - } - - private isCdvDocked() { - return this.config.commitDetailsView.location === GG.CommitDetailsViewLocation.DockedToBottom; - } - - public isCdvOpen(commitHash: string, compareWithHash: string | null) { - return this.expandedCommit !== null && this.expandedCommit.commitHash === commitHash && this.expandedCommit.compareWithHash === compareWithHash; - } - - private getCommitOrder(hash1: string, hash2: string) { - if (this.commitLookup[hash1] > this.commitLookup[hash2]) { - return { from: hash1, to: hash2 }; - } else { - return { from: hash2, to: hash1 }; - } - } - - private getFileViewType() { - return this.gitRepos[this.currentRepo].fileViewType === GG.FileViewType.Default - ? this.config.commitDetailsView.fileViewType - : this.gitRepos[this.currentRepo].fileViewType; - } - - private setFileViewType(type: GG.FileViewType) { - this.gitRepos[this.currentRepo].fileViewType = type; - this.saveRepoState(); - } - - private changeFileViewType(type: GG.FileViewType) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'); - if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.fileChanges === null || filesElem === null) return; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - this.setFileViewType(type); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - filesElem.innerHTML = generateFileViewHtml(expandedCommit.fileTree, expandedCommit.fileChanges, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, type, commitOrder.to === UNCOMMITTED); - this.makeCdvFileViewInteractive(); - this.renderCdvFileViewTypeBtns(); - } - - private openFolders(open: boolean) { - let expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileTree === null) return; - let folders = document.getElementsByClassName('fileTreeFolder'); - for (let i = 0; i < folders.length; i++) { - let sourceElem = (folders[i]); - let parent = sourceElem.parentElement!; - if (open) { - parent.classList.remove('closed'); - sourceElem.children[0].children[0].innerHTML = SVG_ICONS.openFolder; - parent.children[1].classList.remove('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), true); - - } else { - parent.classList.add('closed'); - sourceElem.children[0].children[0].innerHTML = SVG_ICONS.closedFolder; - parent.children[1].classList.add('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), false); - } - } - this.saveState(); - } - - private makeCdvFileViewInteractive() { - const getFileElemOfEventTarget = (target: EventTarget) => (target).closest('.fileTreeFileRecord'); - const getFileOfFileElem = (fileChanges: ReadonlyArray, fileElem: HTMLElement) => fileChanges[parseInt(fileElem.dataset.index!)]; - - const getCommitHashForFile = (file: GG.GitFileChange, expandedCommit: ExpandedCommit) => { - const commit = this.commits[this.commitLookup[expandedCommit.commitHash]]; - if (expandedCommit.compareWithHash !== null) { - return this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; - } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { - return commit.stash.untrackedFilesHash!; - } else { - return expandedCommit.commitHash; - } - }; - - const triggerViewFileDiff = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - let commit = this.commits[this.commitLookup[expandedCommit.commitHash]], fromHash: string, toHash: string, fileStatus = file.type; - if (expandedCommit.compareWithHash !== null) { - // Commit Comparison - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash); - fromHash = commitOrder.from; - toHash = commitOrder.to; - } else if (commit.stash !== null) { - // Stash Commit - if (fileStatus === GG.GitFileStatus.Untracked) { - fromHash = commit.stash.untrackedFilesHash!; - toHash = commit.stash.untrackedFilesHash!; - fileStatus = GG.GitFileStatus.Added; - } else { - fromHash = commit.stash.baseHash; - toHash = expandedCommit.commitHash; - } - } else { - // Single Commit - fromHash = expandedCommit.commitHash; - toHash = expandedCommit.commitHash; - } - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ - command: 'viewDiff', - repo: this.currentRepo, - fromHash: fromHash, - toHash: toHash, - oldFilePath: file.oldFilePath, - newFilePath: file.newFilePath, - type: fileStatus - }); - }; - - const triggerCopyFilePath = (file: GG.GitFileChange, absolute: boolean) => { - sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath, absolute: absolute }); - }; - - const triggerResetFileToRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - const commitHash = getCommitHashForFile(file, expandedCommit); - dialog.showConfirmation('Are you sure you want to reset ' + escapeHtml(file.newFilePath) + ' to it\'s state at commit ' + abbrevCommit(commitHash) + '? Any uncommitted changes made to this file will be overwritten.', 'Yes, reset file', () => { - runAction({ command: 'resetFileToRevision', repo: this.currentRepo, commitHash: commitHash, filePath: file.newFilePath }, 'Resetting file'); - }, { - type: TargetType.CommitDetailsView, - hash: commitHash, - elem: fileElem - }); - }; - - const triggerViewFileAtRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, null, true); - sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - addListenerToClass('fileTreeFolder', 'click', (e) => { - let expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileTree === null || e.target === null) return; - - let sourceElem = (e.target).closest('.fileTreeFolder'); - let parent = sourceElem.parentElement!; - parent.classList.toggle('closed'); - let isOpen = !parent.classList.contains('closed'); - parent.children[0].children[0].innerHTML = isOpen ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder; - parent.children[1].classList.toggle('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), isOpen); - this.saveState(); - }); - - addListenerToClass('fileTreeRepo', 'click', (e) => { - if (e.target === null) return; - this.loadRepos(this.gitRepos, null, { - repo: decodeURIComponent(((e.target).closest('.fileTreeRepo')).dataset.path!) - }); - }); - - addListenerToClass('fileTreeFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const sourceElem = (e.target).closest('.fileTreeFile'), fileElem = getFileElemOfEventTarget(e.target); - if (!sourceElem.classList.contains('gitDiffPossible')) return; - triggerViewFileDiff(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('copyGitFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem), true); - }); - - addListenerToClass('viewGitFileAtRevision', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerViewFileAtRevision(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('openGitFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerOpenFile(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('fileTreeFileRecord', 'contextmenu', (e: Event) => { - handledEvent(e); - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - const fileElem = getFileElemOfEventTarget(e.target); - const file = getFileOfFileElem(expandedCommit.fileChanges, fileElem); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const isUncommitted = commitOrder.to === UNCOMMITTED; - - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - expandedCommit.contextMenuOpen.fileView = parseInt(fileElem.dataset.index!); - - const target: ContextMenuTarget & CommitTarget = { - type: TargetType.CommitDetailsView, - hash: expandedCommit.commitHash, - index: this.commitLookup[expandedCommit.commitHash], - elem: fileElem - }; - const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); - const fileExistsAtThisRevision = file.type !== GG.GitFileStatus.Deleted && !isUncommitted; - const fileExistsAtThisRevisionAndDiffPossible = fileExistsAtThisRevision && diffPossible; - const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); - const visibility = this.config.contextMenuActionsVisibility.commitDetailsViewFile; - - contextMenu.show([ - [ - { - title: 'View Diff', - visible: visibility.viewDiff && diffPossible, - onClick: () => triggerViewFileDiff(file, fileElem) - }, - { - title: 'View File at this Revision', - visible: visibility.viewFileAtThisRevision && fileExistsAtThisRevisionAndDiffPossible, - onClick: () => triggerViewFileAtRevision(file, fileElem) - }, - { - title: 'View Diff with Working File', - visible: visibility.viewDiffWithWorkingFile && fileExistsAtThisRevisionAndDiffPossible, - onClick: () => triggerViewFileDiffWithWorkingFile(file, fileElem) - }, - { - title: 'Open File', - visible: visibility.openFile && file.type !== GG.GitFileStatus.Deleted, - onClick: () => triggerOpenFile(file, fileElem) - } - ], - [ - { - title: 'Mark as Reviewed', - visible: visibility.markAsReviewed && codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) - }, - { - title: 'Mark as Not Reviewed', - visible: visibility.markAsNotReviewed && expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) - } - ], - [ - { - title: 'Reset File to this Revision' + ELLIPSIS, - visible: visibility.resetFileToThisRevision && fileExistsAtThisRevision && expandedCommit.compareWithHash === null, - onClick: () => triggerResetFileToRevision(file, fileElem) - } - ], - [ - { - title: 'Copy Absolute File Path to Clipboard', - visible: visibility.copyAbsoluteFilePath, - onClick: () => triggerCopyFilePath(file, true) - }, - { - title: 'Copy Relative File Path to Clipboard', - visible: visibility.copyRelativeFilePath, - onClick: () => triggerCopyFilePath(file, false) - } - ] - ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { - expandedCommit.contextMenuOpen.fileView = -1; - }); - }); - } - - private renderCdvFileViewTypeBtns() { - if (this.expandedCommit === null) return; - let treeBtnElem = document.getElementById('cdvFileViewTypeTree'), listBtnElem = document.getElementById('cdvFileViewTypeList'); - if (treeBtnElem === null || listBtnElem === null) return; - - let listView = this.getFileViewType() === GG.FileViewType.List; - alterClass(treeBtnElem, CLASS_ACTIVE, !listView); - alterClass(listBtnElem, CLASS_ACTIVE, listView); - setFolderBtns(); - function setFolderBtns() { - let btns = document.getElementsByClassName('cdvFolderBtn'); - for (let i = 0; i < btns.length; i++) { - if (listView) - btns[i].classList.add('hidden'); - else - btns[i].classList.remove('hidden'); - } - } - } - - private renderCdvExternalDiffBtn() { - if (this.expandedCommit === null) return; - const externalDiffBtnElem = document.getElementById('cdvExternalDiff'); - if (externalDiffBtnElem === null) return; - - alterClass(externalDiffBtnElem, CLASS_ENABLED, this.gitConfig !== null && (this.gitConfig.diffTool !== null || this.gitConfig.guiDiffTool !== null)); - const toolName = this.gitConfig !== null - ? this.gitConfig.guiDiffTool !== null - ? this.gitConfig.guiDiffTool - : this.gitConfig.diffTool - : null; - externalDiffBtnElem.title = 'Open External Directory Diff' + (toolName !== null ? ' with "' + toolName + '"' : ''); - } - - private static closeCdvContextMenuIfOpen(expandedCommit: ExpandedCommit) { - if (expandedCommit.contextMenuOpen.summary || expandedCommit.contextMenuOpen.fileView > -1) { - expandedCommit.contextMenuOpen.summary = false; - expandedCommit.contextMenuOpen.fileView = -1; - contextMenu.close(); - } - } - - - /* Code Review */ - - public startCodeReview(commitHash: string, compareWithHash: string | null, codeReview: GG.CodeReview) { - if (this.expandedCommit === null || this.expandedCommit.commitHash !== commitHash || this.expandedCommit.compareWithHash !== compareWithHash) return; - this.saveAndRenderCodeReview(codeReview); - } - - public endCodeReview() { - if (this.expandedCommit === null || this.expandedCommit.codeReview === null) return; - this.saveAndRenderCodeReview(null); - } - - private saveAndRenderCodeReview(codeReview: GG.CodeReview | null) { - let filesElem = document.getElementById('cdvFilesView'); - if (this.expandedCommit === null || this.expandedCommit.fileTree === null || filesElem === null) return; - - this.expandedCommit.codeReview = codeReview; - setFileTreeReviewed(this.expandedCommit.fileTree, codeReview === null); - this.saveState(); - this.renderCodeReviewBtn(); - updateFileTreeHtml(filesElem, this.expandedCommit.fileTree); - } - - private renderCodeReviewBtn() { - if (this.expandedCommit === null) return; - let btnElem = document.getElementById('cdvCodeReview'); - if (btnElem === null) return; - - let active = this.expandedCommit.codeReview !== null; - alterClass(btnElem, CLASS_ACTIVE, active); - btnElem.title = (active ? 'End' : 'Start') + ' Code Review'; - } -} - - -/* Main */ - -const contextMenu = new ContextMenu(), dialog = new Dialog(), eventOverlay = new EventOverlay(); -let loaded = false; - -window.addEventListener('load', () => { - if (loaded) return; - loaded = true; - - TextFormatter.registerCustomEmojiMappings(initialState.config.customEmojiShortcodeMappings); - - const viewElem = document.getElementById('view'); - if (viewElem === null) return; - - const gitGraph = new GitGraphView(viewElem, VSCODE_API.getState()); - const imageResizer = new ImageResizer(); - - /* Command Processing */ - window.addEventListener('message', event => { - const msg: GG.ResponseMessage = event.data; - switch (msg.command) { - case 'addRemote': - refreshOrDisplayError(msg.error, 'Unable to Add Remote', true); - break; - case 'addTag': - if (msg.pushToRemote !== null && msg.errors.length === 2 && msg.errors[0] === null && isExtensionErrorInfo(msg.errors[1], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { - gitGraph.refresh(false); - handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.errors[1]!); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Add Tag'); - } - break; - case 'applyStash': - refreshOrDisplayError(msg.error, 'Unable to Apply Stash'); - break; - case 'branchFromStash': - refreshOrDisplayError(msg.error, 'Unable to Create Branch from Stash'); - break; - case 'checkoutBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Checkout Branch' + (msg.pullAfterwards !== null ? ' & Pull Changes' : '')); - break; - case 'checkoutCommit': - refreshOrDisplayError(msg.error, 'Unable to Checkout Commit'); - break; - case 'cherrypickCommit': - refreshAndDisplayErrors(msg.errors, 'Unable to Cherry Pick Commit'); - break; - case 'cleanUntrackedFiles': - refreshOrDisplayError(msg.error, 'Unable to Clean Untracked Files'); - break; - case 'commitDetails': - if (msg.commitDetails !== null) { - gitGraph.showCommitDetails(msg.commitDetails, gitGraph.createFileTree(msg.commitDetails.fileChanges, msg.codeReview), msg.avatar, msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); - } else { - gitGraph.closeCommitDetails(true); - dialog.showError('Unable to load Commit Details', msg.error, null, null); - } - break; - case 'compareCommits': - if (msg.error === null) { - gitGraph.showCommitComparison(msg.commitHash, msg.compareWithHash, msg.fileChanges, gitGraph.createFileTree(msg.fileChanges, msg.codeReview), msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); - } else { - gitGraph.closeCommitComparison(true); - dialog.showError('Unable to load Commit Comparison', msg.error, null, null); - } - break; - case 'copyFilePath': - finishOrDisplayError(msg.error, 'Unable to Copy File Path to Clipboard'); - break; - case 'copyToClipboard': - finishOrDisplayError(msg.error, 'Unable to Copy ' + msg.type + ' to Clipboard'); - break; - case 'createArchive': - finishOrDisplayError(msg.error, 'Unable to Create Archive', true); - break; - case 'createBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Create Branch'); - break; - case 'createPullRequest': - finishOrDisplayErrors(msg.errors, 'Unable to Create Pull Request', () => { - if (msg.push) { - gitGraph.refresh(false); - } - }, true); - break; - case 'deleteBranch': - handleResponseDeleteBranch(msg); - break; - case 'deleteRemote': - refreshOrDisplayError(msg.error, 'Unable to Delete Remote', true); - break; - case 'deleteRemoteBranch': - refreshOrDisplayError(msg.error, 'Unable to Delete Remote Branch'); - break; - case 'deleteTag': - refreshOrDisplayError(msg.error, 'Unable to Delete Tag'); - break; - case 'deleteUserDetails': - finishOrDisplayErrors(msg.errors, 'Unable to Remove Git User Details', () => gitGraph.requestLoadConfig(), true); - break; - case 'dropCommit': - refreshOrDisplayError(msg.error, 'Unable to Drop Commit'); - break; - case 'dropStash': - refreshOrDisplayError(msg.error, 'Unable to Drop Stash'); - break; - case 'editRemote': - refreshOrDisplayError(msg.error, 'Unable to Save Changes to Remote', true); - break; - case 'editUserDetails': - finishOrDisplayErrors(msg.errors, 'Unable to Save Git User Details', () => gitGraph.requestLoadConfig(), true); - break; - case 'exportRepoConfig': - refreshOrDisplayError(msg.error, 'Unable to Export Repository Configuration'); - break; - case 'fetch': - refreshOrDisplayError(msg.error, 'Unable to Fetch from Remote(s)'); - break; - case 'fetchAvatar': - imageResizer.resize(msg.image, (resizedImage) => { - gitGraph.loadAvatar(msg.email, resizedImage); - }); - break; - case 'fetchIntoLocalBranch': - refreshOrDisplayError(msg.error, 'Unable to Fetch into Local Branch'); - break; - case 'loadCommits': - gitGraph.processLoadCommitsResponse(msg); - break; - case 'loadConfig': - gitGraph.processLoadConfig(msg); - break; - case 'loadRepoInfo': - gitGraph.processLoadRepoInfoResponse(msg); - break; - case 'loadRepos': - gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); - break; - case 'merge': - refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); - break; - case 'openExtensionSettings': - finishOrDisplayError(msg.error, 'Unable to Open Extension Settings'); - break; - case 'openExternalDirDiff': - finishOrDisplayError(msg.error, 'Unable to Open External Directory Diff', true); - break; - case 'openExternalUrl': - finishOrDisplayError(msg.error, 'Unable to Open External URL'); - break; - case 'openFile': - finishOrDisplayError(msg.error, 'Unable to Open File'); - break; - case 'openTerminal': - finishOrDisplayError(msg.error, 'Unable to Open Terminal', true); - break; - case 'popStash': - refreshOrDisplayError(msg.error, 'Unable to Pop Stash'); - break; - case 'pruneRemote': - refreshOrDisplayError(msg.error, 'Unable to Prune Remote'); - break; - case 'pullBranch': - refreshOrDisplayError(msg.error, 'Unable to Pull Branch'); - break; - case 'pushBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Push Branch', msg.willUpdateBranchConfig); - break; - case 'pushStash': - refreshOrDisplayError(msg.error, 'Unable to Stash Uncommitted Changes'); - break; - case 'pushTag': - if (msg.errors.length === 1 && isExtensionErrorInfo(msg.errors[0], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { - handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.errors[0]!); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Push Tag'); - } - break; - case 'rebase': - if (msg.error === null) { - if (msg.interactive) { - dialog.closeActionRunning(); - } else { - gitGraph.refresh(false); - } - } else { - dialog.showError('Unable to Rebase current branch on ' + msg.actionOn, msg.error, null, null); - } - break; - case 'refresh': - gitGraph.refresh(false); - break; - case 'renameBranch': - refreshOrDisplayError(msg.error, 'Unable to Rename Branch'); - break; - case 'resetFileToRevision': - refreshOrDisplayError(msg.error, 'Unable to Reset File to Revision'); - break; - case 'resetToCommit': - refreshOrDisplayError(msg.error, 'Unable to Reset to Commit'); - break; - case 'revertCommit': - refreshOrDisplayError(msg.error, 'Unable to Revert Commit'); - break; - case 'setGlobalViewState': - finishOrDisplayError(msg.error, 'Unable to save the Global View State'); - break; - case 'setWorkspaceViewState': - finishOrDisplayError(msg.error, 'Unable to save the Workspace View State'); - break; - case 'startCodeReview': - if (msg.error === null) { - gitGraph.startCodeReview(msg.commitHash, msg.compareWithHash, msg.codeReview); - } else { - dialog.showError('Unable to Start Code Review', msg.error, null, null); - } - break; - case 'tagDetails': - if (msg.details !== null) { - gitGraph.renderTagDetails(msg.tagName, msg.commitHash, msg.details); - } else { - dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); - } - break; - case 'updateCodeReview': - if (msg.error !== null) { - dialog.showError('Unable to update Code Review', msg.error, null, null); - } - break; - case 'viewDiff': - finishOrDisplayError(msg.error, 'Unable to View Diff'); - break; - case 'viewDiffWithWorkingFile': - finishOrDisplayError(msg.error, 'Unable to View Diff with Working File'); - break; - case 'viewFileAtRevision': - finishOrDisplayError(msg.error, 'Unable to View File at Revision'); - break; - case 'viewScm': - finishOrDisplayError(msg.error, 'Unable to open the Source Control View'); - break; - } - }); - - function handleResponseDeleteBranch(msg: GG.ResponseDeleteBranch) { - if (msg.errors.length > 0 && msg.errors[0] !== null && msg.errors[0].includes('git branch -D')) { - dialog.showConfirmation('The branch ' + escapeHtml(msg.branchName) + ' is not fully merged. Would you like to force delete it?', 'Yes, force delete branch', () => { - runAction({ command: 'deleteBranch', repo: msg.repo, branchName: msg.branchName, forceDelete: true, deleteOnRemotes: msg.deleteOnRemotes }, 'Deleting Branch'); - }, { type: TargetType.Repo }); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Delete Branch'); - } - } - - function handleResponsePushTagCommitNotOnRemote(repo: string, tagName: string, remotes: string[], commitHash: string, error: string) { - const remotesNotContainingCommit: string[] = parseExtensionErrorInfo(error, GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote); - - const html = '' + SVG_ICONS.alert + 'Warning: Commit is not on Remote' + (remotesNotContainingCommit.length > 1 ? 's ' : ' ') + '
' + - '' + - '

The tag ' + escapeHtml(tagName) + ' is on a commit that isn\'t on any known branch on the remote' + (remotesNotContainingCommit.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotesNotContainingCommit.map((remote) => '' + escapeHtml(remote) + '')) + '.

' + - '

Would you like to proceed to push the tag to the remote' + (remotes.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotes.map((remote) => '' + escapeHtml(remote) + '')) + ' anyway?

' + - '
'; - - dialog.showForm(html, [{ type: DialogInputType.Checkbox, name: 'Always Proceed', value: false }], 'Proceed to Push', (values) => { - if (values[0]) { - updateGlobalViewState('pushTagSkipRemoteCheck', true); - } - runAction({ - command: 'pushTag', - repo: repo, - tagName: tagName, - remotes: remotes, - commitHash: commitHash, - skipRemoteCheck: true - }, 'Pushing Tag'); - }, { type: TargetType.Repo }, 'Cancel', null, true); - } - - function refreshOrDisplayError(error: GG.ErrorInfo, errorMessage: string, configChanges: boolean = false) { - if (error === null) { - gitGraph.refresh(false, configChanges); - } else { - dialog.showError(errorMessage, error, null, null); - } - } - - function refreshAndDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, configChanges: boolean = false) { - const reducedErrors = reduceErrorInfos(errors); - if (reducedErrors.error !== null) { - dialog.showError(errorMessage, reducedErrors.error, null, null); - } - if (reducedErrors.partialOrCompleteSuccess) { - gitGraph.refresh(false, configChanges); - } else if (configChanges) { - gitGraph.requestLoadConfig(); - } - } - - function finishOrDisplayError(error: GG.ErrorInfo, errorMessage: string, dismissActionRunning: boolean = false) { - if (error !== null) { - dialog.showError(errorMessage, error, null, null); - } else if (dismissActionRunning) { - dialog.closeActionRunning(); - } - } - - function finishOrDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, partialOrCompleteSuccessCallback: () => void, dismissActionRunning: boolean = false) { - const reducedErrors = reduceErrorInfos(errors); - finishOrDisplayError(reducedErrors.error, errorMessage, dismissActionRunning); - if (reducedErrors.partialOrCompleteSuccess) { - partialOrCompleteSuccessCallback(); - } - } - - function reduceErrorInfos(errors: GG.ErrorInfo[]) { - let error: GG.ErrorInfo = null, partialOrCompleteSuccess = false; - for (let i = 0; i < errors.length; i++) { - if (errors[i] !== null) { - error = error !== null ? error + '\n\n' + errors[i] : errors[i]; - } else { - partialOrCompleteSuccess = true; - } - } - - return { - error: error, - partialOrCompleteSuccess: partialOrCompleteSuccess - }; - } - - /** - * Checks whether the given ErrorInfo has an ErrorInfoExtensionPrefix. - * @param error The ErrorInfo to check. - * @param prefix The ErrorInfoExtensionPrefix to test. - * @returns TRUE => ErrorInfo has the ErrorInfoExtensionPrefix, FALSE => ErrorInfo doesn\'t have the ErrorInfoExtensionPrefix - */ - function isExtensionErrorInfo(error: GG.ErrorInfo, prefix: GG.ErrorInfoExtensionPrefix) { - return error !== null && error.startsWith(prefix); - } - - /** - * Parses the JSON data from an ErrorInfo prefixed by the provided ErrorInfoExtensionPrefix. - * @param error The ErrorInfo to parse. - * @param prefix The ErrorInfoExtensionPrefix used by `error`. - * @returns The parsed JSON data. - */ - function parseExtensionErrorInfo(error: string, prefix: GG.ErrorInfoExtensionPrefix) { - return JSON.parse(error.substring(prefix.length)); - } -}); - - -/* File Tree Methods (for the Commit Details & Comparison Views) */ - -function generateFileViewHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, type: GG.FileViewType, isUncommitted: boolean) { - return type === GG.FileViewType.List - ? generateFileListHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted) - : generateFileTreeHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, true); -} - -function generateFileTreeHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean, topLevelFolder: boolean): string { - const curFolderInfo = topLevelFolder || !initialState.config.commitDetailsView.fileTreeCompactFolders - ? { folder: folder, name: folder.name, pathSeg: folder.name } - : getCurrentFolderInfo(folder, folder.name, folder.name); - - const children = sortFolderKeys(curFolderInfo.folder).map((key) => { - const cur = curFolderInfo.folder.contents[key]; - return cur.type === 'folder' - ? generateFileTreeHtml(cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, false) - : generateFileTreeLeafHtml(cur.name, cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); - }); - - return (topLevelFolder ? '' : '' + (curFolderInfo.folder.open ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder) + '' + escapeHtml(curFolderInfo.name) + '') + - '
    ' + children.join('') + '
' + - (topLevelFolder ? '' : ''); -} - -function getCurrentFolderInfo(folder: FileTreeFolder, name: string, pathSeg: string): { folder: FileTreeFolder, name: string, pathSeg: string } { - const keys = Object.keys(folder.contents); - let child: FileTreeNode; - return keys.length === 1 && (child = folder.contents[keys[0]]).type === 'folder' - ? getCurrentFolderInfo(child, name + ' / ' + child.name, pathSeg + '/' + child.name) - : { folder: folder, name: name, pathSeg: pathSeg }; -} - -function generateFileListHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { - const sortLeaves = (folder: FileTreeFolder, folderPath: string) => { - let keys = sortFolderKeys(folder); - let items: { relPath: string, leaf: FileTreeLeaf }[] = []; - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - let relPath = (folderPath !== '' ? folderPath + '/' : '') + cur.name; - if (cur.type === 'folder') { - items = items.concat(sortLeaves(cur, relPath)); - } else { - items.push({ relPath: relPath, leaf: cur }); - } - } - return items; - }; - let sortedLeaves = sortLeaves(folder, ''); - let html = ''; - for (let i = 0; i < sortedLeaves.length; i++) { - html += generateFileTreeLeafHtml(sortedLeaves[i].relPath, sortedLeaves[i].leaf, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); - } - return '
    ' + html + '
'; -} - -function generateFileTreeLeafHtml(name: string, leaf: FileTreeLeaf, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { - let encodedName = encodeURIComponent(name), escapedName = escapeHtml(name); - if (leaf.type === 'file') { - const fileTreeFile = gitFiles[leaf.index]; - const textFile = fileTreeFile.additions !== null && fileTreeFile.deletions !== null; - const diffPossible = fileTreeFile.type === GG.GitFileStatus.Untracked || textFile; - const changeTypeMessage = GIT_FILE_CHANGE_TYPES[fileTreeFile.type] + (fileTreeFile.type === GG.GitFileStatus.Renamed ? ' (' + escapeHtml(fileTreeFile.oldFilePath) + ' → ' + escapeHtml(fileTreeFile.newFilePath) + ')' : ''); - return '
  • ' + SVG_ICONS.file + '' + escapedName + '' + - (initialState.config.enhancedAccessibility ? '' + fileTreeFile.type + '' : '') + - (fileTreeFile.type !== GG.GitFileStatus.Added && fileTreeFile.type !== GG.GitFileStatus.Untracked && fileTreeFile.type !== GG.GitFileStatus.Deleted && textFile ? '(+' + fileTreeFile.additions + '|-' + fileTreeFile.deletions + ')' : '') + - (fileTreeFile.newFilePath === lastViewedFile ? '' + SVG_ICONS.eyeOpen + '' : '') + - '' + SVG_ICONS.copy + '' + - (fileTreeFile.type !== GG.GitFileStatus.Deleted - ? (diffPossible && !isUncommitted ? '' + SVG_ICONS.commit + '' : '') + - '' + SVG_ICONS.openFile + '' - : '' - ) + '
  • '; - } else { - return '
  • ' + SVG_ICONS.closedFolder + '' + escapedName + '
  • '; - } -} - -function alterFileTreeFolderOpen(folder: FileTreeFolder, folderPath: string, open: boolean) { - let path = folderPath.split('/'), i, cur = folder; - for (i = 0; i < path.length; i++) { - if (typeof cur.contents[path[i]] !== 'undefined') { - cur = cur.contents[path[i]]; - if (i === path.length - 1) cur.open = open; - } else { - return; - } - } -} - -function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string, reviewed: boolean) { - let path = filePath.split('/'), i, cur = folder, folders = [folder]; - for (i = 0; i < path.length; i++) { - if (typeof cur.contents[path[i]] !== 'undefined') { - if (i < path.length - 1) { - cur = cur.contents[path[i]]; - folders.push(cur); - } else { - (cur.contents[path[i]]).reviewed = reviewed; - } - } else { - break; - } - } - - // Recalculate whether each of the folders leading to the file are now reviewed (deepest first). - for (i = folders.length - 1; i >= 0; i--) { - let keys = Object.keys(folders[i].contents), entireFolderReviewed = true; - for (let j = 0; j < keys.length; j++) { - let cur = folders[i].contents[keys[j]]; - if ((cur.type === 'folder' || cur.type === 'file') && !cur.reviewed) { - entireFolderReviewed = false; - break; - } - } - folders[i].reviewed = entireFolderReviewed; - } -} - -function setFileTreeReviewed(folder: FileTreeFolder, reviewed: boolean) { - folder.reviewed = reviewed; - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if (cur.type === 'folder') { - setFileTreeReviewed(cur, reviewed); - } else if (cur.type === 'file') { - cur.reviewed = reviewed; - } - } -} - -function calcFileTreeFoldersReviewed(folder: FileTreeFolder) { - const calc = (folder: FileTreeFolder) => { - let reviewed = true; - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if ((cur.type === 'folder' && !calc(cur)) || (cur.type === 'file' && !cur.reviewed)) reviewed = false; - } - folder.reviewed = reviewed; - return reviewed; - }; - calc(folder); -} - -function updateFileTreeHtml(elem: HTMLElement, folder: FileTreeFolder) { - let ul = getChildUl(elem); - if (ul === null) return; - - for (let i = 0; i < ul.children.length; i++) { - let li = ul.children[i]; - let pathSeg = decodeURIComponent(li.dataset.pathseg!); - let child = getChildByPathSegment(folder, pathSeg); - if (child.type === 'folder') { - alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); - updateFileTreeHtml(li, child); - } else if (child.type === 'file') { - alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); - } - } -} - -function updateFileTreeHtmlFileReviewed(elem: HTMLElement, folder: FileTreeFolder, filePath: string) { - let path = filePath; - const update = (elem: HTMLElement, folder: FileTreeFolder) => { - let ul = getChildUl(elem); - if (ul === null) return; - - for (let i = 0; i < ul.children.length; i++) { - let li = ul.children[i]; - let pathSeg = decodeURIComponent(li.dataset.pathseg!); - if (path === pathSeg || path.startsWith(pathSeg + '/')) { - let child = getChildByPathSegment(folder, pathSeg); - if (child.type === 'folder') { - alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); - path = path.substring(pathSeg.length + 1); - update(li, child); - } else if (child.type === 'file') { - alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); - } - break; - } - } - }; - update(elem, folder); -} - -function getFilesInTree(folder: FileTreeFolder, gitFiles: ReadonlyArray) { - let files: string[] = []; - const scanFolder = (folder: FileTreeFolder) => { - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if (cur.type === 'folder') { - scanFolder(cur); - } else if (cur.type === 'file') { - files.push(gitFiles[cur.index].newFilePath); - } - } - }; - scanFolder(folder); - return files; -} - -function sortFolderKeys(folder: FileTreeFolder) { - let keys = Object.keys(folder.contents); - keys.sort((a, b) => folder.contents[a].type !== 'file' && folder.contents[b].type === 'file' ? -1 : folder.contents[a].type === 'file' && folder.contents[b].type !== 'file' ? 1 : folder.contents[a].name.localeCompare(folder.contents[b].name)); - return keys; -} - -function getChildByPathSegment(folder: FileTreeFolder, pathSeg: string) { - let cur: FileTreeNode = folder, comps = pathSeg.split('/'); - for (let i = 0; i < comps.length; i++) { - cur = (cur).contents[comps[i]]; - } - return cur; -} - - -/* Repository State Helpers */ - -function getCommitOrdering(repoValue: GG.RepoCommitOrdering): GG.CommitOrdering { - switch (repoValue) { - case GG.RepoCommitOrdering.Default: - return initialState.config.commitOrdering; - case GG.RepoCommitOrdering.Date: - return GG.CommitOrdering.Date; - case GG.RepoCommitOrdering.AuthorDate: - return GG.CommitOrdering.AuthorDate; - case GG.RepoCommitOrdering.Topological: - return GG.CommitOrdering.Topological; - } -} - -function getShowRemoteBranches(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showRemoteBranches - : repoValue === GG.BooleanOverride.Enabled; -} - -function getSimplifyByDecoration(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.simplifyByDecoration - : repoValue === GG.BooleanOverride.Enabled; -} - -function getShowStashes(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showStashes - : repoValue === GG.BooleanOverride.Enabled; -} - -function getShowTags(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showTags - : repoValue === GG.BooleanOverride.Enabled; -} - -function getIncludeCommitsMentionedByReflogs(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.includeCommitsMentionedByReflogs - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnlyFollowFirstParent(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.onlyFollowFirstParent - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnRepoLoadShowCheckedOutBranch(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.onRepoLoad.showCheckedOutBranch - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnRepoLoadShowSpecificBranches(repoValue: string[] | null) { - return repoValue === null - ? initialState.config.onRepoLoad.showSpecificBranches - : repoValue; -} - - -/* Miscellaneous Helper Methods */ - -function haveFilesChanged(oldFiles: ReadonlyArray | null, newFiles: ReadonlyArray | null) { - if ((oldFiles === null) !== (newFiles === null)) { - return true; - } else if (oldFiles === null && newFiles === null) { - return false; - } else { - return !arraysEqual(oldFiles!, newFiles!, (a, b) => a.additions === b.additions && a.deletions === b.deletions && a.newFilePath === b.newFilePath && a.oldFilePath === b.oldFilePath && a.type === b.type); - } -} - -function abbrevCommit(commitHash: string) { - return commitHash.substring(0, 8); -} - -function getRepoDropdownOptions(repos: Readonly) { - const repoPaths = getSortedRepositoryPaths(repos, initialState.config.repoDropdownOrder); - const paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; - const resolveAmbiguous = (indexes: number[]) => { - // Find ambiguous names within indexes - let firstOccurrence: { [name: string]: number } = {}, ambiguous: { [name: string]: number[] } = {}; - for (let i = 0; i < indexes.length; i++) { - let name = distinctNames[indexes[i]]; - if (typeof firstOccurrence[name] === 'number') { - // name is ambiguous - if (typeof ambiguous[name] === 'undefined') { - // initialise ambiguous array with the first occurrence - ambiguous[name] = [firstOccurrence[name]]; - } - ambiguous[name].push(indexes[i]); // append current ambiguous index - } else { - firstOccurrence[name] = indexes[i]; // set the first occurrence of the name - } - } - - let ambiguousNames = Object.keys(ambiguous); - for (let i = 0; i < ambiguousNames.length; i++) { - // For each ambiguous name, resolve the ambiguous indexes - let ambiguousIndexes = ambiguous[ambiguousNames[i]], retestIndexes = []; - for (let j = 0; j < ambiguousIndexes.length; j++) { - let ambiguousIndex = ambiguousIndexes[j]; - let nextSep = paths[ambiguousIndex].lastIndexOf('/', paths[ambiguousIndex].length - distinctNames[ambiguousIndex].length - 2); - if (firstSep[ambiguousIndex] < nextSep) { - // prepend the addition path and retest - distinctNames[ambiguousIndex] = paths[ambiguousIndex].substring(nextSep + 1); - retestIndexes.push(ambiguousIndex); - } else { - distinctNames[ambiguousIndex] = paths[ambiguousIndex]; - } - } - if (retestIndexes.length > 1) { - // If there are 2 or more indexes that may be ambiguous - resolveAmbiguous(retestIndexes); - } - } - }; - - // Initialise recursion - const indexes = []; - for (let i = 0; i < repoPaths.length; i++) { - firstSep.push(repoPaths[i].indexOf('/')); - const repo = repos[repoPaths[i]]; - if (repo.name) { - // A name has been set for the repository - paths.push(repoPaths[i]); - names.push(repo.name); - distinctNames.push(repo.name); - } else if (firstSep[i] === repoPaths[i].length - 1 || firstSep[i] === -1) { - // Path has no slashes, or a single trailing slash ==> use the path as the name - paths.push(repoPaths[i]); - names.push(repoPaths[i]); - distinctNames.push(repoPaths[i]); - } else { - paths.push(repoPaths[i].endsWith('/') ? repoPaths[i].substring(0, repoPaths[i].length - 1) : repoPaths[i]); // Remove trailing slash if it exists - let name = paths[i].substring(paths[i].lastIndexOf('/') + 1); - names.push(name); - distinctNames.push(name); - indexes.push(i); - } - } - resolveAmbiguous(indexes); - - const options: DropdownOption[] = []; - for (let i = 0; i < repoPaths.length; i++) { - let hint; - if (names[i] === distinctNames[i]) { - // Name is distinct, no hint needed - hint = ''; - } else { - // Hint path is the prefix of the distinctName before the common suffix with name - let hintPath = distinctNames[i].substring(0, distinctNames[i].length - names[i].length - 1); - - // Keep two informative directories - let hintComps = hintPath.split('/'); - let keepDirs = hintComps[0] !== '' ? 2 : 3; - if (hintComps.length > keepDirs) hintComps.splice(keepDirs, hintComps.length - keepDirs, '...'); - - // Construct the hint - hint = (distinctNames[i] !== paths[i] ? '.../' : '') + hintComps.join('/'); - } - options.push({ name: names[i], value: repoPaths[i], hint: hint }); - } - return options; -} - -function runAction(msg: GG.RequestMessage, action: string) { - dialog.showActionRunning(action); - sendMessage(msg); -} - -function getBranchLabels(heads: ReadonlyArray, remotes: ReadonlyArray) { - let headLabels: { name: string; remotes: string[] }[] = [], headLookup: { [name: string]: number } = {}, remoteLabels: ReadonlyArray; - for (let i = 0; i < heads.length; i++) { - headLabels.push({ name: heads[i], remotes: [] }); - headLookup[heads[i]] = i; - } - if (initialState.config.referenceLabels.combineLocalAndRemoteBranchLabels) { - let remainingRemoteLabels = []; - for (let i = 0; i < remotes.length; i++) { - if (remotes[i].remote !== null) { // If the remote of the remote branch ref is known - let branchName = remotes[i].name.substring(remotes[i].remote!.length + 1); - if (typeof headLookup[branchName] === 'number') { - headLabels[headLookup[branchName]].remotes.push(remotes[i].remote!); - continue; - } - } - remainingRemoteLabels.push(remotes[i]); - } - remoteLabels = remainingRemoteLabels; - } else { - remoteLabels = remotes; - } - return { heads: headLabels, remotes: remoteLabels }; -} - -function findCommitElemWithId(elems: HTMLCollectionOf, id: number | null) { - if (id === null) return null; - let findIdStr = id.toString(); - for (let i = 0; i < elems.length; i++) { - if (findIdStr === elems[i].dataset.id) return elems[i]; - } - return null; -} - -function generateSignatureHtml(signature: GG.GitSignature) { - return '' - + (signature.status === GG.GitSignatureStatus.GoodAndValid - ? SVG_ICONS.passed - : signature.status === GG.GitSignatureStatus.Bad - ? SVG_ICONS.failed - : SVG_ICONS.inconclusive) - + ''; -} - -function closeDialogAndContextMenu() { - if (dialog.isOpen()) dialog.close(); - if (contextMenu.isOpen()) contextMenu.close(); -} +class GitGraphView { + private gitRepos: GG.GitRepoSet; + private gitBranches: ReadonlyArray = []; + private gitBranchHead: string | null = null; + private gitConfig: GG.GitRepoConfig | null = null; + private gitRemotes: ReadonlyArray = []; + private gitStashes: ReadonlyArray = []; + private gitTags: ReadonlyArray = []; + private commits: GG.GitCommit[] = []; + private commitHead: string | null = null; + private commitLookup: { [hash: string]: number } = {}; + private onlyFollowFirstParent: boolean = false; + private avatars: AvatarImageCollection = {}; + private currentBranches: string[] | null = null; + private currentAuthors: string[] | null = null; + + private currentRepo!: string; + private currentRepoLoading: boolean = true; + private currentRepoRefreshState: { + inProgress: boolean; + hard: boolean; + loadRepoInfoRefreshId: number; + loadCommitsRefreshId: number; + repoInfoChanges: boolean; + configChanges: boolean; + requestingRepoInfo: boolean; + requestingConfig: boolean; + }; + private loadViewTo: GG.LoadGitGraphViewTo = null; + + private readonly graph: Graph; + private readonly config: Config; + + private moreCommitsAvailable: boolean = false; + private expandedCommit: ExpandedCommit | null = null; + private maxCommits: number; + private scrollTop = 0; + private renderedGitBranchHead: string | null = null; + + private lastScrollToStash: { + time: number, + hash: string | null + } = { time: 0, hash: null }; + + private readonly findWidget: FindWidget; + private readonly settingsWidget: SettingsWidget; + private readonly repoDropdown: Dropdown; + private readonly branchDropdown: Dropdown; + private readonly authorDropdown: Dropdown; + + private readonly viewElem: HTMLElement; + private readonly controlsElem: HTMLElement; + private readonly tableElem: HTMLElement; + private tableColHeadersElem: HTMLElement | null; + private readonly footerElem: HTMLElement; + private readonly showRemoteBranchesElem: HTMLInputElement; + private readonly simplifyByDecorationElem: HTMLInputElement; + private readonly refreshBtnElem: HTMLElement; + + constructor(viewElem: HTMLElement, prevState: WebViewState | null) { + this.gitRepos = initialState.repos; + this.config = initialState.config; + this.maxCommits = this.config.initialLoadCommits; + this.viewElem = viewElem; + this.currentRepoRefreshState = { + inProgress: false, + hard: true, + loadRepoInfoRefreshId: initialState.loadRepoInfoRefreshId, + loadCommitsRefreshId: initialState.loadCommitsRefreshId, + repoInfoChanges: false, + configChanges: false, + requestingRepoInfo: false, + requestingConfig: false + }; + + this.controlsElem = document.getElementById('controls')!; + this.tableElem = document.getElementById('commitTable')!; + this.tableColHeadersElem = document.getElementById('tableColHeaders')!; + this.footerElem = document.getElementById('footer')!; + + viewElem.focus(); + + this.graph = new Graph('commitGraph', viewElem, this.config.graph, this.config.mute); + + this.repoDropdown = new Dropdown('repoDropdown', true, false, 'Repos', (values) => { + this.loadRepo(values[0]); + }); + + this.branchDropdown = new Dropdown('branchDropdown', false, true, 'Branches', (values) => { + this.currentBranches = values; + this.maxCommits = this.config.initialLoadCommits; + this.saveState(); + this.clearCommits(); + this.requestLoadRepoInfoAndCommits(true, true); + }); + this.authorDropdown = new Dropdown('authorDropdown', false, true, 'Authors', (values) => { + this.currentAuthors = values; + this.maxCommits = this.config.initialLoadCommits; + this.saveState(); + this.clearCommits(); + this.requestLoadRepoInfoAndCommits(true, true); + }); + this.showRemoteBranchesElem = document.getElementById('showRemoteBranchesCheckbox')!; + this.showRemoteBranchesElem.addEventListener('change', () => { + this.saveRepoStateValue(this.currentRepo, 'showRemoteBranchesV2', this.showRemoteBranchesElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); + this.refresh(true); + }); + this.simplifyByDecorationElem = document.getElementById('simplifyByDecorationCheckbox')!; + this.simplifyByDecorationElem.addEventListener('change', () => { + this.saveRepoStateValue(this.currentRepo, 'simplifyByDecoration', this.simplifyByDecorationElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); + this.refresh(true); + }); + + this.refreshBtnElem = document.getElementById('refreshBtn')!; + this.refreshBtnElem.addEventListener('click', () => { + if (!this.refreshBtnElem.classList.contains(CLASS_REFRESHING)) { + this.refresh(true, true); + } + }); + this.renderRefreshButton(); + + this.findWidget = new FindWidget(this); + this.settingsWidget = new SettingsWidget(this); + + alterClass(document.body, CLASS_BRANCH_LABELS_ALIGNED_TO_GRAPH, this.config.referenceLabels.branchLabelsAlignedToGraph); + alterClass(document.body, CLASS_TAG_LABELS_RIGHT_ALIGNED, this.config.referenceLabels.tagLabelsOnRight); + + this.observeWindowSizeChanges(); + this.observeWebviewStyleChanges(); + this.observeViewScroll(); + this.observeKeyboardEvents(); + this.observeUrls(); + this.observeTableEvents(); + + if (prevState && !prevState.currentRepoLoading && typeof this.gitRepos[prevState.currentRepo] !== 'undefined') { + this.currentRepo = prevState.currentRepo; + this.currentBranches = prevState.currentBranches; + this.currentAuthors = prevState.currentAuthors; + this.maxCommits = prevState.maxCommits; + this.expandedCommit = prevState.expandedCommit; + this.avatars = prevState.avatars; + this.gitConfig = prevState.gitConfig; + this.loadRepoInfo(prevState.gitBranches, prevState.gitBranchHead, prevState.gitRemotes, prevState.gitStashes, true); + this.loadCommits(prevState.commits, prevState.commitHead, prevState.gitTags, prevState.moreCommitsAvailable, prevState.onlyFollowFirstParent); + this.findWidget.restoreState(prevState.findWidget); + this.settingsWidget.restoreState(prevState.settingsWidget); + this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[prevState.currentRepo].showRemoteBranchesV2); + this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[prevState.currentRepo].simplifyByDecoration); + } + + let loadViewTo = initialState.loadViewTo; + if (loadViewTo === null && prevState && prevState.currentRepoLoading && typeof prevState.currentRepo !== 'undefined') { + loadViewTo = { repo: prevState.currentRepo }; + } + + if (!this.loadRepos(this.gitRepos, initialState.lastActiveRepo, loadViewTo)) { + if (prevState) { + this.scrollTop = prevState.scrollTop; + this.viewElem.scroll(0, this.scrollTop); + } + this.requestLoadRepoInfoAndCommits(false, false); + } + + const currentBtn = document.getElementById('currentBtn')!, fetchBtn = document.getElementById('fetchBtn')!, findBtn = document.getElementById('findBtn')!, settingsBtn = document.getElementById('settingsBtn')!, terminalBtn = document.getElementById('terminalBtn')!; + currentBtn.innerHTML = SVG_ICONS.current; + currentBtn.addEventListener('click', () => { + if (this.commitHead) { + this.scrollToCommit(this.commitHead, true, true, false, true); + } + }); + fetchBtn.title = 'Fetch' + (this.config.fetchAndPrune ? ' & Prune' : '') + ' from Remote(s)'; + fetchBtn.innerHTML = SVG_ICONS.download; + fetchBtn.addEventListener('click', () => this.fetchFromRemotesAction()); + findBtn.innerHTML = SVG_ICONS.search; + findBtn.addEventListener('click', () => this.findWidget.show(true)); + settingsBtn.innerHTML = SVG_ICONS.gear; + settingsBtn.addEventListener('click', () => this.settingsWidget.show(this.currentRepo)); + terminalBtn.innerHTML = SVG_ICONS.terminal; + terminalBtn.addEventListener('click', () => { + runAction({ + command: 'openTerminal', + repo: this.currentRepo, + name: this.gitRepos[this.currentRepo].name || getRepoName(this.currentRepo) + }, 'Opening Terminal'); + }); + } + + + /* Loading Data */ + + public loadRepos(repos: GG.GitRepoSet, lastActiveRepo: string | null, loadViewTo: GG.LoadGitGraphViewTo) { + this.gitRepos = repos; + this.saveState(); + + let newRepo: string; + if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { + newRepo = loadViewTo.repo; + } else if (typeof repos[this.currentRepo] === 'undefined') { + newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' + ? lastActiveRepo + : getSortedRepositoryPaths(repos, this.config.repoDropdownOrder)[0]; + } else { + newRepo = this.currentRepo; + } + + alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); + this.renderRepoDropdownOptions(newRepo); + + if (loadViewTo !== null) { + if (loadViewTo.repo === newRepo) { + this.loadViewTo = loadViewTo; + } else { + this.loadViewTo = null; + showErrorMessage('Unable to load the Git Graph View for the repository "' + loadViewTo.repo + '". It is not currently included in Git Graph.'); + } + } else { + this.loadViewTo = null; + } + + if (this.currentRepo !== newRepo) { + this.loadRepo(newRepo); + return true; + } else { + this.finaliseRepoLoad(false); + return false; + } + } + + private loadRepo(repo: string) { + this.currentRepo = repo; + this.currentRepoLoading = true; + this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[this.currentRepo].showRemoteBranchesV2); + this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[this.currentRepo].simplifyByDecoration); + this.maxCommits = this.config.initialLoadCommits; + this.gitConfig = null; + this.gitRemotes = []; + this.gitStashes = []; + this.gitTags = []; + this.currentBranches = null; + this.currentAuthors = null; + this.renderFetchButton(); + this.closeCommitDetails(false); + this.settingsWidget.close(); + this.saveState(); + this.refresh(true); + } + + private loadRepoInfo(branchOptions: ReadonlyArray, branchHead: string | null, remotes: ReadonlyArray, stashes: ReadonlyArray, isRepo: boolean) { + // Changes to this.gitStashes are reflected as changes to the commits when loadCommits is run + this.gitStashes = stashes; + + if (!isRepo || (!this.currentRepoRefreshState.hard && arraysStrictlyEqual(this.gitBranches, branchOptions) && this.gitBranchHead === branchHead && arraysStrictlyEqual(this.gitRemotes, remotes))) { + this.saveState(); + this.finaliseLoadRepoInfo(false, isRepo); + return; + } + + // Changes to these properties must be indicated as a repository info change + this.gitBranches = branchOptions; + this.gitBranchHead = branchHead; + this.gitRemotes = remotes; + + // Update the state of the fetch button + this.renderFetchButton(); + + const filterCurrentBranches = () => { + // Configure current branches + if (this.currentBranches !== null && !(this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES)) { + // Filter any branches that are currently selected, but no longer exist + const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); + this.currentBranches = this.currentBranches.filter((branch) => + this.gitBranches.includes(branch) || globPatterns.includes(branch) || branch === 'HEAD' + ); + } + }; + + filterCurrentBranches(); + if (this.currentBranches === null || this.currentBranches.length === 0) { + // No branches are currently selected + const onRepoLoadShowCheckedOutBranch = getOnRepoLoadShowCheckedOutBranch(this.gitRepos[this.currentRepo].onRepoLoadShowCheckedOutBranch); + const onRepoLoadShowSpecificBranches = getOnRepoLoadShowSpecificBranches(this.gitRepos[this.currentRepo].onRepoLoadShowSpecificBranches); + this.currentBranches = []; + if (onRepoLoadShowSpecificBranches.length > 0) { + // Show specific branches if they exist in the repository + const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); + this.currentBranches.push(...onRepoLoadShowSpecificBranches.filter((branch) => + this.gitBranches.includes(branch) || globPatterns.includes(branch) + )); + } + if (onRepoLoadShowCheckedOutBranch && this.gitBranchHead !== null && !this.currentBranches.includes(this.gitBranchHead)) { + // Show the checked-out branch, and it hasn't already been added as a specific branch + this.currentBranches.push(this.gitBranchHead); + } + if (this.currentBranches.length === 0) { + this.currentBranches.push(SHOW_ALL_BRANCHES); + } + } + filterCurrentBranches(); + + this.saveState(); + + // Set up branch dropdown options + this.branchDropdown.setOptions(this.getBranchOptions(true), this.currentBranches); + this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); + + // Remove hidden remotes that no longer exist + let hiddenRemotes = this.gitRepos[this.currentRepo].hideRemotes; + let hideRemotes = hiddenRemotes.filter((hiddenRemote) => remotes.includes(hiddenRemote)); + if (hiddenRemotes.length !== hideRemotes.length) { + this.saveRepoStateValue(this.currentRepo, 'hideRemotes', hideRemotes); + } + + this.finaliseLoadRepoInfo(true, isRepo); + } + + private finaliseLoadRepoInfo(repoInfoChanges: boolean, isRepo: boolean) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + if (isRepo) { + refreshState.repoInfoChanges = refreshState.repoInfoChanges || repoInfoChanges; + refreshState.requestingRepoInfo = false; + this.requestLoadCommits(); + } else { + dialog.closeActionRunning(); + refreshState.inProgress = false; + this.loadViewTo = null; + this.renderRefreshButton(); + sendMessage({ command: 'loadRepos', check: true }); + } + } + } + + private loadCommits(commits: GG.GitCommit[], commitHead: string | null, tags: ReadonlyArray, moreAvailable: boolean, onlyFollowFirstParent: boolean) { + // This list of tags is just used to provide additional information in the dialogs. Tag information included in commits is used for all other purposes (e.g. rendering, context menus) + const tagsChanged = !arraysStrictlyEqual(this.gitTags, tags); + this.gitTags = tags; + + if (!this.currentRepoLoading && !this.currentRepoRefreshState.hard && this.moreCommitsAvailable === moreAvailable && this.onlyFollowFirstParent === onlyFollowFirstParent && this.commitHead === commitHead && commits.length > 0 && arraysEqual(this.commits, commits, (a, b) => + a.hash === b.hash && + arraysStrictlyEqual(a.heads, b.heads) && + arraysEqual(a.tags, b.tags, (a, b) => a.name === b.name && a.annotated === b.annotated) && + arraysEqual(a.remotes, b.remotes, (a, b) => a.name === b.name && a.remote === b.remote) && + arraysStrictlyEqual(a.parents, b.parents) && + ((a.stash === null && b.stash === null) || (a.stash !== null && b.stash !== null && a.stash.selector === b.stash.selector)) + ) && this.renderedGitBranchHead === this.gitBranchHead) { + + if (this.commits[0].hash === UNCOMMITTED) { + this.commits[0] = commits[0]; + this.saveState(); + this.renderUncommittedChanges(); + if (this.expandedCommit !== null && this.expandedCommit.commitElem !== null) { + if (this.expandedCommit.compareWithHash === null) { + // Commit Details View is open + if (this.expandedCommit.commitHash === UNCOMMITTED) { + this.requestCommitDetails(this.expandedCommit.commitHash, true); + } + } else { + // Commit Comparison is open + if (this.expandedCommit.compareWithElem !== null && (this.expandedCommit.commitHash === UNCOMMITTED || this.expandedCommit.compareWithHash === UNCOMMITTED)) { + this.requestCommitComparison(this.expandedCommit.commitHash, this.expandedCommit.compareWithHash, true); + } + } + } + } else if (tagsChanged) { + this.saveState(); + } + this.finaliseLoadCommits(); + return; + } + + const currentRepoLoading = this.currentRepoLoading; + this.currentRepoLoading = false; + this.moreCommitsAvailable = moreAvailable; + this.onlyFollowFirstParent = onlyFollowFirstParent; + this.commits = commits; + this.commitHead = commitHead; + this.commitLookup = {}; + + let i: number, expandedCommitVisible = false, expandedCompareWithCommitVisible = false, avatarsNeeded: { [email: string]: string[] } = {}, commit; + for (i = 0; i < this.commits.length; i++) { + commit = this.commits[i]; + this.commitLookup[commit.hash] = i; + if (this.expandedCommit !== null) { + if (this.expandedCommit.commitHash === commit.hash) { + expandedCommitVisible = true; + } else if (this.expandedCommit.compareWithHash === commit.hash) { + expandedCompareWithCommitVisible = true; + } + } + if (this.config.fetchAvatars && typeof this.avatars[commit.email] !== 'string' && commit.email !== '') { + if (typeof avatarsNeeded[commit.email] === 'undefined') { + avatarsNeeded[commit.email] = [commit.hash]; + } else { + avatarsNeeded[commit.email].push(commit.hash); + } + } + } + + if (this.expandedCommit !== null && (!expandedCommitVisible || (this.expandedCommit.compareWithHash !== null && !expandedCompareWithCommitVisible))) { + this.closeCommitDetails(false); + } + + this.saveState(); + + this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); + this.render(); + + if (currentRepoLoading && this.config.onRepoLoad.scrollToHead && this.commitHead !== null) { + this.scrollToCommit(this.commitHead, true); + } + + this.finaliseLoadCommits(); + this.requestAvatars(avatarsNeeded); + } + + private finaliseLoadCommits() { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + dialog.closeActionRunning(); + + if (dialog.isTargetDynamicSource()) { + if (refreshState.repoInfoChanges) { + dialog.close(); + } else { + dialog.refresh(this.getCommits()); + } + } + + if (contextMenu.isTargetDynamicSource()) { + if (refreshState.repoInfoChanges) { + contextMenu.close(); + } else { + contextMenu.refresh(this.getCommits()); + } + } + + refreshState.inProgress = false; + this.renderRefreshButton(); + } + + this.finaliseRepoLoad(true); + } + + private finaliseRepoLoad(didLoadRepoData: boolean) { + if (this.loadViewTo !== null && this.currentRepo === this.loadViewTo.repo) { + if (this.loadViewTo.commitDetails && (this.expandedCommit === null || this.expandedCommit.commitHash !== this.loadViewTo.commitDetails.commitHash || this.expandedCommit.compareWithHash !== this.loadViewTo.commitDetails.compareWithHash)) { + const commitIndex = this.getCommitId(this.loadViewTo.commitDetails.commitHash); + const compareWithIndex = this.loadViewTo.commitDetails.compareWithHash !== null ? this.getCommitId(this.loadViewTo.commitDetails.compareWithHash) : null; + const commitElems = getCommitElems(); + const commitElem = findCommitElemWithId(commitElems, commitIndex); + const compareWithElem = findCommitElemWithId(commitElems, compareWithIndex); + + if (commitElem !== null && (this.loadViewTo.commitDetails.compareWithHash === null || compareWithElem !== null)) { + if (compareWithElem !== null) { + this.loadCommitComparison(commitElem, compareWithElem); + } else { + this.loadCommitDetails(commitElem); + } + } else { + showErrorMessage('Unable to resume Code Review, it could not be found in the latest ' + this.maxCommits + ' commits that were loaded in this repository.'); + } + } else if (this.loadViewTo.runCommandOnLoad) { + switch (this.loadViewTo.runCommandOnLoad) { + case 'fetch': + this.fetchFromRemotesAction(); + break; + } + } + } + this.loadViewTo = null; + + if (this.gitConfig === null || (didLoadRepoData && this.currentRepoRefreshState.configChanges)) { + this.requestLoadConfig(); + } + } + + private clearCommits() { + closeDialogAndContextMenu(); + this.moreCommitsAvailable = false; + this.commits = []; + this.commitHead = null; + this.commitLookup = {}; + this.renderedGitBranchHead = null; + this.closeCommitDetails(false); + this.saveState(); + this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); + this.tableElem.innerHTML = ''; + this.footerElem.innerHTML = ''; + this.renderGraph(); + this.findWidget.refresh(); + } + + public processLoadRepoInfoResponse(msg: GG.ResponseLoadRepoInfo) { + if (msg.error === null) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress && refreshState.loadRepoInfoRefreshId === msg.refreshId) { + this.loadRepoInfo(msg.branches, msg.head, msg.remotes, msg.stashes, msg.isRepo); + } + } else { + this.displayLoadDataError('Unable to load Repository Info', msg.error); + } + } + + public processLoadCommitsResponse(msg: GG.ResponseLoadCommits) { + if (msg.error === null) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress && refreshState.loadCommitsRefreshId === msg.refreshId) { + this.loadCommits(msg.commits, msg.head, msg.tags, msg.moreCommitsAvailable, msg.onlyFollowFirstParent); + } + } else { + const error = this.gitBranches.length === 0 && msg.error.indexOf('bad revision \'HEAD\'') > -1 + ? 'There are no commits in this repository.' + : msg.error; + this.displayLoadDataError('Unable to load Commits', error); + } + } + + public processLoadConfig(msg: GG.ResponseLoadConfig) { + this.currentRepoRefreshState.requestingConfig = false; + if (msg.config !== null && this.currentRepo === msg.repo) { + this.gitConfig = msg.config; + this.saveState(); + + this.renderCdvExternalDiffBtn(); + } + this.settingsWidget.refresh(); + this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); + } + + private displayLoadDataError(message: string, reason: string) { + this.clearCommits(); + this.currentRepoRefreshState.inProgress = false; + this.loadViewTo = null; + this.renderRefreshButton(); + dialog.showError(message, reason, 'Retry', () => { + this.refresh(true); + }); + } + + public loadAvatar(email: string, image: string) { + this.avatars[email] = image; + this.saveState(); + let avatarsElems = >document.getElementsByClassName('avatar'), escapedEmail = escapeHtml(email); + for (let i = 0; i < avatarsElems.length; i++) { + if (avatarsElems[i].dataset.email === escapedEmail) { + avatarsElems[i].innerHTML = ''; + } + } + } + + + /* Getters */ + + public getBranches(): ReadonlyArray { + return this.gitBranches; + } + + public getBranchOptions(includeShowAll?: boolean): ReadonlyArray { + const options: DialogSelectInputOption[] = []; + if (includeShowAll) { + options.push({ name: 'Show All', value: SHOW_ALL_BRANCHES }); + } + options.push({ name: 'HEAD', value: 'HEAD' }); + for (let i = 0; i < this.config.customBranchGlobPatterns.length; i++) { + options.push({ name: 'Glob: ' + this.config.customBranchGlobPatterns[i].name, value: this.config.customBranchGlobPatterns[i].glob }); + } + for (let i = 0; i < this.gitBranches.length; i++) { + options.push({ name: this.gitBranches[i].indexOf('remotes/') === 0 ? this.gitBranches[i].substring(8) : this.gitBranches[i], value: this.gitBranches[i] }); + } + return options; + } + public getAuthorOptions(): ReadonlyArray { + const options: DialogSelectInputOption[] = []; + options.push({ name: 'All', value: SHOW_ALL_BRANCHES }); + if (this.gitConfig && this.gitConfig.authors) { + for (let i = 0; i < this!.gitConfig!.authors.length; i++) { + const author = this!.gitConfig!.authors[i]; + options.push({ name: author.name, value: author.name }); + } + } + return options; + } + public getCommitId(hash: string) { + if (typeof this.commitLookup[hash] === 'number') { + return this.commitLookup[hash]; + } + // If a full match isn't found, try to find a matching partial hash + for (const key in this.commitLookup) { + if (key.startsWith(hash)) { + return this.commitLookup[key]; + } + } + return null; + } + + private getCommitOfElem(elem: HTMLElement) { + let id = parseInt(elem.dataset.id!); + return id < this.commits.length ? this.commits[id] : null; + } + + public getCommits(): ReadonlyArray { + return this.commits; + } + + private getPushRemote(branch: string | null = null) { + const possibleRemotes = []; + if (this.gitConfig !== null) { + if (branch !== null && typeof this.gitConfig.branches[branch] !== 'undefined') { + possibleRemotes.push(this.gitConfig.branches[branch].pushRemote, this.gitConfig.branches[branch].remote); + } + possibleRemotes.push(this.gitConfig.pushDefault); + } + possibleRemotes.push('origin'); + return possibleRemotes.find((remote) => remote !== null && this.gitRemotes.includes(remote)) || this.gitRemotes[0]; + } + + public getRepoConfig(): Readonly | null { + return this.gitConfig; + } + + public getRepoState(repo: string): Readonly | null { + return typeof this.gitRepos[repo] !== 'undefined' + ? this.gitRepos[repo] + : null; + } + + public isConfigLoading(): boolean { + return this.currentRepoRefreshState.requestingConfig; + } + + + /* Refresh */ + + public refresh(hard: boolean, configChanges: boolean = false) { + if (hard) { + this.clearCommits(); + } + this.requestLoadRepoInfoAndCommits(hard, false, configChanges); + } + + + /* Requests */ + + private requestLoadRepoInfo() { + const repoState = this.gitRepos[this.currentRepo]; + sendMessage({ + command: 'loadRepoInfo', + repo: this.currentRepo, + refreshId: ++this.currentRepoRefreshState.loadRepoInfoRefreshId, + showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), + simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), + showStashes: getShowStashes(repoState.showStashes), + hideRemotes: repoState.hideRemotes + }); + } + + private requestLoadCommits() { + const repoState = this.gitRepos[this.currentRepo]; + sendMessage({ + command: 'loadCommits', + repo: this.currentRepo, + refreshId: ++this.currentRepoRefreshState.loadCommitsRefreshId, + branches: this.currentBranches === null || (this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES) ? null : this.currentBranches, + authors: this.currentAuthors === null || (this.currentAuthors.length === 1 && this.currentAuthors[0] === SHOW_ALL_BRANCHES) ? null : this.currentAuthors, + maxCommits: this.maxCommits, + showTags: getShowTags(repoState.showTags), + showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), + simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), + includeCommitsMentionedByReflogs: getIncludeCommitsMentionedByReflogs(repoState.includeCommitsMentionedByReflogs), + onlyFollowFirstParent: getOnlyFollowFirstParent(repoState.onlyFollowFirstParent), + commitOrdering: getCommitOrdering(repoState.commitOrdering), + remotes: this.gitRemotes, + hideRemotes: repoState.hideRemotes, + stashes: this.gitStashes + }); + } + + private requestLoadRepoInfoAndCommits(hard: boolean, skipRepoInfo: boolean, configChanges: boolean = false) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + refreshState.hard = refreshState.hard || hard; + refreshState.configChanges = refreshState.configChanges || configChanges; + if (!skipRepoInfo) { + // This request will trigger a loadCommit request after the loadRepoInfo request has completed. + // Invalidate any previous commit requests in progress. + refreshState.loadCommitsRefreshId++; + } + } else { + refreshState.hard = hard; + refreshState.inProgress = true; + refreshState.repoInfoChanges = false; + refreshState.configChanges = configChanges; + refreshState.requestingRepoInfo = false; + } + + this.renderRefreshButton(); + if (this.commits.length === 0) { + this.tableElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; + } + + if (skipRepoInfo) { + if (!refreshState.requestingRepoInfo) { + this.requestLoadCommits(); + } + } else { + refreshState.requestingRepoInfo = true; + this.requestLoadRepoInfo(); + } + } + + public requestLoadConfig() { + this.currentRepoRefreshState.requestingConfig = true; + sendMessage({ command: 'loadConfig', repo: this.currentRepo, remotes: this.gitRemotes }); + this.settingsWidget.refresh(); + } + + public requestCommitDetails(hash: string, refresh: boolean) { + let commit = this.commits[this.commitLookup[hash]]; + sendMessage({ + command: 'commitDetails', + repo: this.currentRepo, + commitHash: hash, + hasParents: commit.parents.length > 0, + stash: commit.stash, + avatarEmail: this.config.fetchAvatars && hash !== UNCOMMITTED ? commit.email : null, + refresh: refresh + }); + } + + public requestCommitComparison(hash: string, compareWithHash: string, refresh: boolean) { + let commitOrder = this.getCommitOrder(hash, compareWithHash); + sendMessage({ + command: 'compareCommits', + repo: this.currentRepo, + commitHash: hash, compareWithHash: compareWithHash, + fromHash: commitOrder.from, toHash: commitOrder.to, + refresh: refresh + }); + } + + private requestAvatars(avatars: { [email: string]: string[] }) { + let emails = Object.keys(avatars), remote = this.gitRemotes.length > 0 ? this.gitRemotes.includes('origin') ? 'origin' : this.gitRemotes[0] : null; + for (let i = 0; i < emails.length; i++) { + sendMessage({ command: 'fetchAvatar', repo: this.currentRepo, remote: remote, email: emails[i], commits: avatars[emails[i]] }); + } + } + + + /* State */ + + public saveState() { + let expandedCommit; + if (this.expandedCommit !== null) { + expandedCommit = Object.assign({}, this.expandedCommit); + expandedCommit.commitElem = null; + expandedCommit.compareWithElem = null; + expandedCommit.contextMenuOpen = { + summary: false, + fileView: -1 + }; + } else { + expandedCommit = null; + } + + VSCODE_API.setState({ + currentRepo: this.currentRepo, + currentRepoLoading: this.currentRepoLoading, + gitRepos: this.gitRepos, + gitBranches: this.gitBranches, + gitBranchHead: this.gitBranchHead, + gitConfig: this.gitConfig, + gitRemotes: this.gitRemotes, + gitStashes: this.gitStashes, + gitTags: this.gitTags, + commits: this.commits, + commitHead: this.commitHead, + avatars: this.avatars, + currentBranches: this.currentBranches, + currentAuthors: this.currentAuthors, + moreCommitsAvailable: this.moreCommitsAvailable, + maxCommits: this.maxCommits, + onlyFollowFirstParent: this.onlyFollowFirstParent, + expandedCommit: expandedCommit, + scrollTop: this.scrollTop, + findWidget: this.findWidget.getState(), + settingsWidget: this.settingsWidget.getState() + }); + } + + public saveRepoState() { + sendMessage({ command: 'setRepoState', repo: this.currentRepo, state: this.gitRepos[this.currentRepo] }); + } + + private saveColumnWidths(columnWidths: GG.ColumnWidth[]) { + this.gitRepos[this.currentRepo].columnWidths = [columnWidths[0], columnWidths[2], columnWidths[3], columnWidths[4]]; + this.saveRepoState(); + } + + private saveExpandedCommitLoading(index: number, commitHash: string, commitElem: HTMLElement, compareWithHash: string | null, compareWithElem: HTMLElement | null) { + this.expandedCommit = { + index: index, + commitHash: commitHash, + commitElem: commitElem, + compareWithHash: compareWithHash, + compareWithElem: compareWithElem, + commitDetails: null, + fileChanges: null, + fileTree: null, + avatar: null, + codeReview: null, + lastViewedFile: null, + loading: true, + scrollTop: { + summary: 0, + fileView: 0 + }, + contextMenuOpen: { + summary: false, + fileView: -1 + } + }; + this.saveState(); + } + + public saveRepoStateValue(repo: string, key: K, value: GG.GitRepoState[K]) { + if (repo === this.currentRepo) { + this.gitRepos[this.currentRepo][key] = value; + this.saveRepoState(); + } + } + + + /* Renderers */ + + private render() { + this.renderTable(); + this.renderGraph(); + } + + private renderGraph() { + if (typeof this.currentRepo === 'undefined') { + // Only render the graph if a repo is loaded (or a repo is currently being loaded) + return; + } + + const colHeadersElem = document.getElementById('tableColHeaders'); + const cdvHeight = this.gitRepos[this.currentRepo].isCdvSummaryHidden ? 0 : this.gitRepos[this.currentRepo].cdvHeight; + const headerHeight = colHeadersElem !== null ? colHeadersElem.clientHeight + 1 : 0; + const expandedCommit = this.isCdvDocked() ? null : this.expandedCommit; + const expandedCommitElem = expandedCommit !== null ? document.getElementById('cdv') : null; + + // Update the graphs grid dimensions + this.config.graph.grid.expandY = expandedCommitElem !== null + ? expandedCommitElem.getBoundingClientRect().height + : cdvHeight; + this.config.graph.grid.y = this.commits.length > 0 && this.tableElem.children.length > 0 + ? (this.tableElem.children[0].clientHeight - headerHeight - (expandedCommit !== null ? cdvHeight : 0)) / this.commits.length + : this.config.graph.grid.y; + this.config.graph.grid.offsetY = headerHeight + this.config.graph.grid.y / 2; + + this.graph.render(expandedCommit); + } + + private renderTable() { + const colVisibility = this.getColumnVisibility(); + const currentHash = this.commits.length > 0 && this.commits[0].hash === UNCOMMITTED ? UNCOMMITTED : this.commitHead; + const vertexColours = this.graph.getVertexColours(); + const widthsAtVertices = this.config.referenceLabels.branchLabelsAlignedToGraph ? this.graph.getWidthsAtVertices() : []; + const mutedCommits = this.graph.getMutedCommits(currentHash); + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + emoji: true, + issueLinking: true, + markdown: this.config.markdown + }); + + let html = 'GraphDescription' + + (colVisibility.date ? 'Date' : '') + + (colVisibility.author ? 'Author' : '') + + (colVisibility.commit ? 'Commit' : '') + + ''; + + for (let i = 0; i < this.commits.length; i++) { + let commit = this.commits[i]; + let message = '' + textFormatter.format(commit.message) + ''; + let date = formatShortDate(commit.date); + let branchLabels = getBranchLabels(commit.heads, commit.remotes); + let refBranches = '', refTags = '', j, k, refName, remoteName, refActive, refHtml, branchCheckedOutAtCommit: string | null = null; + + for (j = 0; j < branchLabels.heads.length; j++) { + refName = escapeHtml(branchLabels.heads[j].name); + refActive = branchLabels.heads[j].name === this.gitBranchHead; + refHtml = '' + SVG_ICONS.branch + '' + refName + ''; + for (k = 0; k < branchLabels.heads[j].remotes.length; k++) { + remoteName = escapeHtml(branchLabels.heads[j].remotes[k]); + refHtml += '' + remoteName + ''; + } + refHtml += ''; + refBranches = refActive ? refHtml + refBranches : refBranches + refHtml; + if (refActive) branchCheckedOutAtCommit = this.gitBranchHead; + } + for (j = 0; j < branchLabels.remotes.length; j++) { + refName = escapeHtml(branchLabels.remotes[j].name); + refBranches += '' + SVG_ICONS.branch + '' + refName + ''; + } + + for (j = 0; j < commit.tags.length; j++) { + refName = escapeHtml(commit.tags[j].name); + refTags += '' + SVG_ICONS.tag + '' + refName + ''; + } + + if (commit.stash !== null) { + refName = escapeHtml(commit.stash.selector); + refBranches = '' + SVG_ICONS.stash + '' + escapeHtml(commit.stash.selector.substring(5)) + '' + refBranches; + } + + const commitDot = commit.hash === this.commitHead + ? '' + : ''; + + html += '' + + (this.config.referenceLabels.branchLabelsAlignedToGraph ? '' + getResizeColHtml(0) + (refBranches !== '' ? '' + getResizeColHtml(1) + '' + commitDot : '' + getResizeColHtml(0) + '' + getResizeColHtml(1) + '' + commitDot + refBranches) + (this.config.referenceLabels.tagLabelsOnRight ? message + refTags : refTags + message) + '' + + (colVisibility.date ? '' + getResizeColHtml(2) + date.formatted + '' : '') + + (colVisibility.author ? '' + getResizeColHtml(3) + (this.config.fetchAvatars ? '' + (typeof this.avatars[commit.email] === 'string' ? '' : '') + '' : '') + escapeHtml(commit.author) + '' : '') + + (colVisibility.commit ? '' + getResizeColHtml(4) + abbrevCommit(commit.hash) + '' : '') + + ''; + + + } + function getResizeColHtml(col: number) { + return (col > 0 ? '' : '') + (col < 4 ? '' : ''); + } + this.tableElem.innerHTML = '' + html + '
    '; + this.footerElem.innerHTML = this.moreCommitsAvailable ? '
    Load More Commits
    ' : ''; + this.makeTableResizable(); + this.findWidget.refresh(); + this.renderedGitBranchHead = this.gitBranchHead; + + if (this.moreCommitsAvailable) { + document.getElementById('loadMoreCommitsBtn')!.addEventListener('click', () => { + this.loadMoreCommits(); + }); + } + + if (this.expandedCommit !== null) { + const expandedCommit = this.expandedCommit, elems = getCommitElems(); + const commitElem = findCommitElemWithId(elems, this.getCommitId(expandedCommit.commitHash)); + const compareWithElem = expandedCommit.compareWithHash !== null ? findCommitElemWithId(elems, this.getCommitId(expandedCommit.compareWithHash)) : null; + + if (commitElem === null || (expandedCommit.compareWithHash !== null && compareWithElem === null)) { + this.closeCommitDetails(false); + this.saveState(); + } else { + expandedCommit.index = parseInt(commitElem.dataset.id!); + expandedCommit.commitElem = commitElem; + expandedCommit.compareWithElem = compareWithElem; + this.saveState(); + if (expandedCommit.compareWithHash === null) { + // Commit Details View is open + if (!expandedCommit.loading && expandedCommit.commitDetails !== null && expandedCommit.fileTree !== null) { + this.showCommitDetails(expandedCommit.commitDetails, expandedCommit.fileTree, expandedCommit.avatar, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); + if (expandedCommit.commitHash === UNCOMMITTED) { + this.requestCommitDetails(expandedCommit.commitHash, true); + } + } else { + this.loadCommitDetails(commitElem); + } + } else { + // Commit Comparison is open + if (!expandedCommit.loading && expandedCommit.fileChanges !== null && expandedCommit.fileTree !== null) { + this.showCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, expandedCommit.fileChanges, expandedCommit.fileTree, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); + if (expandedCommit.commitHash === UNCOMMITTED || expandedCommit.compareWithHash === UNCOMMITTED) { + this.requestCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, true); + } + } else { + this.loadCommitComparison(commitElem, compareWithElem!); + } + } + } + } + + if (this.config.stickyHeader) { + this.tableColHeadersElem = document.getElementById('tableColHeaders'); + this.alignTableHeaderToControls(); + } + } + + private renderUncommittedChanges() { + const colVisibility = this.getColumnVisibility(), date = formatShortDate(this.commits[0].date); + document.getElementById('uncommittedChanges')!.innerHTML = '' + escapeHtml(this.commits[0].message) + '' + + (colVisibility.date ? '' + date.formatted + '' : '') + + (colVisibility.author ? '*' : '') + + (colVisibility.commit ? '*' : ''); + } + + private renderFetchButton() { + alterClass(this.controlsElem, CLASS_FETCH_SUPPORTED, this.gitRemotes.length > 0); + } + + public renderRefreshButton() { + const enabled = !this.currentRepoRefreshState.inProgress; + this.refreshBtnElem.title = enabled ? 'Refresh' : 'Refreshing'; + this.refreshBtnElem.innerHTML = enabled ? SVG_ICONS.refresh : SVG_ICONS.loading; + alterClass(this.refreshBtnElem, CLASS_REFRESHING, !enabled); + } + + public renderTagDetails(tagName: string, commitHash: string, details: GG.GitTagDetails) { + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + commits: true, + emoji: true, + issueLinking: true, + markdown: this.config.markdown, + multiline: true, + urls: true + }); + dialog.showMessage( + 'Tag ' + escapeHtml(tagName) + '
    ' + + 'Object: ' + escapeHtml(details.hash) + '
    ' + + 'Commit: ' + escapeHtml(commitHash) + '
    ' + + 'Tagger: ' + escapeHtml(details.taggerName) + ' <' + escapeHtml(details.taggerEmail) + '>' + (details.signature !== null ? generateSignatureHtml(details.signature) : '') + '
    ' + + 'Date: ' + formatLongDate(details.taggerDate) + '

    ' + + textFormatter.format(details.message) + + '
    ' + ); + } + + public renderRepoDropdownOptions(repo?: string) { + this.repoDropdown.setOptions(getRepoDropdownOptions(this.gitRepos), [repo || this.currentRepo]); + } + + + /* Context Menu Generation */ + + private getBranchContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { + const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.branch; + const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(refName); + + return [[ + { + title: 'Checkout Branch', + visible: visibility.checkout && this.gitBranchHead !== refName, + onClick: () => this.checkoutBranchAction(refName, null, null, target) + }, { + title: 'Rename Branch' + ELLIPSIS, + visible: visibility.rename, + onClick: () => { + dialog.showRefInput('Enter the new name for branch ' + escapeHtml(refName) + ':', refName, 'Rename Branch', (newName) => { + runAction({ command: 'renameBranch', repo: this.currentRepo, oldName: refName, newName: newName }, 'Renaming Branch'); + }, target); + } + }, { + title: 'Delete Branch' + ELLIPSIS, + visible: visibility.delete && this.gitBranchHead !== refName, + onClick: () => { + let remotesWithBranch = this.gitRemotes.filter(remote => this.gitBranches.includes('remotes/' + remote + '/' + refName)); + let inputs: DialogInput[] = [{ type: DialogInputType.Checkbox, name: 'Force Delete', value: this.config.dialogDefaults.deleteBranch.forceDelete }]; + if (remotesWithBranch.length > 0) { + inputs.push({ + type: DialogInputType.Checkbox, + name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : ''), + value: false, + info: 'This branch is on the remote' + (remotesWithBranch.length > 1 ? 's: ' : ' ') + formatCommaSeparatedList(remotesWithBranch.map((remote) => '"' + remote + '"')) + }); + } + dialog.showForm('Are you sure you want to delete the branch ' + escapeHtml(refName) + '?', inputs, 'Yes, delete', (values) => { + runAction({ command: 'deleteBranch', repo: this.currentRepo, branchName: refName, forceDelete: values[0], deleteOnRemotes: remotesWithBranch.length > 0 && values[1] ? remotesWithBranch : [] }, 'Deleting Branch'); + }, target); + } + }, { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge && this.gitBranchHead !== refName, + onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.Branch, target) + }, { + title: 'Rebase current Branch on Branch' + ELLIPSIS, + visible: visibility.rebase && this.gitBranchHead !== refName, + onClick: () => this.rebaseAction(refName, refName, GG.RebaseActionOn.Branch, target) + }, { + title: 'Push Branch' + ELLIPSIS, + visible: visibility.push && this.gitRemotes.length > 0, + onClick: () => { + const multipleRemotes = this.gitRemotes.length > 1; + const inputs: DialogInput[] = [ + { type: DialogInputType.Checkbox, name: 'Set Upstream', value: true }, + { + type: DialogInputType.Radio, + name: 'Push Mode', + options: [ + { name: 'Normal', value: GG.GitPushBranchMode.Normal }, + { name: 'Force With Lease', value: GG.GitPushBranchMode.ForceWithLease }, + { name: 'Force', value: GG.GitPushBranchMode.Force } + ], + default: GG.GitPushBranchMode.Normal + } + ]; + + if (multipleRemotes) { + inputs.unshift({ + type: DialogInputType.Select, + name: 'Push to Remote(s)', + defaults: [this.getPushRemote(refName)], + options: this.gitRemotes.map((remote) => ({ name: remote, value: remote })), + multiple: true + }); + } + + dialog.showForm('Are you sure you want to push the branch ' + escapeHtml(refName) + '' + (multipleRemotes ? '' : ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '') + '?', inputs, 'Yes, push', (values) => { + const remotes = multipleRemotes ? values.shift() : [this.gitRemotes[0]]; + const setUpstream = values[0]; + runAction({ + command: 'pushBranch', + repo: this.currentRepo, + branchName: refName, + remotes: remotes, + setUpstream: setUpstream, + mode: values[1], + willUpdateBranchConfig: setUpstream && remotes.length > 0 && (this.gitConfig === null || typeof this.gitConfig.branches[refName] === 'undefined' || this.gitConfig.branches[refName].remote !== remotes[remotes.length - 1]) + }, 'Pushing Branch'); + }, target); + } + } + ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), + { + title: 'Create Pull Request' + ELLIPSIS, + visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null, + onClick: () => { + const config = this.gitRepos[this.currentRepo].pullRequestConfig; + if (config === null) return; + dialog.showCheckbox('Are you sure you want to create a Pull Request for branch ' + escapeHtml(refName) + '?', 'Push branch before creating the Pull Request', true, 'Yes, create Pull Request', (push) => { + runAction({ command: 'createPullRequest', repo: this.currentRepo, config: config, sourceRemote: config.sourceRemote, sourceOwner: config.sourceOwner, sourceRepo: config.sourceRepo, sourceBranch: refName, push: push }, 'Creating Pull Request'); + }, target); + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); + } + }, + { + title: 'Select in Branches Dropdown', + visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.selectOption(refName) + }, + { + title: 'Unselect in Branches Dropdown', + visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.unselectOption(refName) + } + ], [ + { + title: 'Copy Branch Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); + } + } + ]]; + } + + private getCommitContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { + const hash = target.hash, visibility = this.config.contextMenuActionsVisibility.commit; + const commit = this.commits[this.commitLookup[hash]]; + return [[ + { + title: 'Add Tag' + ELLIPSIS, + visible: visibility.addTag, + onClick: () => this.addTagAction(hash, '', this.config.dialogDefaults.addTag.type, '', null, target) + }, { + title: 'Create Branch' + ELLIPSIS, + visible: visibility.createBranch, + onClick: () => this.createBranchAction(hash, '', this.config.dialogDefaults.createBranch.checkout, target) + } + ], [ + { + title: 'Checkout' + (globalState.alwaysAcceptCheckoutCommit ? '' : ELLIPSIS), + visible: visibility.checkout, + onClick: () => { + const checkoutCommit = () => runAction({ command: 'checkoutCommit', repo: this.currentRepo, commitHash: hash }, 'Checking out Commit'); + if (globalState.alwaysAcceptCheckoutCommit) { + checkoutCommit(); + } else { + dialog.showCheckbox('Are you sure you want to checkout commit ' + abbrevCommit(hash) + '? This will result in a \'detached HEAD\' state.', 'Always Accept', false, 'Yes, checkout', (alwaysAccept) => { + if (alwaysAccept) { + updateGlobalViewState('alwaysAcceptCheckoutCommit', true); + } + checkoutCommit(); + }, target); + } + } + }, { + title: 'Cherry Pick' + ELLIPSIS, + visible: visibility.cherrypick, + onClick: () => { + const isMerge = commit.parents.length > 1; + let inputs: DialogInput[] = []; + if (isMerge) { + let options = commit.parents.map((hash, index) => ({ + name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), + value: (index + 1).toString() + })); + inputs.push({ + type: DialogInputType.Select, + name: 'Parent Hash', + options: options, + default: '1', + info: 'Choose the parent hash on the main branch, to cherry pick the commit relative to.' + }); + } + inputs.push({ + type: DialogInputType.Checkbox, + name: 'Record Origin', + value: this.config.dialogDefaults.cherryPick.recordOrigin, + info: 'Record that this commit was the origin of the cherry pick by appending a line to the original commit message that states "(cherry picked from commit ...​)".' + }, { + type: DialogInputType.Checkbox, + name: 'No Commit', + value: this.config.dialogDefaults.cherryPick.noCommit, + info: 'Cherry picked changes will be staged but not committed, so that you can select and commit specific parts of this commit.' + }); + + dialog.showForm('Are you sure you want to cherry pick commit ' + abbrevCommit(hash) + '?', inputs, 'Yes, cherry pick', (values) => { + let parentIndex = isMerge ? parseInt(values.shift()) : 0; + runAction({ + command: 'cherrypickCommit', + repo: this.currentRepo, + commitHash: hash, + parentIndex: parentIndex, + recordOrigin: values[0], + noCommit: values[1] + }, 'Cherry picking Commit'); + }, target); + } + }, { + title: 'Revert' + ELLIPSIS, + visible: visibility.revert, + onClick: () => { + if (commit.parents.length > 1) { + let options = commit.parents.map((hash, index) => ({ + name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), + value: (index + 1).toString() + })); + dialog.showSelect('Are you sure you want to revert merge commit ' + abbrevCommit(hash) + '? Choose the parent hash on the main branch, to revert the commit relative to:', '1', options, 'Yes, revert', (parentIndex) => { + runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: parseInt(parentIndex) }, 'Reverting Commit'); + }, target); + } else { + dialog.showConfirmation('Are you sure you want to revert commit ' + abbrevCommit(hash) + '?', 'Yes, revert', () => { + runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: 0 }, 'Reverting Commit'); + }, target); + } + } + }, { + title: 'Drop' + ELLIPSIS, + visible: visibility.drop && this.graph.dropCommitPossible(this.commitLookup[hash]), + onClick: () => { + dialog.showConfirmation('Are you sure you want to permanently drop commit ' + abbrevCommit(hash) + '?' + (this.onlyFollowFirstParent ? '
    Note: By enabling "Only follow the first parent of commits", some commits may have been hidden from the Git Graph View that could affect the outcome of performing this action.' : ''), 'Yes, drop', () => { + runAction({ command: 'dropCommit', repo: this.currentRepo, commitHash: hash }, 'Dropping Commit'); + }, target); + } + } + ], [ + { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge, + onClick: () => this.mergeAction(hash, abbrevCommit(hash), GG.MergeActionOn.Commit, target) + }, { + title: 'Rebase current Branch on this Commit' + ELLIPSIS, + visible: visibility.rebase, + onClick: () => this.rebaseAction(hash, abbrevCommit(hash), GG.RebaseActionOn.Commit, target) + }, { + title: 'Reset current branch to this Commit' + ELLIPSIS, + visible: visibility.reset, + onClick: () => { + dialog.showSelect('Are you sure you want to reset ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' to commit ' + abbrevCommit(hash) + '?', this.config.dialogDefaults.resetCommit.mode, [ + { name: 'Soft - Keep all changes, but reset head', value: GG.GitResetMode.Soft }, + { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, + { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } + ], 'Yes, reset', (mode) => { + runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: hash, resetMode: mode }, 'Resetting to Commit'); + }, target); + } + } + ], [ + { + title: 'Copy Commit Hash to Clipboard', + visible: visibility.copyHash, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Commit Hash', data: hash }); + } + }, + { + title: 'Copy Commit Subject to Clipboard', + visible: visibility.copySubject, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Commit Subject', data: commit.message }); + } + } + ]]; + } + + private getRemoteBranchContextMenuActions(remote: string, target: DialogTarget & RefTarget): ContextMenuActions { + const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.remoteBranch; + const branchName = remote !== '' ? refName.substring(remote.length + 1) : ''; + const prefixedRefName = 'remotes/' + refName; + const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(prefixedRefName); + return [[ + { + title: 'Checkout Branch' + ELLIPSIS, + visible: visibility.checkout, + onClick: () => this.checkoutBranchAction(refName, remote, null, target) + }, { + title: 'Delete Remote Branch' + ELLIPSIS, + visible: visibility.delete && remote !== '', + onClick: () => { + dialog.showConfirmation('Are you sure you want to delete the remote branch ' + escapeHtml(refName) + '?', 'Yes, delete', () => { + runAction({ command: 'deleteRemoteBranch', repo: this.currentRepo, branchName: branchName, remote: remote }, 'Deleting Remote Branch'); + }, target); + } + }, { + title: 'Fetch into local branch' + ELLIPSIS, + visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, + onClick: () => { + dialog.showForm('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Force Fetch', + value: this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, + info: 'Force the local branch to be reset to this remote branch.' + }], 'Yes, fetch', (values) => { + runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: values[0] }, 'Fetching Branch'); + }, target); + } + }, { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge, + onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.RemoteTrackingBranch, target) + }, { + title: 'Pull into current branch' + ELLIPSIS, + visible: visibility.pull && remote !== '', + onClick: () => { + dialog.showForm('Are you sure you want to pull the remote branch ' + escapeHtml(refName) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '? If a merge is required:', [ + { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.pullBranch.noFastForward }, + { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.pullBranch.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this remote branch.' } + ], 'Yes, pull', (values) => { + runAction({ command: 'pullBranch', repo: this.currentRepo, branchName: branchName, remote: remote, createNewCommit: values[0], squash: values[1] }, 'Pulling Branch'); + }, target); + } + } + ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), + { + title: 'Create Pull Request', + visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' && + (this.gitRepos[this.currentRepo].pullRequestConfig!.sourceRemote === remote || this.gitRepos[this.currentRepo].pullRequestConfig!.destRemote === remote), + onClick: () => { + const config = this.gitRepos[this.currentRepo].pullRequestConfig; + if (config === null) return; + const isDestRemote = config.destRemote === remote; + runAction({ + command: 'createPullRequest', + repo: this.currentRepo, + config: config, + sourceRemote: isDestRemote ? config.destRemote! : config.sourceRemote, + sourceOwner: isDestRemote ? config.destOwner : config.sourceOwner, + sourceRepo: isDestRemote ? config.destRepo : config.sourceRepo, + sourceBranch: branchName, + push: false + }, 'Creating Pull Request'); + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); + } + }, + { + title: 'Select in Branches Dropdown', + visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.selectOption(prefixedRefName) + }, + { + title: 'Unselect in Branches Dropdown', + visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.unselectOption(prefixedRefName) + } + ], [ + { + title: 'Copy Branch Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); + } + } + ]]; + } + + private getStashContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { + const hash = target.hash, selector = target.ref, visibility = this.config.contextMenuActionsVisibility.stash; + return [[ + { + title: 'Apply Stash' + ELLIPSIS, + visible: visibility.apply, + onClick: () => { + dialog.showForm('Are you sure you want to apply the stash ' + escapeHtml(selector.substring(5)) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Reinstate Index', + value: this.config.dialogDefaults.applyStash.reinstateIndex, + info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' + }], 'Yes, apply stash', (values) => { + runAction({ command: 'applyStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Applying Stash'); + }, target); + } + }, { + title: 'Create Branch from Stash' + ELLIPSIS, + visible: visibility.createBranch, + onClick: () => { + dialog.showRefInput('Create a branch from stash ' + escapeHtml(selector.substring(5)) + ' with the name:', '', 'Create Branch', (branchName) => { + runAction({ command: 'branchFromStash', repo: this.currentRepo, selector: selector, branchName: branchName }, 'Creating Branch'); + }, target); + } + }, { + title: 'Pop Stash' + ELLIPSIS, + visible: visibility.pop, + onClick: () => { + dialog.showForm('Are you sure you want to pop the stash ' + escapeHtml(selector.substring(5)) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Reinstate Index', + value: this.config.dialogDefaults.popStash.reinstateIndex, + info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' + }], 'Yes, pop stash', (values) => { + runAction({ command: 'popStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Popping Stash'); + }, target); + } + }, { + title: 'Drop Stash' + ELLIPSIS, + visible: visibility.drop, + onClick: () => { + dialog.showConfirmation('Are you sure you want to drop the stash ' + escapeHtml(selector.substring(5)) + '?', 'Yes, drop', () => { + runAction({ command: 'dropStash', repo: this.currentRepo, selector: selector }, 'Dropping Stash'); + }, target); + } + } + ], [ + { + title: 'Copy Stash Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Stash Name', data: selector }); + } + }, { + title: 'Copy Stash Hash to Clipboard', + visible: visibility.copyHash, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Stash Hash', data: hash }); + } + } + ]]; + } + + private getTagContextMenuActions(isAnnotated: boolean, target: DialogTarget & RefTarget): ContextMenuActions { + const hash = target.hash, tagName = target.ref, visibility = this.config.contextMenuActionsVisibility.tag; + return [[ + { + title: 'View Details', + visible: visibility.viewDetails && isAnnotated, + onClick: () => { + runAction({ command: 'tagDetails', repo: this.currentRepo, tagName: tagName, commitHash: hash }, 'Retrieving Tag Details'); + } + }, { + title: 'Delete Tag' + ELLIPSIS, + visible: visibility.delete, + onClick: () => { + let message = 'Are you sure you want to delete the tag ' + escapeHtml(tagName) + '?'; + if (this.gitRemotes.length > 1) { + let options = [{ name: 'Don\'t delete on any remote', value: '-1' }]; + this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); + dialog.showSelect(message + '
    Do you also want to delete the tag on a remote:', '-1', options, 'Yes, delete', remoteIndex => { + this.deleteTagAction(tagName, remoteIndex !== '-1' ? this.gitRemotes[parseInt(remoteIndex)] : null); + }, target); + } else if (this.gitRemotes.length === 1) { + dialog.showCheckbox(message, 'Also delete on remote', false, 'Yes, delete', deleteOnRemote => { + this.deleteTagAction(tagName, deleteOnRemote ? this.gitRemotes[0] : null); + }, target); + } else { + dialog.showConfirmation(message, 'Yes, delete', () => { + this.deleteTagAction(tagName, null); + }, target); + } + } + }, { + title: 'Push Tag' + ELLIPSIS, + visible: visibility.push && this.gitRemotes.length > 0, + onClick: () => { + const runPushTagAction = (remotes: string[]) => { + runAction({ + command: 'pushTag', + repo: this.currentRepo, + tagName: tagName, + remotes: remotes, + commitHash: hash, + skipRemoteCheck: globalState.pushTagSkipRemoteCheck + }, 'Pushing Tag'); + }; + + if (this.gitRemotes.length === 1) { + dialog.showConfirmation('Are you sure you want to push the tag ' + escapeHtml(tagName) + ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '?', 'Yes, push', () => { + runPushTagAction([this.gitRemotes[0]]); + }, target); + } else if (this.gitRemotes.length > 1) { + const defaults = [this.getPushRemote()]; + const options = this.gitRemotes.map((remote) => ({ name: remote, value: remote })); + dialog.showMultiSelect('Are you sure you want to push the tag ' + escapeHtml(tagName) + '? Select the remote(s) to push the tag to:', defaults, options, 'Yes, push', (remotes) => { + runPushTagAction(remotes); + }, target); + } + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: tagName }, 'Creating Archive'); + } + }, + { + title: 'Copy Tag Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Tag Name', data: tagName }); + } + } + ]]; + } + + private getUncommittedChangesContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { + let visibility = this.config.contextMenuActionsVisibility.uncommittedChanges; + return [[ + { + title: 'Stash uncommitted changes' + ELLIPSIS, + visible: visibility.stash, + onClick: () => { + dialog.showForm('Are you sure you want to stash the uncommitted changes?', [ + { type: DialogInputType.Text, name: 'Message', default: '', placeholder: 'Optional' }, + { type: DialogInputType.Checkbox, name: 'Include Untracked', value: this.config.dialogDefaults.stashUncommittedChanges.includeUntracked, info: 'Include all untracked files in the stash, and then clean them from the working directory.' } + ], 'Yes, stash', (values) => { + runAction({ command: 'pushStash', repo: this.currentRepo, message: values[0], includeUntracked: values[1] }, 'Stashing uncommitted changes'); + }, target); + } + } + ], [ + { + title: 'Reset uncommitted changes' + ELLIPSIS, + visible: visibility.reset, + onClick: () => { + dialog.showSelect('Are you sure you want to reset the uncommitted changes to HEAD?', this.config.dialogDefaults.resetUncommitted.mode, [ + { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, + { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } + ], 'Yes, reset', (mode) => { + runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: 'HEAD', resetMode: mode }, 'Resetting uncommitted changes'); + }, target); + } + }, { + title: 'Clean untracked files' + ELLIPSIS, + visible: visibility.clean, + onClick: () => { + dialog.showCheckbox('Are you sure you want to clean all untracked files?', 'Clean untracked directories', true, 'Yes, clean', directories => { + runAction({ command: 'cleanUntrackedFiles', repo: this.currentRepo, directories: directories }, 'Cleaning untracked files'); + }, target); + } + } + ], [ + { + title: 'Open Source Control View', + visible: visibility.openSourceControlView, + onClick: () => { + sendMessage({ command: 'viewScm' }); + } + } + ]]; + } + + private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction { + const issueLinks: { url: string, displayText: string }[] = []; + + let issueLinking: IssueLinking | null, match: RegExpExecArray | null; + if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) { + issueLinking.regexp.lastIndex = 0; + while (match = issueLinking.regexp.exec(refName)) { + if (match[0].length === 0) break; + issueLinks.push({ + url: generateIssueLinkFromMatch(match, issueLinking), + displayText: match[0] + }); + } + } + + return { + title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''), + visible: issueLinks.length > 0, + onClick: () => { + if (issueLinks.length > 1) { + dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => { + sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url }); + }, target); + } else if (issueLinks.length === 1) { + sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url }); + } + } + }; + } + + + /* Actions */ + + private addTagAction(hash: string, initialName: string, initialType: GG.TagType, initialMessage: string, initialPushToRemote: string | null, target: DialogTarget & CommitTarget, isInitialLoad: boolean = true) { + let mostRecentTagsIndex = -1; + for (let i = 0; i < this.commits.length; i++) { + if (this.commits[i].tags.length > 0 && (mostRecentTagsIndex === -1 || this.commits[i].date > this.commits[mostRecentTagsIndex].date)) { + mostRecentTagsIndex = i; + } + } + const mostRecentTags = mostRecentTagsIndex > -1 ? this.commits[mostRecentTagsIndex].tags.map((tag) => '"' + tag.name + '"') : []; + + const inputs: DialogInput[] = [ + { type: DialogInputType.TextRef, name: 'Name', default: initialName, info: mostRecentTags.length > 0 ? 'The most recent tag' + (mostRecentTags.length > 1 ? 's' : '') + ' in the loaded commits ' + (mostRecentTags.length > 1 ? 'are' : 'is') + ' ' + formatCommaSeparatedList(mostRecentTags) + '.' : undefined }, + { type: DialogInputType.Select, name: 'Type', default: initialType === GG.TagType.Annotated ? 'annotated' : 'lightweight', options: [{ name: 'Annotated', value: 'annotated' }, { name: 'Lightweight', value: 'lightweight' }] }, + { type: DialogInputType.Text, name: 'Message', default: initialMessage, placeholder: 'Optional', info: 'A message can only be added to an annotated tag.' } + ]; + if (this.gitRemotes.length > 1) { + const options = [{ name: 'Don\'t push', value: '-1' }]; + this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); + const defaultOption = initialPushToRemote !== null + ? this.gitRemotes.indexOf(initialPushToRemote) + : isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote + ? this.gitRemotes.indexOf(this.getPushRemote()) + : -1; + inputs.push({ type: DialogInputType.Select, name: 'Push to remote', options: options, default: defaultOption.toString(), info: 'Once this tag has been added, push it to this remote.' }); + } else if (this.gitRemotes.length === 1) { + const defaultValue = initialPushToRemote !== null || (isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote); + inputs.push({ type: DialogInputType.Checkbox, name: 'Push to remote', value: defaultValue, info: 'Once this tag has been added, push it to the repositories remote.' }); + } + + dialog.showForm('Add tag to commit ' + abbrevCommit(hash) + ':', inputs, 'Add Tag', (values) => { + const tagName = values[0]; + const type = values[1] === 'annotated' ? GG.TagType.Annotated : GG.TagType.Lightweight; + const message = values[2]; + const pushToRemote = this.gitRemotes.length > 1 && values[3] !== '-1' + ? this.gitRemotes[parseInt(values[3])] + : this.gitRemotes.length === 1 && values[3] + ? this.gitRemotes[0] + : null; + + const runAddTagAction = (force: boolean) => { + runAction({ + command: 'addTag', + repo: this.currentRepo, + tagName: tagName, + commitHash: hash, + type: type, + message: message, + pushToRemote: pushToRemote, + pushSkipRemoteCheck: globalState.pushTagSkipRemoteCheck, + force: force + }, 'Adding Tag'); + }; + + if (this.gitTags.includes(tagName)) { + dialog.showTwoButtons('A tag named ' + escapeHtml(tagName) + ' already exists, do you want to replace it with this new tag?', 'Yes, replace the existing tag', () => { + runAddTagAction(true); + }, 'No, choose another tag name', () => { + this.addTagAction(hash, tagName, type, message, pushToRemote, target, false); + }, target); + } else { + runAddTagAction(false); + } + }, target); + } + + private checkoutBranchAction(refName: string, remote: string | null, prefillName: string | null, target: DialogTarget & (CommitTarget | RefTarget)) { + if (remote !== null) { + dialog.showRefInput('Enter the name of the new branch you would like to create when checking out ' + escapeHtml(refName) + ':', (prefillName !== null ? prefillName : (remote !== '' ? refName.substring(remote.length + 1) : refName)), 'Checkout Branch', newBranch => { + if (this.gitBranches.includes(newBranch)) { + const canPullFromRemote = remote !== ''; + dialog.showTwoButtons('The name ' + escapeHtml(newBranch) + ' is already used by another branch:', 'Choose another branch name', () => { + this.checkoutBranchAction(refName, remote, newBranch, target); + }, 'Checkout the existing branch' + (canPullFromRemote ? ' & pull changes' : ''), () => { + runAction({ + command: 'checkoutBranch', + repo: this.currentRepo, + branchName: newBranch, + remoteBranch: null, + pullAfterwards: canPullFromRemote + ? { + branchName: refName.substring(remote.length + 1), + remote: remote, + createNewCommit: this.config.dialogDefaults.pullBranch.noFastForward, + squash: this.config.dialogDefaults.pullBranch.squash + } + : null + }, 'Checking out Branch' + (canPullFromRemote ? ' & Pulling Changes' : '')); + }, target); + } else { + runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: newBranch, remoteBranch: refName, pullAfterwards: null }, 'Checking out Branch'); + } + }, target); + } else { + runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: refName, remoteBranch: null, pullAfterwards: null }, 'Checking out Branch'); + } + } + + private createBranchAction(hash: string, initialName: string, initialCheckOut: boolean, target: DialogTarget & CommitTarget) { + dialog.showForm('Create branch at commit ' + abbrevCommit(hash) + ':', [ + { type: DialogInputType.TextRef, name: 'Name', default: initialName }, + { type: DialogInputType.Checkbox, name: 'Check out', value: initialCheckOut } + ], 'Create Branch', (values) => { + const branchName = values[0], checkOut = values[1]; + if (this.gitBranches.includes(branchName)) { + dialog.showTwoButtons('A branch named ' + escapeHtml(branchName) + ' already exists, do you want to replace it with this new branch?', 'Yes, replace the existing branch', () => { + runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: true }, 'Creating Branch'); + }, 'No, choose another branch name', () => { + this.createBranchAction(hash, branchName, checkOut, target); + }, target); + } else { + runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: false }, 'Creating Branch'); + } + }, target); + } + + private deleteTagAction(refName: string, deleteOnRemote: string | null) { + runAction({ command: 'deleteTag', repo: this.currentRepo, tagName: refName, deleteOnRemote: deleteOnRemote }, 'Deleting Tag'); + } + + private fetchFromRemotesAction() { + runAction({ command: 'fetch', repo: this.currentRepo, name: null, prune: this.config.fetchAndPrune, pruneTags: this.config.fetchAndPruneTags }, 'Fetching from Remote(s)'); + } + + private mergeAction(obj: string, name: string, actionOn: GG.MergeActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { + dialog.showForm('Are you sure you want to merge ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '?', [ + { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.merge.noFastForward }, + { type: DialogInputType.Checkbox, name: 'Allow unrelated histories', value: this.config.dialogDefaults.merge.allowUnrelatedHistories, info: 'Allow merging branches from two completely different repositories or branches.' }, + { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.merge.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this ' + actionOn.toLowerCase() + '.' }, + { type: DialogInputType.Checkbox, name: 'No Commit', value: this.config.dialogDefaults.merge.noCommit, info: 'The changes of the merge will be staged but not committed, so that you can review and/or modify the merge result before committing.' } + ], 'Yes, merge', (values) => { + runAction({ command: 'merge', repo: this.currentRepo, obj: obj, actionOn: actionOn, createNewCommit: values[0], allowUnrelatedHistories: values[1], squash: values[2], noCommit: values[3] }, 'Merging ' + actionOn); + }, target); + } + + private rebaseAction(obj: string, name: string, actionOn: GG.RebaseActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { + dialog.showForm('Are you sure you want to rebase ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' on ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + '?', [ + { type: DialogInputType.Checkbox, name: 'Interactive Rebase (launch in new Terminal)', value: this.config.dialogDefaults.rebase.interactive }, + { type: DialogInputType.Checkbox, name: 'Ignore Date', value: this.config.dialogDefaults.rebase.ignoreDate, info: 'Only applicable to a non-interactive rebase.' } + ], 'Yes, rebase', (values) => { + let interactive = values[0]; + runAction({ command: 'rebase', repo: this.currentRepo, obj: obj, actionOn: actionOn, ignoreDate: values[1], interactive: interactive }, interactive ? 'Launching Interactive Rebase' : 'Rebasing on ' + actionOn); + }, target); + } + + + /* Table Utils */ + + private makeTableResizable() { + let colHeadersElem = document.getElementById('tableColHeaders')!, cols = >document.getElementsByClassName('tableColHeader'); + let columnWidths: GG.ColumnWidth[], mouseX = -1, col = -1, colIndex = -1; + + const makeTableFixedLayout = () => { + cols[0].style.width = columnWidths[0] + 'px'; + cols[0].style.padding = ''; + for (let i = 2; i < cols.length; i++) { + cols[i].style.width = columnWidths[parseInt(cols[i].dataset.col!)] + 'px'; + } + this.tableElem.className = 'fixedLayout'; + this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); + this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); + }; + + for (let i = 0; i < cols.length; i++) { + let col = parseInt(cols[i].dataset.col!); + cols[i].innerHTML += (i > 0 ? '' : '') + (i < cols.length - 1 ? '' : ''); + } + + let cWidths = this.gitRepos[this.currentRepo].columnWidths; + if (cWidths === null) { // Initialise auto column layout if it is the first time viewing the repo. + let defaults = this.config.defaultColumnVisibility; + columnWidths = [COLUMN_AUTO, COLUMN_AUTO, defaults.date ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.author ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.commit ? COLUMN_AUTO : COLUMN_HIDDEN]; + this.saveColumnWidths(columnWidths); + } else { + columnWidths = [cWidths[0], COLUMN_AUTO, cWidths[1], cWidths[2], cWidths[3]]; + } + + if (columnWidths[0] !== COLUMN_AUTO) { + // Table should have fixed layout + makeTableFixedLayout(); + } else { + // Table should have automatic layout + this.tableElem.className = 'autoLayout'; + + let colWidth = cols[0].offsetWidth, graphWidth = this.graph.getContentWidth(); + let maxWidth = Math.round(this.viewElem.clientWidth * 0.333); + if (Math.max(graphWidth, colWidth) > maxWidth) { + this.graph.limitMaxWidth(maxWidth); + graphWidth = maxWidth; + this.tableElem.className += ' limitGraphWidth'; + this.tableElem.style.setProperty(CSS_PROP_LIMIT_GRAPH_WIDTH, maxWidth + 'px'); + } else { + this.graph.limitMaxWidth(-1); + this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); + } + + if (colWidth < Math.max(graphWidth, 64)) { + cols[0].style.padding = '6px ' + Math.floor((Math.max(graphWidth, 64) - (colWidth - COLUMN_LEFT_RIGHT_PADDING)) / 2) + 'px'; + } + } + + const processResizingColumn: EventListener = (e) => { + if (col > -1) { + let mouseEvent = e; + let mouseDeltaX = mouseEvent.clientX - mouseX; + + if (col === 0) { + if (columnWidths[0] + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -columnWidths[0] + COLUMN_MIN_WIDTH; + if (cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - COLUMN_MIN_WIDTH; + columnWidths[0] += mouseDeltaX; + cols[0].style.width = columnWidths[0] + 'px'; + this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); + } else { + let colWidth = col !== 1 ? columnWidths[col] : cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING; + let nextCol = col + 1; + while (columnWidths[nextCol] === COLUMN_HIDDEN) nextCol++; + + if (colWidth + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -colWidth + COLUMN_MIN_WIDTH; + if (columnWidths[nextCol] - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = columnWidths[nextCol] - COLUMN_MIN_WIDTH; + if (col !== 1) { + columnWidths[col] += mouseDeltaX; + cols[colIndex].style.width = columnWidths[col] + 'px'; + } + columnWidths[nextCol] -= mouseDeltaX; + cols[colIndex + 1].style.width = columnWidths[nextCol] + 'px'; + } + mouseX = mouseEvent.clientX; + } + }; + const stopResizingColumn: EventListener = () => { + if (col > -1) { + col = -1; + colIndex = -1; + mouseX = -1; + eventOverlay.remove(); + this.saveColumnWidths(columnWidths); + } + }; + + addListenerToClass('resizeCol', 'mousedown', (e) => { + if (e.target === null) return; + col = parseInt((e.target).dataset.col!); + while (columnWidths[col] === COLUMN_HIDDEN) col--; + mouseX = (e).clientX; + + let isAuto = columnWidths[0] === COLUMN_AUTO; + for (let i = 0; i < cols.length; i++) { + let curCol = parseInt(cols[i].dataset.col!); + if (isAuto && curCol !== 1) columnWidths[curCol] = cols[i].clientWidth - COLUMN_LEFT_RIGHT_PADDING; + if (curCol === col) colIndex = i; + } + if (isAuto) makeTableFixedLayout(); + eventOverlay.create('colResize', processResizingColumn, stopResizingColumn); + }); + + colHeadersElem.addEventListener('contextmenu', (e: MouseEvent) => { + handledEvent(e); + + const toggleColumnState = (col: number, defaultWidth: number) => { + columnWidths[col] = columnWidths[col] !== COLUMN_HIDDEN ? COLUMN_HIDDEN : columnWidths[0] === COLUMN_AUTO ? COLUMN_AUTO : defaultWidth - COLUMN_LEFT_RIGHT_PADDING; + this.saveColumnWidths(columnWidths); + this.render(); + }; + + const commitOrdering = getCommitOrdering(this.gitRepos[this.currentRepo].commitOrdering); + const changeCommitOrdering = (repoCommitOrdering: GG.RepoCommitOrdering) => { + this.saveRepoStateValue(this.currentRepo, 'commitOrdering', repoCommitOrdering); + this.refresh(true); + }; + + contextMenu.show([ + [ + { + title: 'Date', + visible: true, + checked: columnWidths[2] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(2, 128) + }, + { + title: 'Author', + visible: true, + checked: columnWidths[3] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(3, 128) + }, + { + title: 'Commit', + visible: true, + checked: columnWidths[4] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(4, 80) + } + ], + [ + { + title: 'Commit Timestamp Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.Date, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Date) + }, + { + title: 'Author Timestamp Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.AuthorDate, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.AuthorDate) + }, + { + title: 'Topological Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.Topological, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Topological) + } + ] + ], true, null, e, this.viewElem); + }); + } + + public getColumnVisibility() { + let colWidths = this.gitRepos[this.currentRepo].columnWidths; + if (colWidths !== null) { + return { date: colWidths[1] !== COLUMN_HIDDEN, author: colWidths[2] !== COLUMN_HIDDEN, commit: colWidths[3] !== COLUMN_HIDDEN }; + } else { + let defaults = this.config.defaultColumnVisibility; + return { date: defaults.date, author: defaults.author, commit: defaults.commit }; + } + } + + private getNumColumns() { + let colVisibility = this.getColumnVisibility(); + return 2 + (colVisibility.date ? 1 : 0) + (colVisibility.author ? 1 : 0) + (colVisibility.commit ? 1 : 0); + } + + /** + * Scroll the view to the previous or next stash. + * @param next TRUE => Jump to the next stash, FALSE => Jump to the previous stash. + */ + private scrollToStash(next: boolean) { + const stashCommits = this.commits.filter((commit) => commit.stash !== null); + if (stashCommits.length > 0) { + const curTime = (new Date()).getTime(); + if (this.lastScrollToStash.time < curTime - 5000) { + // Reset the lastScrollToStash hash if it was more than 5 seconds ago + this.lastScrollToStash.hash = null; + } + + const lastScrollToStashCommitIndex = this.lastScrollToStash.hash !== null + ? stashCommits.findIndex((commit) => commit.hash === this.lastScrollToStash.hash) + : -1; + let scrollToStashCommitIndex = lastScrollToStashCommitIndex + (next ? 1 : -1); + if (scrollToStashCommitIndex >= stashCommits.length) { + scrollToStashCommitIndex = 0; + } else if (scrollToStashCommitIndex < 0) { + scrollToStashCommitIndex = stashCommits.length - 1; + } + this.scrollToCommit(stashCommits[scrollToStashCommitIndex].hash, true, true); + this.lastScrollToStash.time = curTime; + this.lastScrollToStash.hash = stashCommits[scrollToStashCommitIndex].hash; + } + } + + /** + * Scroll the view to a commit (if it exists). + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + const elem = findCommitElemWithId(getCommitElems(), this.getCommitId(hash)); + if (elem === null) { + if (persistently) { + // Scroll to the last loaded commit for trigger loadMoreCommits() + const commits = document.getElementsByClassName('commit'); + if (commits.length === 0) { + return; + } + const lastCommit = commits[commits.length - 1]; + lastCommit.scrollIntoView(); + + // Recursive call + setTimeout(() => { + this.scrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); + }, 500); + } + // Do nothing + return; + } + let elemTop = this.controlsElem.clientHeight + elem.offsetTop; + if (alwaysCenterCommit || elemTop - 8 < this.viewElem.scrollTop || elemTop + 32 - this.viewElem.clientHeight > this.viewElem.scrollTop) { + this.viewElem.scroll(0, this.controlsElem.clientHeight + elem.offsetTop + 12 - this.viewElem.clientHeight / 2); + } + + if (flash && !elem.classList.contains('flash')) { + elem.classList.add('flash'); + setTimeout(() => { + elem.classList.remove('flash'); + }, 850); + } + + if (openDetails) { + this.loadCommitDetails(elem); + } + } + + private loadMoreCommits() { + this.footerElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; + this.maxCommits += this.config.loadMoreCommits; + this.saveState(); + this.requestLoadRepoInfoAndCommits(false, true); + } + + private alignTableHeaderToControls() { + if (!this.tableColHeadersElem) { + return; + } + } + + + /* Observers */ + + private observeWindowSizeChanges() { + let windowWidth = window.outerWidth, windowHeight = window.outerHeight; + window.addEventListener('resize', () => { + if (windowWidth === window.outerWidth && windowHeight === window.outerHeight) { + this.renderGraph(); + } else { + windowWidth = window.outerWidth; + windowHeight = window.outerHeight; + } + + if (this.config.stickyHeader) { + this.alignTableHeaderToControls(); + } + }); + } + + private observeWebviewStyleChanges() { + let fontFamily = getVSCodeStyle(CSS_PROP_FONT_FAMILY), + editorFontFamily = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), + findMatchColour = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), + selectionBackgroundColor = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); + + const setFlashColour = (colour: string) => { + document.body.style.setProperty('--git-graph-flashPrimary', modifyColourOpacity(colour, 0.7)); + document.body.style.setProperty('--git-graph-flashSecondary', modifyColourOpacity(colour, 0.5)); + }; + const setSelectionBackgroundColorExists = () => { + alterClass(document.body, 'selection-background-color-exists', selectionBackgroundColor); + }; + + this.findWidget.setColour(findMatchColour); + setFlashColour(findMatchColour); + setSelectionBackgroundColorExists(); + + (new MutationObserver(() => { + let ff = getVSCodeStyle(CSS_PROP_FONT_FAMILY), + eff = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), + fmc = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), + sbc = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); + + if (ff !== fontFamily || eff !== editorFontFamily) { + fontFamily = ff; + editorFontFamily = eff; + this.repoDropdown.refresh(); + this.branchDropdown.refresh(); + this.authorDropdown.refresh(); + } + if (fmc !== findMatchColour) { + findMatchColour = fmc; + this.findWidget.setColour(findMatchColour); + setFlashColour(findMatchColour); + } + if (selectionBackgroundColor !== sbc) { + selectionBackgroundColor = sbc; + setSelectionBackgroundColorExists(); + } + })).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); + } + + private observeViewScroll() { + let active = this.viewElem.scrollTop > 0, timeout: NodeJS.Timer | null = null; + this.viewElem.addEventListener('scroll', () => { + const scrollTop = this.viewElem.scrollTop; + if (active !== scrollTop > 0) { + active = scrollTop > 0; + } + + if (this.config.loadMoreCommitsAutomatically && this.moreCommitsAvailable && !this.currentRepoRefreshState.inProgress) { + const viewHeight = this.viewElem.clientHeight, contentHeight = this.viewElem.scrollHeight; + if (scrollTop > 0 && viewHeight > 0 && contentHeight > 0 && (scrollTop + viewHeight) >= contentHeight - 25) { + // If the user has scrolled such that the bottom of the visible view is within 25px of the end of the content, load more commits. + this.loadMoreCommits(); + } + } + + if (timeout !== null) clearTimeout(timeout); + timeout = setTimeout(() => { + this.scrollTop = scrollTop; + this.saveState(); + timeout = null; + }, 250); + }); + } + + private observeKeyboardEvents() { + document.addEventListener('keydown', (e) => { + if (contextMenu.isOpen()) { + if (e.key === 'Escape') { + contextMenu.close(); + handledEvent(e); + } + } else if (dialog.isOpen()) { + if (e.key === 'Escape') { + dialog.close(); + handledEvent(e); + } else if (e.keyCode ? e.keyCode === 13 : e.key === 'Enter') { + // Use keyCode === 13 to detect 'Enter' events if available (for compatibility with IME Keyboards used by Chinese / Japanese / Korean users) + dialog.submit(); + handledEvent(e); + } + } else if (this.expandedCommit !== null && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const curHashIndex = this.commitLookup[this.expandedCommit.commitHash]; + let newHashIndex = -1; + + if (e.ctrlKey || e.metaKey) { + // Up / Down navigates according to the order of commits on the branch + if (e.shiftKey) { + // Follow commits on alternative branches when possible + if (e.key === 'ArrowUp') { + newHashIndex = this.graph.getAlternativeChildIndex(curHashIndex); + } else if (e.key === 'ArrowDown') { + newHashIndex = this.graph.getAlternativeParentIndex(curHashIndex); + } + } else { + // Follow commits on the same branch + if (e.key === 'ArrowUp') { + newHashIndex = this.graph.getFirstChildIndex(curHashIndex); + } else if (e.key === 'ArrowDown') { + newHashIndex = this.graph.getFirstParentIndex(curHashIndex); + } + } + } else { + // Up / Down navigates according to the order of commits in the table + if (e.key === 'ArrowUp' && curHashIndex > 0) { + newHashIndex = curHashIndex - 1; + } else if (e.key === 'ArrowDown' && curHashIndex < this.commits.length - 1) { + newHashIndex = curHashIndex + 1; + } + } + + if (newHashIndex > -1) { + handledEvent(e); + const elem = findCommitElemWithId(getCommitElems(), newHashIndex); + if (elem !== null) this.loadCommitDetails(elem); + } + } else if (e.key && (e.ctrlKey || e.metaKey)) { + const key = e.key.toLowerCase(), keybindings = this.config.keybindings; + if (key === keybindings.scrollToStash) { + this.scrollToStash(!e.shiftKey); + handledEvent(e); + } else if (!e.shiftKey) { + if (key === keybindings.refresh) { + this.refresh(true, true); + handledEvent(e); + } else if (key === keybindings.find) { + this.findWidget.show(true); + handledEvent(e); + } else if (key === keybindings.scrollToHead && this.commitHead !== null) { + this.scrollToCommit(this.commitHead, true, true); + handledEvent(e); + } + } + } else if (e.key === 'Escape') { + if (this.repoDropdown.isOpen()) { + this.repoDropdown.close(); + handledEvent(e); + } else if (this.branchDropdown.isOpen()) { + this.branchDropdown.close(); + handledEvent(e); + } else if (this.authorDropdown.isOpen()) { + this.authorDropdown.close(); + handledEvent(e); + } else if (this.settingsWidget.isVisible()) { + this.settingsWidget.close(); + handledEvent(e); + } else if (this.findWidget.isVisible()) { + this.findWidget.close(); + handledEvent(e); + } else if (this.expandedCommit !== null) { + this.closeCommitDetails(true); + handledEvent(e); + } + } + }); + } + + private observeUrls() { + const followInternalLink = (e: MouseEvent) => { + if (e.target !== null && isInternalUrlElem(e.target)) { + const value = unescapeHtml((e.target).dataset.value!); + switch ((e.target).dataset.type!) { + case 'commit': + if (typeof this.commitLookup[value] === 'number' && (this.expandedCommit === null || this.expandedCommit.commitHash !== value || this.expandedCommit.compareWithHash !== null)) { + const elem = findCommitElemWithId(getCommitElems(), this.commitLookup[value]); + if (elem !== null) this.loadCommitDetails(elem); + } + break; + } + } + }; + + document.body.addEventListener('click', followInternalLink); + + document.body.addEventListener('contextmenu', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + + const isExternalUrl = isExternalUrlElem(eventTarget), isInternalUrl = isInternalUrlElem(eventTarget); + if (isExternalUrl || isInternalUrl) { + const viewElem: HTMLElement | null = eventTarget.closest('#view'); + let eventElem: HTMLElement | null; + + let target: (ContextMenuTarget & CommitTarget) | RepoTarget, isInDialog = false; + if (this.expandedCommit !== null && eventTarget.closest('#cdv') !== null) { + // URL is in the Commit Details View + target = { + type: TargetType.CommitDetailsView, + hash: this.expandedCommit.commitHash, + index: this.commitLookup[this.expandedCommit.commitHash], + elem: eventTarget + }; + GitGraphView.closeCdvContextMenuIfOpen(this.expandedCommit); + this.expandedCommit.contextMenuOpen.summary = true; + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // URL is in the Commits + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + target = { + type: TargetType.Commit, + hash: commit.hash, + index: parseInt(eventElem.dataset.id!), + elem: eventTarget + }; + } else { + // URL is in a dialog + target = { + type: TargetType.Repo + }; + isInDialog = true; + } + + handledEvent(e); + contextMenu.show([ + [ + { + title: 'Open URL', + visible: isExternalUrl, + onClick: () => { + sendMessage({ command: 'openExternalUrl', url: (eventTarget).href }); + } + }, + { + title: 'Follow Internal Link', + visible: isInternalUrl, + onClick: () => followInternalLink(e) + }, + { + title: 'Copy URL to Clipboard', + visible: isExternalUrl, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'External URL', data: (eventTarget).href }); + } + } + ] + ], false, target, e, viewElem || document.body, () => { + if (target.type === TargetType.CommitDetailsView && this.expandedCommit !== null) { + this.expandedCommit.contextMenuOpen.summary = false; + } + }, isInDialog ? 'dialogContextMenu' : null); + } + }); + } + + private observeTableEvents() { + + // Register Click Event Handler + this.tableElem.addEventListener('click', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was clicked + e.stopPropagation(); + if (contextMenu.isOpen()) { + contextMenu.close(); + } + + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // .commit was clicked + if (this.expandedCommit !== null) { + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + + if (this.expandedCommit.commitHash === commit.hash) { + this.closeCommitDetails(true); + } else if ((e).ctrlKey || (e).metaKey) { + if (this.expandedCommit.compareWithHash === commit.hash) { + this.closeCommitComparison(true); + } else if (this.expandedCommit.commitElem !== null) { + this.loadCommitComparison(this.expandedCommit.commitElem, eventElem); + } + } else { + this.loadCommitDetails(eventElem); + } + } else { + this.loadCommitDetails(eventElem); + } + } + }); + + // Register Double Click Event Handler + this.tableElem.addEventListener('dblclick', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was double clicked + e.stopPropagation(); + closeDialogAndContextMenu(); + const commitElem = eventElem.closest('.commit')!; + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + if (eventElem.classList.contains(CLASS_REF_HEAD) || eventElem.classList.contains(CLASS_REF_REMOTE)) { + let sourceElem = eventElem.children[1]; + let refName = unescapeHtml(eventElem.dataset.name!), isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); + if (isHead && isRemoteCombinedWithHead) { + refName = unescapeHtml((eventTarget).dataset.fullref!); + sourceElem = eventTarget; + isHead = false; + } + + const target: ContextMenuTarget & DialogTarget & RefTarget = { + type: TargetType.Ref, + hash: commit.hash, + index: parseInt(commitElem.dataset.id!), + ref: refName, + elem: sourceElem + }; + + this.checkoutBranchAction(refName, isHead ? null : unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!), null, target); + } + } + }); + + // Register ContextMenu Event Handler + this.tableElem.addEventListener('contextmenu', (e: Event) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was right clicked + handledEvent(e); + const commitElem = eventElem.closest('.commit')!; + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + const target: ContextMenuTarget & DialogTarget & RefTarget = { + type: TargetType.Ref, + hash: commit.hash, + index: parseInt(commitElem.dataset.id!), + ref: unescapeHtml(eventElem.dataset.name!), + elem: eventElem.children[1] + }; + + let actions: ContextMenuActions; + if (eventElem.classList.contains(CLASS_REF_STASH)) { + actions = this.getStashContextMenuActions(target); + } else if (eventElem.classList.contains(CLASS_REF_TAG)) { + actions = this.getTagContextMenuActions(eventElem.dataset.tagtype === 'annotated', target); + } else { + let isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); + if (isHead && isRemoteCombinedWithHead) { + target.ref = unescapeHtml((eventTarget).dataset.fullref!); + target.elem = eventTarget; + isHead = false; + } + if (isHead) { + actions = this.getBranchContextMenuActions(target); + } else { + const remote = unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!); + actions = this.getRemoteBranchContextMenuActions(remote, target); + } + } + + contextMenu.show(actions, false, target, e, this.viewElem); + + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // .commit was right clicked + handledEvent(e); + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + + const target: ContextMenuTarget & DialogTarget & CommitTarget = { + type: TargetType.Commit, + hash: commit.hash, + index: parseInt(eventElem.dataset.id!), + elem: eventElem + }; + + let actions: ContextMenuActions; + if (commit.hash === UNCOMMITTED) { + actions = this.getUncommittedChangesContextMenuActions(target); + } else if (commit.stash !== null) { + target.ref = commit.stash.selector; + actions = this.getStashContextMenuActions(target); + } else { + actions = this.getCommitContextMenuActions(target); + } + + contextMenu.show(actions, false, target, e, this.viewElem); + } + }); + } + + + /* Commit Details View */ + + public loadCommitDetails(commitElem: HTMLElement) { + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + this.closeCommitDetails(false); + this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, null, null); + commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + this.renderCommitDetailsView(false); + this.requestCommitDetails(commit.hash, false); + } + + public closeCommitDetails(saveAndRender: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + const elem = document.getElementById('cdv'), isDocked = this.isCdvDocked(); + if (elem !== null) { + elem.remove(); + } + if (isDocked) { + this.viewElem.style.bottom = '0px'; + } + if (expandedCommit.commitElem !== null) { + expandedCommit.commitElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + if (expandedCommit.compareWithElem !== null) { + expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + this.expandedCommit = null; + if (saveAndRender) { + this.saveState(); + if (!isDocked) { + this.renderGraph(); + } + } + } + + public showCommitDetails(commitDetails: GG.GitCommitDetails, fileTree: FileTreeFolder, avatar: string | null, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.commitHash !== commitDetails.hash || expandedCommit.compareWithHash !== null) return; + + if (!this.isCdvDocked()) { + const elem = document.getElementById('cdv'); + if (elem !== null) elem.remove(); + } + + expandedCommit.commitDetails = commitDetails; + if (haveFilesChanged(expandedCommit.fileChanges, commitDetails.fileChanges)) { + expandedCommit.fileChanges = commitDetails.fileChanges; + expandedCommit.fileTree = fileTree; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + } + expandedCommit.avatar = avatar; + expandedCommit.codeReview = codeReview; + if (!refresh) { + expandedCommit.lastViewedFile = lastViewedFile; + } + expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.loading = false; + this.saveState(); + + this.renderCommitDetailsView(refresh); + } + + public createFileTree(gitFiles: ReadonlyArray, codeReview: GG.CodeReview | null) { + let contents: FileTreeFolderContents = {}, i, j, path, absPath, cur: FileTreeFolder; + let files: FileTreeFolder = { type: 'folder', name: '', folderPath: '', contents: contents, open: true, reviewed: true }; + + for (i = 0; i < gitFiles.length; i++) { + cur = files; + path = gitFiles[i].newFilePath.split('/'); + absPath = this.currentRepo; + for (j = 0; j < path.length; j++) { + absPath += '/' + path[j]; + if (typeof this.gitRepos[absPath] !== 'undefined') { + if (typeof cur.contents[path[j]] === 'undefined') { + cur.contents[path[j]] = { type: 'repo', name: path[j], path: absPath }; + } + break; + } else if (j < path.length - 1) { + if (typeof cur.contents[path[j]] === 'undefined') { + contents = {}; + cur.contents[path[j]] = { type: 'folder', name: path[j], folderPath: absPath.substring(this.currentRepo.length + 1), contents: contents, open: true, reviewed: true }; + } + cur = cur.contents[path[j]]; + } else if (path[j] !== '') { + cur.contents[path[j]] = { type: 'file', name: path[j], index: i, reviewed: codeReview === null || !codeReview.remainingFiles.includes(gitFiles[i].newFilePath) }; + } + } + } + if (codeReview !== null) calcFileTreeFoldersReviewed(files); + return files; + } + + + /* Commit Comparison View */ + + private loadCommitComparison(commitElem: HTMLElement, compareWithElem: HTMLElement) { + const commit = this.getCommitOfElem(commitElem); + const compareWithCommit = this.getCommitOfElem(compareWithElem); + + if (commit !== null && compareWithCommit !== null) { + if (this.expandedCommit !== null) { + if (this.expandedCommit.commitHash !== commit.hash) { + this.closeCommitDetails(false); + } else if (this.expandedCommit.compareWithHash !== compareWithCommit.hash) { + this.closeCommitComparison(false); + } + } + + this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, compareWithCommit.hash, compareWithElem); + commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + this.renderCommitDetailsView(false); + this.requestCommitComparison(commit.hash, compareWithCommit.hash, false); + } + } + + public closeCommitComparison(saveAndRequestCommitDetails: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.compareWithHash === null) return; + + if (expandedCommit.compareWithElem !== null) { + expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + if (saveAndRequestCommitDetails) { + if (expandedCommit.commitElem !== null) { + this.saveExpandedCommitLoading(expandedCommit.index, expandedCommit.commitHash, expandedCommit.commitElem, null, null); + this.renderCommitDetailsView(false); + this.requestCommitDetails(expandedCommit.commitHash, false); + } else { + this.closeCommitDetails(true); + } + } + } + + public showCommitComparison(commitHash: string, compareWithHash: string, fileChanges: ReadonlyArray, fileTree: FileTreeFolder, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.compareWithElem === null || expandedCommit.commitHash !== commitHash || expandedCommit.compareWithHash !== compareWithHash) return; + + if (haveFilesChanged(expandedCommit.fileChanges, fileChanges)) { + expandedCommit.fileChanges = fileChanges; + expandedCommit.fileTree = fileTree; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + } + expandedCommit.codeReview = codeReview; + if (!refresh) { + expandedCommit.lastViewedFile = lastViewedFile; + } + expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.loading = false; + this.saveState(); + + this.renderCommitDetailsView(refresh); + } + + + /* Render Commit Details / Comparison View */ + + private renderCommitDetailsView(refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null) return; + + let elem = document.getElementById('cdv'), html = '
    ', isDocked = this.isCdvDocked(); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const codeReviewPossible = !expandedCommit.loading && commitOrder.to !== UNCOMMITTED; + const externalDiffPossible = !expandedCommit.loading && (expandedCommit.compareWithHash !== null || this.commits[this.commitLookup[expandedCommit.commitHash]].parents.length > 0); + + if (elem === null) { + elem = document.createElement(isDocked ? 'div' : 'tr'); + elem.id = 'cdv'; + elem.className = isDocked ? 'docked' : 'inline'; + this.setCdvHeight(elem, isDocked); + if (isDocked) { + document.body.appendChild(elem); + } else { + insertAfter(elem, expandedCommit.commitElem); + } + } + + if (expandedCommit.loading) { + html += '
    ' + SVG_ICONS.loading + ' Loading ' + (expandedCommit.compareWithHash === null ? expandedCommit.commitHash !== UNCOMMITTED ? 'Commit Details' : 'Uncommitted Changes' : 'Commit Comparison') + ' ...
    '; + } else { + html += '
    '; + if (expandedCommit.compareWithHash === null) { + // Commit details should be shown + if (expandedCommit.commitHash !== UNCOMMITTED) { + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + commits: true, + emoji: true, + issueLinking: true, + markdown: this.config.markdown, + multiline: true, + urls: true + }); + const commitDetails = expandedCommit.commitDetails!; + const parents = commitDetails.parents.length > 0 + ? commitDetails.parents.map((parent) => { + const escapedParent = escapeHtml(parent); + return typeof this.commitLookup[parent] === 'number' + ? '' + escapedParent + '' + : escapedParent; + }).join(', ') + : 'None'; + html += '' + + 'Commit: ' + escapeHtml(commitDetails.hash) + '
    ' + + 'Parents: ' + parents + '
    ' + + 'Author: ' + escapeHtml(commitDetails.author) + (commitDetails.authorEmail !== '' ? ' <' + escapeHtml(commitDetails.authorEmail) + '>' : '') + '
    ' + + (commitDetails.authorDate !== commitDetails.committerDate ? 'Author Date: ' + formatLongDate(commitDetails.authorDate) + '
    ' : '') + + 'Committer: ' + escapeHtml(commitDetails.committer) + (commitDetails.committerEmail !== '' ? ' <' + escapeHtml(commitDetails.committerEmail) + '>' : '') + (commitDetails.signature !== null ? generateSignatureHtml(commitDetails.signature) : '') + '
    ' + + '' + (commitDetails.authorDate !== commitDetails.committerDate ? 'Committer ' : '') + 'Date: ' + formatLongDate(commitDetails.committerDate) + + '
    ' + + (expandedCommit.avatar !== null ? '' : '') + + '


    ' + textFormatter.format(commitDetails.body); + } else { + html += 'Displaying all uncommitted changes.'; + } + } else { + // Commit comparison should be shown + html += 'Displaying all changes from ' + commitOrder.from + ' to ' + (commitOrder.to !== UNCOMMITTED ? commitOrder.to : 'Uncommitted Changes') + '.'; + } + html += '
    ' + (!isDocked ? '
    ' + SVG_ICONS.collapse + '
    ' : '') + '
    ' + generateFileViewHtml(expandedCommit.fileTree!, expandedCommit.fileChanges!, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, this.getFileViewType(), commitOrder.to === UNCOMMITTED) + '
    '; + } + html += '
    ' + SVG_ICONS.close + '
    ' + + (codeReviewPossible ? '
    ' + SVG_ICONS.review + '
    ' : '') + + (!expandedCommit.loading ? '
    ' + SVG_ICONS.fileList + '
    ' + SVG_ICONS.fileTree + '
    ' + SVG_ICONS.collapseAll + '
    ' + SVG_ICONS.expandAll + '
    ' : '') + + (externalDiffPossible ? '
    ' + SVG_ICONS.linkExternal + '
    ' : '') + + '
    '; + + elem.innerHTML = isDocked ? html : '
    ' + html + '
    '; + this.setCdvDivider(); + this.setCdvHeight(elem, isDocked); + if (!isDocked) this.renderGraph(); + + if (!refresh) { + if (isDocked) { + let elemTop = this.controlsElem.clientHeight + expandedCommit.commitElem.offsetTop; + if (elemTop - 8 < this.viewElem.scrollTop) { + // Commit is above what is visible on screen + this.viewElem.scroll(0, elemTop - 8); + } else if (elemTop - this.viewElem.clientHeight + 32 > this.viewElem.scrollTop) { + // Commit is below what is visible on screen + this.viewElem.scroll(0, elemTop - this.viewElem.clientHeight + 32); + } + } else { + let elemTop = this.controlsElem.clientHeight + elem.offsetTop, cdvHeight = this.gitRepos[this.currentRepo].cdvHeight; + if (this.config.commitDetailsView.autoCenter) { + // Center Commit Detail View setting is enabled + // elemTop - commit height [24px] + (commit details view height + commit height [24px]) / 2 - (view height) / 2 + this.viewElem.scroll(0, elemTop - 12 + (cdvHeight - this.viewElem.clientHeight) / 2); + } else if (elemTop - 32 < this.viewElem.scrollTop) { + // Commit Detail View is opening above what is visible on screen + // elemTop - commit height [24px] - desired gap from top [8px] < view scroll offset + this.viewElem.scroll(0, elemTop - 32); + } else if (elemTop + cdvHeight - this.viewElem.clientHeight + 8 > this.viewElem.scrollTop) { + // Commit Detail View is opening below what is visible on screen + // elemTop + commit details view height + desired gap from bottom [8px] - view height > view scroll offset + this.viewElem.scroll(0, elemTop + cdvHeight - this.viewElem.clientHeight + 8); + } + } + } + + this.makeCdvResizable(); + document.getElementById('cdvClose')!.addEventListener('click', () => { + this.closeCommitDetails(true); + }); + + if (!expandedCommit.loading) { + this.makeCdvFileViewInteractive(); + this.renderCdvFileViewTypeBtns(); + this.renderCdvExternalDiffBtn(); + this.makeCdvDividerDraggable(); + + observeElemScroll('cdvSummary', expandedCommit.scrollTop.summary, (scrollTop) => { + if (this.expandedCommit === null) return; + this.expandedCommit.scrollTop.summary = scrollTop; + if (this.expandedCommit.contextMenuOpen.summary) { + this.expandedCommit.contextMenuOpen.summary = false; + contextMenu.close(); + } + }, () => this.saveState()); + + observeElemScroll('cdvFilesView', expandedCommit.scrollTop.fileView, (scrollTop) => { + if (this.expandedCommit === null) return; + this.expandedCommit.scrollTop.fileView = scrollTop; + if (this.expandedCommit.contextMenuOpen.fileView > -1) { + this.expandedCommit.contextMenuOpen.fileView = -1; + contextMenu.close(); + } + }, () => this.saveState()); + + document.getElementById('cdvFileViewTypeTree')!.addEventListener('click', () => { + this.changeFileViewType(GG.FileViewType.Tree); + }); + + document.getElementById('cdvFileViewTypeList')!.addEventListener('click', () => { + this.changeFileViewType(GG.FileViewType.List); + }); + document.getElementById('cdvCollapse')!.addEventListener('click', () => { + this.openFolders(false); + }); + document.getElementById('cdvExpand')!.addEventListener('click', () => { + this.openFolders(true); + }); + let cdvSummaryToggleBtn = document.getElementById('cdvSummaryToggleBtn'); + if (cdvSummaryToggleBtn !== null) cdvSummaryToggleBtn.addEventListener('click', () => { + this.gitRepos[this.currentRepo].isCdvSummaryHidden = !(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + this.saveRepoState(); + this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + }); + this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + + if (codeReviewPossible) { + this.renderCodeReviewBtn(); + document.getElementById('cdvCodeReview')!.addEventListener('click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || e.target === null) return; + let sourceElem = (e.target).closest('#cdvCodeReview')!; + if (sourceElem.classList.contains(CLASS_ACTIVE)) { + sendMessage({ command: 'endCodeReview', repo: this.currentRepo, id: expandedCommit.codeReview!.id }); + this.endCodeReview(); + } else { + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const id = expandedCommit.compareWithHash !== null ? commitOrder.from + '-' + commitOrder.to : expandedCommit.commitHash; + sendMessage({ + command: 'startCodeReview', + repo: this.currentRepo, + id: id, + commitHash: expandedCommit.commitHash, + compareWithHash: expandedCommit.compareWithHash, + files: getFilesInTree(expandedCommit.fileTree!, expandedCommit.fileChanges!), + lastViewedFile: expandedCommit.lastViewedFile + }); + } + }); + } + + if (externalDiffPossible) { + document.getElementById('cdvExternalDiff')!.addEventListener('click', () => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || this.gitConfig === null || (this.gitConfig.diffTool === null && this.gitConfig.guiDiffTool === null)) return; + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + runAction({ + command: 'openExternalDirDiff', + repo: this.currentRepo, + fromHash: commitOrder.from, + toHash: commitOrder.to, + isGui: this.gitConfig.guiDiffTool !== null + }, 'Opening External Directory Diff'); + }); + } + } + } + + private hideCdvSummary(hide: boolean) { + let btnIcon = document.getElementById('cdvSummaryToggleBtn')?.getElementsByTagName('svg')?.[0] ?? null; + let cdvSummary = document.getElementById('cdvSummary'); + if (hide && !this.isCdvDocked()) { + if (btnIcon) btnIcon.style.transform = 'rotate(90deg)'; + cdvSummary!.classList.add('hidden'); + } else { + if (btnIcon) btnIcon.style.transform = 'rotate(-90deg)'; + cdvSummary!.classList.remove('hidden'); + } + let elem = document.getElementById('cdv'); + if (elem !== null) this.setCdvHeight(elem, this.isCdvDocked()); + } + + private setCdvHeight(elem: HTMLElement, isDocked: boolean) { + let height = this.gitRepos[this.currentRepo].cdvHeight, windowHeight = window.innerHeight; + if (height > windowHeight - 40) { + height = Math.max(windowHeight - 40, 100); + if (height !== this.gitRepos[this.currentRepo].cdvHeight) { + this.gitRepos[this.currentRepo].cdvHeight = height; + this.saveRepoState(); + } + } + + let heightPx = height + 'px'; + if (isDocked) { + this.viewElem.style.bottom = heightPx; + elem.style.height = heightPx; + return; + } + let inlineElem = document.getElementById('cdvContentWrapper'); + if (!inlineElem) { + elem.style.height = heightPx; + return; + } + if (this.gitRepos[this.currentRepo].isCdvSummaryHidden) { + inlineElem.style.height = heightPx; + elem.style.height = '0px'; + } else { + inlineElem.style.removeProperty('height'); + elem.style.height = heightPx; + } + this.renderGraph(); + } + + private setCdvDivider() { + let percent = (this.gitRepos[this.currentRepo].cdvDivider * 100).toFixed(2) + '%'; + let summaryElem = document.getElementById('cdvSummary'), dividerElem = document.getElementById('cdvDivider'), filesElem = document.getElementById('cdvFiles'); + if (summaryElem !== null) summaryElem.style.width = percent; + if (dividerElem !== null) dividerElem.style.left = percent; + if (filesElem !== null) filesElem.style.left = percent; + } + + private makeCdvResizable() { + let prevY = -1; + + const processResizingCdvHeight: EventListener = (e) => { + if (prevY < 0) return; + let delta = (e).pageY - prevY, isDocked = this.isCdvDocked(), windowHeight = window.innerHeight; + prevY = (e).pageY; + let height = this.gitRepos[this.currentRepo].cdvHeight + (isDocked ? -delta : delta); + if (height < 100) height = 100; + else if (height > 600) height = 600; + if (height > windowHeight - 40) height = Math.max(windowHeight - 40, 100); + + if (this.gitRepos[this.currentRepo].cdvHeight !== height) { + this.gitRepos[this.currentRepo].cdvHeight = height; + let elem = document.getElementById('cdv'); + if (elem !== null) this.setCdvHeight(elem, isDocked); + if (!isDocked) this.renderGraph(); + } + }; + const stopResizingCdvHeight: EventListener = (e) => { + if (prevY < 0) return; + processResizingCdvHeight(e); + this.saveRepoState(); + prevY = -1; + eventOverlay.remove(); + }; + + addListenerToClass('cdvHeightResize', 'mousedown', (e) => { + prevY = (e).pageY; + eventOverlay.create('rowResize', processResizingCdvHeight, stopResizingCdvHeight); + }); + } + + private makeCdvDividerDraggable() { + let minX = -1, width = -1; + + const processDraggingCdvDivider: EventListener = (e) => { + if (minX < 0) return; + let percent = ((e).clientX - minX) / width; + if (percent < 0.2) percent = 0.2; + else if (percent > 0.8) percent = 0.8; + + if (this.gitRepos[this.currentRepo].cdvDivider !== percent) { + this.gitRepos[this.currentRepo].cdvDivider = percent; + this.setCdvDivider(); + } + }; + const stopDraggingCdvDivider: EventListener = (e) => { + if (minX < 0) return; + processDraggingCdvDivider(e); + this.saveRepoState(); + minX = -1; + eventOverlay.remove(); + }; + + document.getElementById('cdvDivider')!.addEventListener('mousedown', () => { + const contentElem = document.getElementById('cdvContent'); + if (contentElem === null) return; + + const bounds = contentElem.getBoundingClientRect(); + minX = bounds.left; + width = bounds.width; + eventOverlay.create('colResize', processDraggingCdvDivider, stopDraggingCdvDivider); + }); + } + + /** + * Updates the state of a file in the Commit Details View. + * @param file The file that was affected. + * @param fileElem The HTML Element of the file. + * @param isReviewed TRUE/FALSE => Set the files reviewed state accordingly, NULL => Don't update the files reviewed state. + * @param fileWasViewed Was the file viewed - if so, set it to be the last viewed file. + */ + private cdvUpdateFileState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean | null, fileWasViewed: boolean) { + const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'), filePath = file.newFilePath; + if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; + + if (fileWasViewed) { + expandedCommit.lastViewedFile = filePath; + let lastViewedElem = document.getElementById('cdvLastFileViewed'); + if (lastViewedElem !== null) lastViewedElem.remove(); + lastViewedElem = document.createElement('span'); + lastViewedElem.id = 'cdvLastFileViewed'; + lastViewedElem.title = 'Last File Viewed'; + lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; + insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); + } + + if (expandedCommit.codeReview !== null) { + if (isReviewed !== null) { + if (isReviewed) { + expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); + } else { + expandedCommit.codeReview.remainingFiles.push(filePath); + } + + alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); + updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); + } + + sendMessage({ + command: 'updateCodeReview', + repo: this.currentRepo, + id: expandedCommit.codeReview.id, + remainingFiles: expandedCommit.codeReview.remainingFiles, + lastViewedFile: expandedCommit.lastViewedFile + }); + + if (expandedCommit.codeReview.remainingFiles.length === 0) { + expandedCommit.codeReview = null; + this.renderCodeReviewBtn(); + } + } + + this.saveState(); + } + + private isCdvDocked() { + return this.config.commitDetailsView.location === GG.CommitDetailsViewLocation.DockedToBottom; + } + + public isCdvOpen(commitHash: string, compareWithHash: string | null) { + return this.expandedCommit !== null && this.expandedCommit.commitHash === commitHash && this.expandedCommit.compareWithHash === compareWithHash; + } + + private getCommitOrder(hash1: string, hash2: string) { + if (this.commitLookup[hash1] > this.commitLookup[hash2]) { + return { from: hash1, to: hash2 }; + } else { + return { from: hash2, to: hash1 }; + } + } + + private getFileViewType() { + return this.gitRepos[this.currentRepo].fileViewType === GG.FileViewType.Default + ? this.config.commitDetailsView.fileViewType + : this.gitRepos[this.currentRepo].fileViewType; + } + + private setFileViewType(type: GG.FileViewType) { + this.gitRepos[this.currentRepo].fileViewType = type; + this.saveRepoState(); + } + + private changeFileViewType(type: GG.FileViewType) { + const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'); + if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.fileChanges === null || filesElem === null) return; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + this.setFileViewType(type); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + filesElem.innerHTML = generateFileViewHtml(expandedCommit.fileTree, expandedCommit.fileChanges, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, type, commitOrder.to === UNCOMMITTED); + this.makeCdvFileViewInteractive(); + this.renderCdvFileViewTypeBtns(); + } + + private openFolders(open: boolean) { + let expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileTree === null) return; + let folders = document.getElementsByClassName('fileTreeFolder'); + for (let i = 0; i < folders.length; i++) { + let sourceElem = (folders[i]); + let parent = sourceElem.parentElement!; + if (open) { + parent.classList.remove('closed'); + sourceElem.children[0].children[0].innerHTML = SVG_ICONS.openFolder; + parent.children[1].classList.remove('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), true); + + } else { + parent.classList.add('closed'); + sourceElem.children[0].children[0].innerHTML = SVG_ICONS.closedFolder; + parent.children[1].classList.add('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), false); + } + } + this.saveState(); + } + + private makeCdvFileViewInteractive() { + const getFileElemOfEventTarget = (target: EventTarget) => (target).closest('.fileTreeFileRecord'); + const getFileOfFileElem = (fileChanges: ReadonlyArray, fileElem: HTMLElement) => fileChanges[parseInt(fileElem.dataset.index!)]; + + const getCommitHashForFile = (file: GG.GitFileChange, expandedCommit: ExpandedCommit) => { + const commit = this.commits[this.commitLookup[expandedCommit.commitHash]]; + if (expandedCommit.compareWithHash !== null) { + return this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; + } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { + return commit.stash.untrackedFilesHash!; + } else { + return expandedCommit.commitHash; + } + }; + + const triggerViewFileDiff = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + let commit = this.commits[this.commitLookup[expandedCommit.commitHash]], fromHash: string, toHash: string, fileStatus = file.type; + if (expandedCommit.compareWithHash !== null) { + // Commit Comparison + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash); + fromHash = commitOrder.from; + toHash = commitOrder.to; + } else if (commit.stash !== null) { + // Stash Commit + if (fileStatus === GG.GitFileStatus.Untracked) { + fromHash = commit.stash.untrackedFilesHash!; + toHash = commit.stash.untrackedFilesHash!; + fileStatus = GG.GitFileStatus.Added; + } else { + fromHash = commit.stash.baseHash; + toHash = expandedCommit.commitHash; + } + } else { + // Single Commit + fromHash = expandedCommit.commitHash; + toHash = expandedCommit.commitHash; + } + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ + command: 'viewDiff', + repo: this.currentRepo, + fromHash: fromHash, + toHash: toHash, + oldFilePath: file.oldFilePath, + newFilePath: file.newFilePath, + type: fileStatus + }); + }; + + const triggerCopyFilePath = (file: GG.GitFileChange, absolute: boolean) => { + sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath, absolute: absolute }); + }; + + const triggerResetFileToRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + const commitHash = getCommitHashForFile(file, expandedCommit); + dialog.showConfirmation('Are you sure you want to reset ' + escapeHtml(file.newFilePath) + ' to it\'s state at commit ' + abbrevCommit(commitHash) + '? Any uncommitted changes made to this file will be overwritten.', 'Yes, reset file', () => { + runAction({ command: 'resetFileToRevision', repo: this.currentRepo, commitHash: commitHash, filePath: file.newFilePath }, 'Resetting file'); + }, { + type: TargetType.CommitDetailsView, + hash: commitHash, + elem: fileElem + }); + }; + + const triggerViewFileAtRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, null, true); + sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + addListenerToClass('fileTreeFolder', 'click', (e) => { + let expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileTree === null || e.target === null) return; + + let sourceElem = (e.target).closest('.fileTreeFolder'); + let parent = sourceElem.parentElement!; + parent.classList.toggle('closed'); + let isOpen = !parent.classList.contains('closed'); + parent.children[0].children[0].innerHTML = isOpen ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder; + parent.children[1].classList.toggle('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), isOpen); + this.saveState(); + }); + + addListenerToClass('fileTreeRepo', 'click', (e) => { + if (e.target === null) return; + this.loadRepos(this.gitRepos, null, { + repo: decodeURIComponent(((e.target).closest('.fileTreeRepo')).dataset.path!) + }); + }); + + addListenerToClass('fileTreeFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const sourceElem = (e.target).closest('.fileTreeFile'), fileElem = getFileElemOfEventTarget(e.target); + if (!sourceElem.classList.contains('gitDiffPossible')) return; + triggerViewFileDiff(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('copyGitFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem), true); + }); + + addListenerToClass('viewGitFileAtRevision', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerViewFileAtRevision(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('openGitFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerOpenFile(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('fileTreeFileRecord', 'contextmenu', (e: Event) => { + handledEvent(e); + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + const fileElem = getFileElemOfEventTarget(e.target); + const file = getFileOfFileElem(expandedCommit.fileChanges, fileElem); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const isUncommitted = commitOrder.to === UNCOMMITTED; + + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + expandedCommit.contextMenuOpen.fileView = parseInt(fileElem.dataset.index!); + + const target: ContextMenuTarget & CommitTarget = { + type: TargetType.CommitDetailsView, + hash: expandedCommit.commitHash, + index: this.commitLookup[expandedCommit.commitHash], + elem: fileElem + }; + const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); + const fileExistsAtThisRevision = file.type !== GG.GitFileStatus.Deleted && !isUncommitted; + const fileExistsAtThisRevisionAndDiffPossible = fileExistsAtThisRevision && diffPossible; + const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); + const visibility = this.config.contextMenuActionsVisibility.commitDetailsViewFile; + + contextMenu.show([ + [ + { + title: 'View Diff', + visible: visibility.viewDiff && diffPossible, + onClick: () => triggerViewFileDiff(file, fileElem) + }, + { + title: 'View File at this Revision', + visible: visibility.viewFileAtThisRevision && fileExistsAtThisRevisionAndDiffPossible, + onClick: () => triggerViewFileAtRevision(file, fileElem) + }, + { + title: 'View Diff with Working File', + visible: visibility.viewDiffWithWorkingFile && fileExistsAtThisRevisionAndDiffPossible, + onClick: () => triggerViewFileDiffWithWorkingFile(file, fileElem) + }, + { + title: 'Open File', + visible: visibility.openFile && file.type !== GG.GitFileStatus.Deleted, + onClick: () => triggerOpenFile(file, fileElem) + } + ], + [ + { + title: 'Mark as Reviewed', + visible: visibility.markAsReviewed && codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) + }, + { + title: 'Mark as Not Reviewed', + visible: visibility.markAsNotReviewed && expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) + } + ], + [ + { + title: 'Reset File to this Revision' + ELLIPSIS, + visible: visibility.resetFileToThisRevision && fileExistsAtThisRevision && expandedCommit.compareWithHash === null, + onClick: () => triggerResetFileToRevision(file, fileElem) + } + ], + [ + { + title: 'Copy Absolute File Path to Clipboard', + visible: visibility.copyAbsoluteFilePath, + onClick: () => triggerCopyFilePath(file, true) + }, + { + title: 'Copy Relative File Path to Clipboard', + visible: visibility.copyRelativeFilePath, + onClick: () => triggerCopyFilePath(file, false) + } + ] + ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { + expandedCommit.contextMenuOpen.fileView = -1; + }); + }); + } + + private renderCdvFileViewTypeBtns() { + if (this.expandedCommit === null) return; + let treeBtnElem = document.getElementById('cdvFileViewTypeTree'), listBtnElem = document.getElementById('cdvFileViewTypeList'); + if (treeBtnElem === null || listBtnElem === null) return; + + let listView = this.getFileViewType() === GG.FileViewType.List; + alterClass(treeBtnElem, CLASS_ACTIVE, !listView); + alterClass(listBtnElem, CLASS_ACTIVE, listView); + setFolderBtns(); + function setFolderBtns() { + let btns = document.getElementsByClassName('cdvFolderBtn'); + for (let i = 0; i < btns.length; i++) { + if (listView) + btns[i].classList.add('hidden'); + else + btns[i].classList.remove('hidden'); + } + } + } + + private renderCdvExternalDiffBtn() { + if (this.expandedCommit === null) return; + const externalDiffBtnElem = document.getElementById('cdvExternalDiff'); + if (externalDiffBtnElem === null) return; + + alterClass(externalDiffBtnElem, CLASS_ENABLED, this.gitConfig !== null && (this.gitConfig.diffTool !== null || this.gitConfig.guiDiffTool !== null)); + const toolName = this.gitConfig !== null + ? this.gitConfig.guiDiffTool !== null + ? this.gitConfig.guiDiffTool + : this.gitConfig.diffTool + : null; + externalDiffBtnElem.title = 'Open External Directory Diff' + (toolName !== null ? ' with "' + toolName + '"' : ''); + } + + private static closeCdvContextMenuIfOpen(expandedCommit: ExpandedCommit) { + if (expandedCommit.contextMenuOpen.summary || expandedCommit.contextMenuOpen.fileView > -1) { + expandedCommit.contextMenuOpen.summary = false; + expandedCommit.contextMenuOpen.fileView = -1; + contextMenu.close(); + } + } + + + /* Code Review */ + + public startCodeReview(commitHash: string, compareWithHash: string | null, codeReview: GG.CodeReview) { + if (this.expandedCommit === null || this.expandedCommit.commitHash !== commitHash || this.expandedCommit.compareWithHash !== compareWithHash) return; + this.saveAndRenderCodeReview(codeReview); + } + + public endCodeReview() { + if (this.expandedCommit === null || this.expandedCommit.codeReview === null) return; + this.saveAndRenderCodeReview(null); + } + + private saveAndRenderCodeReview(codeReview: GG.CodeReview | null) { + let filesElem = document.getElementById('cdvFilesView'); + if (this.expandedCommit === null || this.expandedCommit.fileTree === null || filesElem === null) return; + + this.expandedCommit.codeReview = codeReview; + setFileTreeReviewed(this.expandedCommit.fileTree, codeReview === null); + this.saveState(); + this.renderCodeReviewBtn(); + updateFileTreeHtml(filesElem, this.expandedCommit.fileTree); + } + + private renderCodeReviewBtn() { + if (this.expandedCommit === null) return; + let btnElem = document.getElementById('cdvCodeReview'); + if (btnElem === null) return; + + let active = this.expandedCommit.codeReview !== null; + alterClass(btnElem, CLASS_ACTIVE, active); + btnElem.title = (active ? 'End' : 'Start') + ' Code Review'; + } +} + + +/* Main */ + +const contextMenu = new ContextMenu(), dialog = new Dialog(), eventOverlay = new EventOverlay(); +let loaded = false; + +window.addEventListener('load', () => { + if (loaded) return; + loaded = true; + + TextFormatter.registerCustomEmojiMappings(initialState.config.customEmojiShortcodeMappings); + + const viewElem = document.getElementById('view'); + if (viewElem === null) return; + + const gitGraph = new GitGraphView(viewElem, VSCODE_API.getState()); + const imageResizer = new ImageResizer(); + + /* Command Processing */ + window.addEventListener('message', event => { + const msg: GG.ResponseMessage = event.data; + switch (msg.command) { + case 'addRemote': + refreshOrDisplayError(msg.error, 'Unable to Add Remote', true); + break; + case 'addTag': + if (msg.pushToRemote !== null && msg.errors.length === 2 && msg.errors[0] === null && isExtensionErrorInfo(msg.errors[1], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { + gitGraph.refresh(false); + handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.errors[1]!); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Add Tag'); + } + break; + case 'applyStash': + refreshOrDisplayError(msg.error, 'Unable to Apply Stash'); + break; + case 'branchFromStash': + refreshOrDisplayError(msg.error, 'Unable to Create Branch from Stash'); + break; + case 'checkoutBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Checkout Branch' + (msg.pullAfterwards !== null ? ' & Pull Changes' : '')); + break; + case 'checkoutCommit': + refreshOrDisplayError(msg.error, 'Unable to Checkout Commit'); + break; + case 'cherrypickCommit': + refreshAndDisplayErrors(msg.errors, 'Unable to Cherry Pick Commit'); + break; + case 'cleanUntrackedFiles': + refreshOrDisplayError(msg.error, 'Unable to Clean Untracked Files'); + break; + case 'commitDetails': + if (msg.commitDetails !== null) { + gitGraph.showCommitDetails(msg.commitDetails, gitGraph.createFileTree(msg.commitDetails.fileChanges, msg.codeReview), msg.avatar, msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); + } else { + gitGraph.closeCommitDetails(true); + dialog.showError('Unable to load Commit Details', msg.error, null, null); + } + break; + case 'compareCommits': + if (msg.error === null) { + gitGraph.showCommitComparison(msg.commitHash, msg.compareWithHash, msg.fileChanges, gitGraph.createFileTree(msg.fileChanges, msg.codeReview), msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); + } else { + gitGraph.closeCommitComparison(true); + dialog.showError('Unable to load Commit Comparison', msg.error, null, null); + } + break; + case 'copyFilePath': + finishOrDisplayError(msg.error, 'Unable to Copy File Path to Clipboard'); + break; + case 'copyToClipboard': + finishOrDisplayError(msg.error, 'Unable to Copy ' + msg.type + ' to Clipboard'); + break; + case 'createArchive': + finishOrDisplayError(msg.error, 'Unable to Create Archive', true); + break; + case 'createBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Create Branch'); + break; + case 'createPullRequest': + finishOrDisplayErrors(msg.errors, 'Unable to Create Pull Request', () => { + if (msg.push) { + gitGraph.refresh(false); + } + }, true); + break; + case 'deleteBranch': + handleResponseDeleteBranch(msg); + break; + case 'deleteRemote': + refreshOrDisplayError(msg.error, 'Unable to Delete Remote', true); + break; + case 'deleteRemoteBranch': + refreshOrDisplayError(msg.error, 'Unable to Delete Remote Branch'); + break; + case 'deleteTag': + refreshOrDisplayError(msg.error, 'Unable to Delete Tag'); + break; + case 'deleteUserDetails': + finishOrDisplayErrors(msg.errors, 'Unable to Remove Git User Details', () => gitGraph.requestLoadConfig(), true); + break; + case 'dropCommit': + refreshOrDisplayError(msg.error, 'Unable to Drop Commit'); + break; + case 'dropStash': + refreshOrDisplayError(msg.error, 'Unable to Drop Stash'); + break; + case 'editRemote': + refreshOrDisplayError(msg.error, 'Unable to Save Changes to Remote', true); + break; + case 'editUserDetails': + finishOrDisplayErrors(msg.errors, 'Unable to Save Git User Details', () => gitGraph.requestLoadConfig(), true); + break; + case 'exportRepoConfig': + refreshOrDisplayError(msg.error, 'Unable to Export Repository Configuration'); + break; + case 'fetch': + refreshOrDisplayError(msg.error, 'Unable to Fetch from Remote(s)'); + break; + case 'fetchAvatar': + imageResizer.resize(msg.image, (resizedImage) => { + gitGraph.loadAvatar(msg.email, resizedImage); + }); + break; + case 'fetchIntoLocalBranch': + refreshOrDisplayError(msg.error, 'Unable to Fetch into Local Branch'); + break; + case 'loadCommits': + gitGraph.processLoadCommitsResponse(msg); + break; + case 'loadConfig': + gitGraph.processLoadConfig(msg); + break; + case 'loadRepoInfo': + gitGraph.processLoadRepoInfoResponse(msg); + break; + case 'loadRepos': + gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); + break; + case 'scrollToCommit': + gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); + break; + case 'merge': + refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); + break; + case 'openExtensionSettings': + finishOrDisplayError(msg.error, 'Unable to Open Extension Settings'); + break; + case 'openExternalDirDiff': + finishOrDisplayError(msg.error, 'Unable to Open External Directory Diff', true); + break; + case 'openExternalUrl': + finishOrDisplayError(msg.error, 'Unable to Open External URL'); + break; + case 'openFile': + finishOrDisplayError(msg.error, 'Unable to Open File'); + break; + case 'openTerminal': + finishOrDisplayError(msg.error, 'Unable to Open Terminal', true); + break; + case 'popStash': + refreshOrDisplayError(msg.error, 'Unable to Pop Stash'); + break; + case 'pruneRemote': + refreshOrDisplayError(msg.error, 'Unable to Prune Remote'); + break; + case 'pullBranch': + refreshOrDisplayError(msg.error, 'Unable to Pull Branch'); + break; + case 'pushBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Push Branch', msg.willUpdateBranchConfig); + break; + case 'pushStash': + refreshOrDisplayError(msg.error, 'Unable to Stash Uncommitted Changes'); + break; + case 'pushTag': + if (msg.errors.length === 1 && isExtensionErrorInfo(msg.errors[0], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { + handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.errors[0]!); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Push Tag'); + } + break; + case 'rebase': + if (msg.error === null) { + if (msg.interactive) { + dialog.closeActionRunning(); + } else { + gitGraph.refresh(false); + } + } else { + dialog.showError('Unable to Rebase current branch on ' + msg.actionOn, msg.error, null, null); + } + break; + case 'refresh': + gitGraph.refresh(false); + break; + case 'renameBranch': + refreshOrDisplayError(msg.error, 'Unable to Rename Branch'); + break; + case 'resetFileToRevision': + refreshOrDisplayError(msg.error, 'Unable to Reset File to Revision'); + break; + case 'resetToCommit': + refreshOrDisplayError(msg.error, 'Unable to Reset to Commit'); + break; + case 'revertCommit': + refreshOrDisplayError(msg.error, 'Unable to Revert Commit'); + break; + case 'setGlobalViewState': + finishOrDisplayError(msg.error, 'Unable to save the Global View State'); + break; + case 'setWorkspaceViewState': + finishOrDisplayError(msg.error, 'Unable to save the Workspace View State'); + break; + case 'startCodeReview': + if (msg.error === null) { + gitGraph.startCodeReview(msg.commitHash, msg.compareWithHash, msg.codeReview); + } else { + dialog.showError('Unable to Start Code Review', msg.error, null, null); + } + break; + case 'tagDetails': + if (msg.details !== null) { + gitGraph.renderTagDetails(msg.tagName, msg.commitHash, msg.details); + } else { + dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); + } + break; + case 'updateCodeReview': + if (msg.error !== null) { + dialog.showError('Unable to update Code Review', msg.error, null, null); + } + break; + case 'viewDiff': + finishOrDisplayError(msg.error, 'Unable to View Diff'); + break; + case 'viewDiffWithWorkingFile': + finishOrDisplayError(msg.error, 'Unable to View Diff with Working File'); + break; + case 'viewFileAtRevision': + finishOrDisplayError(msg.error, 'Unable to View File at Revision'); + break; + case 'viewScm': + finishOrDisplayError(msg.error, 'Unable to open the Source Control View'); + break; + } + }); + + function handleResponseDeleteBranch(msg: GG.ResponseDeleteBranch) { + if (msg.errors.length > 0 && msg.errors[0] !== null && msg.errors[0].includes('git branch -D')) { + dialog.showConfirmation('The branch ' + escapeHtml(msg.branchName) + ' is not fully merged. Would you like to force delete it?', 'Yes, force delete branch', () => { + runAction({ command: 'deleteBranch', repo: msg.repo, branchName: msg.branchName, forceDelete: true, deleteOnRemotes: msg.deleteOnRemotes }, 'Deleting Branch'); + }, { type: TargetType.Repo }); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Delete Branch'); + } + } + + function handleResponsePushTagCommitNotOnRemote(repo: string, tagName: string, remotes: string[], commitHash: string, error: string) { + const remotesNotContainingCommit: string[] = parseExtensionErrorInfo(error, GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote); + + const html = '' + SVG_ICONS.alert + 'Warning: Commit is not on Remote' + (remotesNotContainingCommit.length > 1 ? 's ' : ' ') + '
    ' + + '' + + '

    The tag ' + escapeHtml(tagName) + ' is on a commit that isn\'t on any known branch on the remote' + (remotesNotContainingCommit.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotesNotContainingCommit.map((remote) => '' + escapeHtml(remote) + '')) + '.

    ' + + '

    Would you like to proceed to push the tag to the remote' + (remotes.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotes.map((remote) => '' + escapeHtml(remote) + '')) + ' anyway?

    ' + + '
    '; + + dialog.showForm(html, [{ type: DialogInputType.Checkbox, name: 'Always Proceed', value: false }], 'Proceed to Push', (values) => { + if (values[0]) { + updateGlobalViewState('pushTagSkipRemoteCheck', true); + } + runAction({ + command: 'pushTag', + repo: repo, + tagName: tagName, + remotes: remotes, + commitHash: commitHash, + skipRemoteCheck: true + }, 'Pushing Tag'); + }, { type: TargetType.Repo }, 'Cancel', null, true); + } + + function refreshOrDisplayError(error: GG.ErrorInfo, errorMessage: string, configChanges: boolean = false) { + if (error === null) { + gitGraph.refresh(false, configChanges); + } else { + dialog.showError(errorMessage, error, null, null); + } + } + + function refreshAndDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, configChanges: boolean = false) { + const reducedErrors = reduceErrorInfos(errors); + if (reducedErrors.error !== null) { + dialog.showError(errorMessage, reducedErrors.error, null, null); + } + if (reducedErrors.partialOrCompleteSuccess) { + gitGraph.refresh(false, configChanges); + } else if (configChanges) { + gitGraph.requestLoadConfig(); + } + } + + function finishOrDisplayError(error: GG.ErrorInfo, errorMessage: string, dismissActionRunning: boolean = false) { + if (error !== null) { + dialog.showError(errorMessage, error, null, null); + } else if (dismissActionRunning) { + dialog.closeActionRunning(); + } + } + + function finishOrDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, partialOrCompleteSuccessCallback: () => void, dismissActionRunning: boolean = false) { + const reducedErrors = reduceErrorInfos(errors); + finishOrDisplayError(reducedErrors.error, errorMessage, dismissActionRunning); + if (reducedErrors.partialOrCompleteSuccess) { + partialOrCompleteSuccessCallback(); + } + } + + function reduceErrorInfos(errors: GG.ErrorInfo[]) { + let error: GG.ErrorInfo = null, partialOrCompleteSuccess = false; + for (let i = 0; i < errors.length; i++) { + if (errors[i] !== null) { + error = error !== null ? error + '\n\n' + errors[i] : errors[i]; + } else { + partialOrCompleteSuccess = true; + } + } + + return { + error: error, + partialOrCompleteSuccess: partialOrCompleteSuccess + }; + } + + /** + * Checks whether the given ErrorInfo has an ErrorInfoExtensionPrefix. + * @param error The ErrorInfo to check. + * @param prefix The ErrorInfoExtensionPrefix to test. + * @returns TRUE => ErrorInfo has the ErrorInfoExtensionPrefix, FALSE => ErrorInfo doesn\'t have the ErrorInfoExtensionPrefix + */ + function isExtensionErrorInfo(error: GG.ErrorInfo, prefix: GG.ErrorInfoExtensionPrefix) { + return error !== null && error.startsWith(prefix); + } + + /** + * Parses the JSON data from an ErrorInfo prefixed by the provided ErrorInfoExtensionPrefix. + * @param error The ErrorInfo to parse. + * @param prefix The ErrorInfoExtensionPrefix used by `error`. + * @returns The parsed JSON data. + */ + function parseExtensionErrorInfo(error: string, prefix: GG.ErrorInfoExtensionPrefix) { + return JSON.parse(error.substring(prefix.length)); + } +}); + + +/* File Tree Methods (for the Commit Details & Comparison Views) */ + +function generateFileViewHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, type: GG.FileViewType, isUncommitted: boolean) { + return type === GG.FileViewType.List + ? generateFileListHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted) + : generateFileTreeHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, true); +} + +function generateFileTreeHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean, topLevelFolder: boolean): string { + const curFolderInfo = topLevelFolder || !initialState.config.commitDetailsView.fileTreeCompactFolders + ? { folder: folder, name: folder.name, pathSeg: folder.name } + : getCurrentFolderInfo(folder, folder.name, folder.name); + + const children = sortFolderKeys(curFolderInfo.folder).map((key) => { + const cur = curFolderInfo.folder.contents[key]; + return cur.type === 'folder' + ? generateFileTreeHtml(cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, false) + : generateFileTreeLeafHtml(cur.name, cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); + }); + + return (topLevelFolder ? '' : '' + (curFolderInfo.folder.open ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder) + '' + escapeHtml(curFolderInfo.name) + '') + + '
      ' + children.join('') + '
    ' + + (topLevelFolder ? '' : ''); +} + +function getCurrentFolderInfo(folder: FileTreeFolder, name: string, pathSeg: string): { folder: FileTreeFolder, name: string, pathSeg: string } { + const keys = Object.keys(folder.contents); + let child: FileTreeNode; + return keys.length === 1 && (child = folder.contents[keys[0]]).type === 'folder' + ? getCurrentFolderInfo(child, name + ' / ' + child.name, pathSeg + '/' + child.name) + : { folder: folder, name: name, pathSeg: pathSeg }; +} + +function generateFileListHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { + const sortLeaves = (folder: FileTreeFolder, folderPath: string) => { + let keys = sortFolderKeys(folder); + let items: { relPath: string, leaf: FileTreeLeaf }[] = []; + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + let relPath = (folderPath !== '' ? folderPath + '/' : '') + cur.name; + if (cur.type === 'folder') { + items = items.concat(sortLeaves(cur, relPath)); + } else { + items.push({ relPath: relPath, leaf: cur }); + } + } + return items; + }; + let sortedLeaves = sortLeaves(folder, ''); + let html = ''; + for (let i = 0; i < sortedLeaves.length; i++) { + html += generateFileTreeLeafHtml(sortedLeaves[i].relPath, sortedLeaves[i].leaf, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); + } + return '
      ' + html + '
    '; +} + +function generateFileTreeLeafHtml(name: string, leaf: FileTreeLeaf, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { + let encodedName = encodeURIComponent(name), escapedName = escapeHtml(name); + if (leaf.type === 'file') { + const fileTreeFile = gitFiles[leaf.index]; + const textFile = fileTreeFile.additions !== null && fileTreeFile.deletions !== null; + const diffPossible = fileTreeFile.type === GG.GitFileStatus.Untracked || textFile; + const changeTypeMessage = GIT_FILE_CHANGE_TYPES[fileTreeFile.type] + (fileTreeFile.type === GG.GitFileStatus.Renamed ? ' (' + escapeHtml(fileTreeFile.oldFilePath) + ' → ' + escapeHtml(fileTreeFile.newFilePath) + ')' : ''); + return '
  • ' + SVG_ICONS.file + '' + escapedName + '' + + (initialState.config.enhancedAccessibility ? '' + fileTreeFile.type + '' : '') + + (fileTreeFile.type !== GG.GitFileStatus.Added && fileTreeFile.type !== GG.GitFileStatus.Untracked && fileTreeFile.type !== GG.GitFileStatus.Deleted && textFile ? '(+' + fileTreeFile.additions + '|-' + fileTreeFile.deletions + ')' : '') + + (fileTreeFile.newFilePath === lastViewedFile ? '' + SVG_ICONS.eyeOpen + '' : '') + + '' + SVG_ICONS.copy + '' + + (fileTreeFile.type !== GG.GitFileStatus.Deleted + ? (diffPossible && !isUncommitted ? '' + SVG_ICONS.commit + '' : '') + + '' + SVG_ICONS.openFile + '' + : '' + ) + '
  • '; + } else { + return '
  • ' + SVG_ICONS.closedFolder + '' + escapedName + '
  • '; + } +} + +function alterFileTreeFolderOpen(folder: FileTreeFolder, folderPath: string, open: boolean) { + let path = folderPath.split('/'), i, cur = folder; + for (i = 0; i < path.length; i++) { + if (typeof cur.contents[path[i]] !== 'undefined') { + cur = cur.contents[path[i]]; + if (i === path.length - 1) cur.open = open; + } else { + return; + } + } +} + +function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string, reviewed: boolean) { + let path = filePath.split('/'), i, cur = folder, folders = [folder]; + for (i = 0; i < path.length; i++) { + if (typeof cur.contents[path[i]] !== 'undefined') { + if (i < path.length - 1) { + cur = cur.contents[path[i]]; + folders.push(cur); + } else { + (cur.contents[path[i]]).reviewed = reviewed; + } + } else { + break; + } + } + + // Recalculate whether each of the folders leading to the file are now reviewed (deepest first). + for (i = folders.length - 1; i >= 0; i--) { + let keys = Object.keys(folders[i].contents), entireFolderReviewed = true; + for (let j = 0; j < keys.length; j++) { + let cur = folders[i].contents[keys[j]]; + if ((cur.type === 'folder' || cur.type === 'file') && !cur.reviewed) { + entireFolderReviewed = false; + break; + } + } + folders[i].reviewed = entireFolderReviewed; + } +} + +function setFileTreeReviewed(folder: FileTreeFolder, reviewed: boolean) { + folder.reviewed = reviewed; + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if (cur.type === 'folder') { + setFileTreeReviewed(cur, reviewed); + } else if (cur.type === 'file') { + cur.reviewed = reviewed; + } + } +} + +function calcFileTreeFoldersReviewed(folder: FileTreeFolder) { + const calc = (folder: FileTreeFolder) => { + let reviewed = true; + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if ((cur.type === 'folder' && !calc(cur)) || (cur.type === 'file' && !cur.reviewed)) reviewed = false; + } + folder.reviewed = reviewed; + return reviewed; + }; + calc(folder); +} + +function updateFileTreeHtml(elem: HTMLElement, folder: FileTreeFolder) { + let ul = getChildUl(elem); + if (ul === null) return; + + for (let i = 0; i < ul.children.length; i++) { + let li = ul.children[i]; + let pathSeg = decodeURIComponent(li.dataset.pathseg!); + let child = getChildByPathSegment(folder, pathSeg); + if (child.type === 'folder') { + alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); + updateFileTreeHtml(li, child); + } else if (child.type === 'file') { + alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); + } + } +} + +function updateFileTreeHtmlFileReviewed(elem: HTMLElement, folder: FileTreeFolder, filePath: string) { + let path = filePath; + const update = (elem: HTMLElement, folder: FileTreeFolder) => { + let ul = getChildUl(elem); + if (ul === null) return; + + for (let i = 0; i < ul.children.length; i++) { + let li = ul.children[i]; + let pathSeg = decodeURIComponent(li.dataset.pathseg!); + if (path === pathSeg || path.startsWith(pathSeg + '/')) { + let child = getChildByPathSegment(folder, pathSeg); + if (child.type === 'folder') { + alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); + path = path.substring(pathSeg.length + 1); + update(li, child); + } else if (child.type === 'file') { + alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); + } + break; + } + } + }; + update(elem, folder); +} + +function getFilesInTree(folder: FileTreeFolder, gitFiles: ReadonlyArray) { + let files: string[] = []; + const scanFolder = (folder: FileTreeFolder) => { + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if (cur.type === 'folder') { + scanFolder(cur); + } else if (cur.type === 'file') { + files.push(gitFiles[cur.index].newFilePath); + } + } + }; + scanFolder(folder); + return files; +} + +function sortFolderKeys(folder: FileTreeFolder) { + let keys = Object.keys(folder.contents); + keys.sort((a, b) => folder.contents[a].type !== 'file' && folder.contents[b].type === 'file' ? -1 : folder.contents[a].type === 'file' && folder.contents[b].type !== 'file' ? 1 : folder.contents[a].name.localeCompare(folder.contents[b].name)); + return keys; +} + +function getChildByPathSegment(folder: FileTreeFolder, pathSeg: string) { + let cur: FileTreeNode = folder, comps = pathSeg.split('/'); + for (let i = 0; i < comps.length; i++) { + cur = (cur).contents[comps[i]]; + } + return cur; +} + + +/* Repository State Helpers */ + +function getCommitOrdering(repoValue: GG.RepoCommitOrdering): GG.CommitOrdering { + switch (repoValue) { + case GG.RepoCommitOrdering.Default: + return initialState.config.commitOrdering; + case GG.RepoCommitOrdering.Date: + return GG.CommitOrdering.Date; + case GG.RepoCommitOrdering.AuthorDate: + return GG.CommitOrdering.AuthorDate; + case GG.RepoCommitOrdering.Topological: + return GG.CommitOrdering.Topological; + } +} + +function getShowRemoteBranches(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showRemoteBranches + : repoValue === GG.BooleanOverride.Enabled; +} + +function getSimplifyByDecoration(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.simplifyByDecoration + : repoValue === GG.BooleanOverride.Enabled; +} + +function getShowStashes(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showStashes + : repoValue === GG.BooleanOverride.Enabled; +} + +function getShowTags(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showTags + : repoValue === GG.BooleanOverride.Enabled; +} + +function getIncludeCommitsMentionedByReflogs(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.includeCommitsMentionedByReflogs + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnlyFollowFirstParent(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.onlyFollowFirstParent + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnRepoLoadShowCheckedOutBranch(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.onRepoLoad.showCheckedOutBranch + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnRepoLoadShowSpecificBranches(repoValue: string[] | null) { + return repoValue === null + ? initialState.config.onRepoLoad.showSpecificBranches + : repoValue; +} + + +/* Miscellaneous Helper Methods */ + +function haveFilesChanged(oldFiles: ReadonlyArray | null, newFiles: ReadonlyArray | null) { + if ((oldFiles === null) !== (newFiles === null)) { + return true; + } else if (oldFiles === null && newFiles === null) { + return false; + } else { + return !arraysEqual(oldFiles!, newFiles!, (a, b) => a.additions === b.additions && a.deletions === b.deletions && a.newFilePath === b.newFilePath && a.oldFilePath === b.oldFilePath && a.type === b.type); + } +} + +function abbrevCommit(commitHash: string) { + return commitHash.substring(0, 8); +} + +function getRepoDropdownOptions(repos: Readonly) { + const repoPaths = getSortedRepositoryPaths(repos, initialState.config.repoDropdownOrder); + const paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; + const resolveAmbiguous = (indexes: number[]) => { + // Find ambiguous names within indexes + let firstOccurrence: { [name: string]: number } = {}, ambiguous: { [name: string]: number[] } = {}; + for (let i = 0; i < indexes.length; i++) { + let name = distinctNames[indexes[i]]; + if (typeof firstOccurrence[name] === 'number') { + // name is ambiguous + if (typeof ambiguous[name] === 'undefined') { + // initialise ambiguous array with the first occurrence + ambiguous[name] = [firstOccurrence[name]]; + } + ambiguous[name].push(indexes[i]); // append current ambiguous index + } else { + firstOccurrence[name] = indexes[i]; // set the first occurrence of the name + } + } + + let ambiguousNames = Object.keys(ambiguous); + for (let i = 0; i < ambiguousNames.length; i++) { + // For each ambiguous name, resolve the ambiguous indexes + let ambiguousIndexes = ambiguous[ambiguousNames[i]], retestIndexes = []; + for (let j = 0; j < ambiguousIndexes.length; j++) { + let ambiguousIndex = ambiguousIndexes[j]; + let nextSep = paths[ambiguousIndex].lastIndexOf('/', paths[ambiguousIndex].length - distinctNames[ambiguousIndex].length - 2); + if (firstSep[ambiguousIndex] < nextSep) { + // prepend the addition path and retest + distinctNames[ambiguousIndex] = paths[ambiguousIndex].substring(nextSep + 1); + retestIndexes.push(ambiguousIndex); + } else { + distinctNames[ambiguousIndex] = paths[ambiguousIndex]; + } + } + if (retestIndexes.length > 1) { + // If there are 2 or more indexes that may be ambiguous + resolveAmbiguous(retestIndexes); + } + } + }; + + // Initialise recursion + const indexes = []; + for (let i = 0; i < repoPaths.length; i++) { + firstSep.push(repoPaths[i].indexOf('/')); + const repo = repos[repoPaths[i]]; + if (repo.name) { + // A name has been set for the repository + paths.push(repoPaths[i]); + names.push(repo.name); + distinctNames.push(repo.name); + } else if (firstSep[i] === repoPaths[i].length - 1 || firstSep[i] === -1) { + // Path has no slashes, or a single trailing slash ==> use the path as the name + paths.push(repoPaths[i]); + names.push(repoPaths[i]); + distinctNames.push(repoPaths[i]); + } else { + paths.push(repoPaths[i].endsWith('/') ? repoPaths[i].substring(0, repoPaths[i].length - 1) : repoPaths[i]); // Remove trailing slash if it exists + let name = paths[i].substring(paths[i].lastIndexOf('/') + 1); + names.push(name); + distinctNames.push(name); + indexes.push(i); + } + } + resolveAmbiguous(indexes); + + const options: DropdownOption[] = []; + for (let i = 0; i < repoPaths.length; i++) { + let hint; + if (names[i] === distinctNames[i]) { + // Name is distinct, no hint needed + hint = ''; + } else { + // Hint path is the prefix of the distinctName before the common suffix with name + let hintPath = distinctNames[i].substring(0, distinctNames[i].length - names[i].length - 1); + + // Keep two informative directories + let hintComps = hintPath.split('/'); + let keepDirs = hintComps[0] !== '' ? 2 : 3; + if (hintComps.length > keepDirs) hintComps.splice(keepDirs, hintComps.length - keepDirs, '...'); + + // Construct the hint + hint = (distinctNames[i] !== paths[i] ? '.../' : '') + hintComps.join('/'); + } + options.push({ name: names[i], value: repoPaths[i], hint: hint }); + } + return options; +} + +function runAction(msg: GG.RequestMessage, action: string) { + dialog.showActionRunning(action); + sendMessage(msg); +} + +function getBranchLabels(heads: ReadonlyArray, remotes: ReadonlyArray) { + let headLabels: { name: string; remotes: string[] }[] = [], headLookup: { [name: string]: number } = {}, remoteLabels: ReadonlyArray; + for (let i = 0; i < heads.length; i++) { + headLabels.push({ name: heads[i], remotes: [] }); + headLookup[heads[i]] = i; + } + if (initialState.config.referenceLabels.combineLocalAndRemoteBranchLabels) { + let remainingRemoteLabels = []; + for (let i = 0; i < remotes.length; i++) { + if (remotes[i].remote !== null) { // If the remote of the remote branch ref is known + let branchName = remotes[i].name.substring(remotes[i].remote!.length + 1); + if (typeof headLookup[branchName] === 'number') { + headLabels[headLookup[branchName]].remotes.push(remotes[i].remote!); + continue; + } + } + remainingRemoteLabels.push(remotes[i]); + } + remoteLabels = remainingRemoteLabels; + } else { + remoteLabels = remotes; + } + return { heads: headLabels, remotes: remoteLabels }; +} + +function findCommitElemWithId(elems: HTMLCollectionOf, id: number | null) { + if (id === null) return null; + let findIdStr = id.toString(); + for (let i = 0; i < elems.length; i++) { + if (findIdStr === elems[i].dataset.id) return elems[i]; + } + return null; +} + +function generateSignatureHtml(signature: GG.GitSignature) { + return '' + + (signature.status === GG.GitSignatureStatus.GoodAndValid + ? SVG_ICONS.passed + : signature.status === GG.GitSignatureStatus.Bad + ? SVG_ICONS.failed + : SVG_ICONS.inconclusive) + + ''; +} + +function closeDialogAndContextMenu() { + if (dialog.isOpen()) dialog.close(); + if (contextMenu.isOpen()) contextMenu.close(); +} From 91a774253d84d16492671ffae0747de5ae763317 Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 03:46:13 +0200 Subject: [PATCH 2/9] perf: delete timer for Go To Commit if Graph was created (cherry picked from commit ab3264f834e2579db2c09da7850ebef2749b0e11) --- src/commands.ts | 5 +- src/gitGraphView.ts | 1742 +++++++++++++++++++++---------------------- 2 files changed, 873 insertions(+), 874 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 3296037e..0c2794a2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -381,10 +381,9 @@ export class CommandManager extends Disposable { return; } + GitGraphView.currentPanel.isPanelVisible = true; this.view(repository); - setTimeout(() => { - GitGraphView.scrollToCommit(commitHash, true, false, true, true); - }, 2000); + GitGraphView.scrollToCommit(commitHash, true, false, true, true); } else { this.view(undefined); setTimeout(() => { diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index 170be928..e7f0e6b3 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -1,871 +1,871 @@ -import * as path from 'path'; -import * as vscode from 'vscode'; -import { AvatarManager } from './avatarManager'; -import { getConfig } from './config'; -import { DataSource, GitCommitDetailsData, GitConfigKey } from './dataSource'; -import { ExtensionState } from './extensionState'; -import { Logger } from './logger'; -import { RepoFileWatcher } from './repoFileWatcher'; -import { RepoManager } from './repoManager'; -import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types'; -import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from './utils'; -import { Disposable, toDisposable } from './utils/disposable'; - -/** - * Manages the Git Graph View. - */ -export class GitGraphView extends Disposable { - public static currentPanel: GitGraphView | undefined; - - private readonly panel: vscode.WebviewPanel; - private readonly extensionPath: string; - private readonly avatarManager: AvatarManager; - private readonly dataSource: DataSource; - private readonly extensionState: ExtensionState; - private readonly repoFileWatcher: RepoFileWatcher; - private readonly repoManager: RepoManager; - private readonly logger: Logger; - private isGraphViewLoaded: boolean = false; - private isPanelVisible: boolean = true; - private currentRepo: string | null = null; - private loadViewTo: LoadGitGraphViewTo = null; // Is used by the next call to getHtmlForWebview, and is then reset to null - - private loadRepoInfoRefreshId: number = 0; - private loadCommitsRefreshId: number = 0; - - /** - * If a Git Graph View already exists, show and update it. Otherwise, create a Git Graph View. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param avatarManger The Git Graph AvatarManager instance. - * @param repoManager The Git Graph RepoManager instance. - * @param logger The Git Graph Logger instance. - * @param loadViewTo What to load the view to. - */ - public static createOrShow(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo) { - const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; - - if (GitGraphView.currentPanel) { - // If Git Graph panel already exists - if (GitGraphView.currentPanel.isPanelVisible) { - // If the Git Graph panel is visible - if (loadViewTo !== null) { - GitGraphView.currentPanel.respondLoadRepos(repoManager.getRepos(), loadViewTo); - } - } else { - // If the Git Graph panel is not visible - GitGraphView.currentPanel.loadViewTo = loadViewTo; - } - GitGraphView.currentPanel.panel.reveal(column); - } else { - // If Git Graph panel doesn't already exist - GitGraphView.currentPanel = new GitGraphView(extensionPath, dataSource, extensionState, avatarManager, repoManager, logger, loadViewTo, column); - } - } - - /** - * Scroll the view to a commit (if it exists). - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - * @param openDetails Open details of the specified commit. - * @param persistently Persistently find the commit even if it is not exists. - */ - public static scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { - if (GitGraphView.currentPanel) { - GitGraphView.currentPanel.respondScrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); - } - } - - /** - * Creates a Git Graph View. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param avatarManger The Git Graph AvatarManager instance. - * @param repoManager The Git Graph RepoManager instance. - * @param logger The Git Graph Logger instance. - * @param loadViewTo What to load the view to. - * @param column The column the view should be loaded in. - */ - private constructor(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo, column: vscode.ViewColumn | undefined) { - super(); - this.extensionPath = extensionPath; - this.avatarManager = avatarManager; - this.dataSource = dataSource; - this.extensionState = extensionState; - this.repoManager = repoManager; - this.logger = logger; - this.loadViewTo = loadViewTo; - - const config = getConfig(); - this.panel = vscode.window.createWebviewPanel('git-graph', 'Git Graph', column || vscode.ViewColumn.One, { - enableScripts: true, - localResourceRoots: [vscode.Uri.file(path.join(extensionPath, 'media'))], - retainContextWhenHidden: config.retainContextWhenHidden - }); - this.panel.iconPath = config.tabIconColourTheme === TabIconColourTheme.Colour - ? this.getResourcesUri('webview-icon.svg') - : { - light: this.getResourcesUri('webview-icon-light.svg'), - dark: this.getResourcesUri('webview-icon-dark.svg') - }; - - - this.registerDisposables( - // Dispose Git Graph View resources when disposed - toDisposable(() => { - GitGraphView.currentPanel = undefined; - this.repoFileWatcher.stop(); - }), - - // Dispose this Git Graph View when the Webview Panel is disposed - this.panel.onDidDispose(() => this.dispose()), - - // Register a callback that is called when the view is shown or hidden - this.panel.onDidChangeViewState(() => { - if (this.panel.visible !== this.isPanelVisible) { - if (this.panel.visible) { - this.update(); - } else { - this.currentRepo = null; - this.repoFileWatcher.stop(); - } - this.isPanelVisible = this.panel.visible; - } - }), - - // Subscribe to events triggered when a repository is added or deleted from Git Graph - repoManager.onDidChangeRepos((event) => { - if (!this.panel.visible) return; - const loadViewTo = event.loadRepo !== null ? { repo: event.loadRepo } : null; - if ((event.numRepos === 0 && this.isGraphViewLoaded) || (event.numRepos > 0 && !this.isGraphViewLoaded)) { - this.loadViewTo = loadViewTo; - this.update(); - } else { - this.respondLoadRepos(event.repos, loadViewTo); - } - }), - - // Subscribe to events triggered when an avatar is available - avatarManager.onAvatar((event) => { - this.sendMessage({ - command: 'fetchAvatar', - email: event.email, - image: event.image - }); - }), - - // Respond to messages sent from the Webview - this.panel.webview.onDidReceiveMessage((msg) => this.respondToMessage(msg)), - - // Dispose the Webview Panel when disposed - this.panel - ); - - // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View - this.repoFileWatcher = new RepoFileWatcher(logger, () => { - if (this.panel.visible) { - this.sendMessage({ command: 'refresh' }); - } - }); - - // Render the content of the Webview - this.update(); - - this.logger.log('Created Git Graph View' + (loadViewTo !== null ? ' (active repo: ' + loadViewTo.repo + ')' : '')); - } - - /** - * Respond to a message sent from the front-end. - * @param msg The message that was received. - */ - private async respondToMessage(msg: RequestMessage) { - this.repoFileWatcher.mute(); - let errorInfos: ErrorInfo[]; - - switch (msg.command) { - case 'addRemote': - this.sendMessage({ - command: 'addRemote', - error: await this.dataSource.addRemote(msg.repo, msg.name, msg.url, msg.pushUrl, msg.fetch) - }); - break; - case 'addTag': - errorInfos = [await this.dataSource.addTag(msg.repo, msg.tagName, msg.commitHash, msg.type, msg.message, msg.force)]; - if (errorInfos[0] === null && msg.pushToRemote !== null) { - errorInfos.push(...await this.dataSource.pushTag(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.pushSkipRemoteCheck)); - } - this.sendMessage({ - command: 'addTag', - repo: msg.repo, - tagName: msg.tagName, - pushToRemote: msg.pushToRemote, - commitHash: msg.commitHash, - errors: errorInfos - }); - break; - case 'applyStash': - this.sendMessage({ - command: 'applyStash', - error: await this.dataSource.applyStash(msg.repo, msg.selector, msg.reinstateIndex) - }); - break; - case 'branchFromStash': - this.sendMessage({ - command: 'branchFromStash', - error: await this.dataSource.branchFromStash(msg.repo, msg.selector, msg.branchName) - }); - break; - case 'checkoutBranch': - errorInfos = [await this.dataSource.checkoutBranch(msg.repo, msg.branchName, msg.remoteBranch)]; - if (errorInfos[0] === null && msg.pullAfterwards !== null) { - errorInfos.push(await this.dataSource.pullBranch(msg.repo, msg.pullAfterwards.branchName, msg.pullAfterwards.remote, msg.pullAfterwards.createNewCommit, msg.pullAfterwards.squash)); - } - this.sendMessage({ - command: 'checkoutBranch', - pullAfterwards: msg.pullAfterwards, - errors: errorInfos - }); - break; - case 'checkoutCommit': - this.sendMessage({ - command: 'checkoutCommit', - error: await this.dataSource.checkoutCommit(msg.repo, msg.commitHash) - }); - break; - case 'cherrypickCommit': - errorInfos = [await this.dataSource.cherrypickCommit(msg.repo, msg.commitHash, msg.parentIndex, msg.recordOrigin, msg.noCommit)]; - if (errorInfos[0] === null && msg.noCommit) { - errorInfos.push(await viewScm()); - } - this.sendMessage({ command: 'cherrypickCommit', errors: errorInfos }); - break; - case 'cleanUntrackedFiles': - this.sendMessage({ - command: 'cleanUntrackedFiles', - error: await this.dataSource.cleanUntrackedFiles(msg.repo, msg.directories) - }); - break; - case 'commitDetails': - let data = await Promise.all([ - msg.commitHash === UNCOMMITTED - ? this.dataSource.getUncommittedDetails(msg.repo) - : msg.stash === null - ? this.dataSource.getCommitDetails(msg.repo, msg.commitHash, msg.hasParents) - : this.dataSource.getStashDetails(msg.repo, msg.commitHash, msg.stash), - msg.avatarEmail !== null ? this.avatarManager.getAvatarImage(msg.avatarEmail) : Promise.resolve(null) - ]); - this.sendMessage({ - command: 'commitDetails', - ...data[0], - avatar: data[1], - codeReview: msg.commitHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.commitHash) : null, - refresh: msg.refresh - }); - break; - case 'compareCommits': - this.sendMessage({ - command: 'compareCommits', - commitHash: msg.commitHash, - compareWithHash: msg.compareWithHash, - ...await this.dataSource.getCommitComparison(msg.repo, msg.fromHash, msg.toHash), - codeReview: msg.toHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.fromHash + '-' + msg.toHash) : null, - refresh: msg.refresh - }); - break; - case 'copyFilePath': - this.sendMessage({ - command: 'copyFilePath', - error: await copyFilePathToClipboard(msg.repo, msg.filePath, msg.absolute) - }); - break; - case 'copyToClipboard': - this.sendMessage({ - command: 'copyToClipboard', - type: msg.type, - error: await copyToClipboard(msg.data) - }); - break; - case 'createArchive': - this.sendMessage({ - command: 'createArchive', - error: await archive(msg.repo, msg.ref, this.dataSource) - }); - break; - case 'createBranch': - this.sendMessage({ - command: 'createBranch', - errors: await this.dataSource.createBranch(msg.repo, msg.branchName, msg.commitHash, msg.checkout, msg.force) - }); - break; - case 'createPullRequest': - errorInfos = [msg.push ? await this.dataSource.pushBranch(msg.repo, msg.sourceBranch, msg.sourceRemote, true, GitPushBranchMode.Normal) : null]; - if (errorInfos[0] === null) { - errorInfos.push(await createPullRequest(msg.config, msg.sourceOwner, msg.sourceRepo, msg.sourceBranch)); - } - this.sendMessage({ - command: 'createPullRequest', - push: msg.push, - errors: errorInfos - }); - break; - case 'deleteBranch': - errorInfos = [await this.dataSource.deleteBranch(msg.repo, msg.branchName, msg.forceDelete)]; - if (errorInfos[0] === null) { - for (let i = 0; i < msg.deleteOnRemotes.length; i++) { - errorInfos.push(await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.deleteOnRemotes[i])); - } - } - this.sendMessage({ - command: 'deleteBranch', - repo: msg.repo, - branchName: msg.branchName, - deleteOnRemotes: msg.deleteOnRemotes, - errors: errorInfos - }); - break; - case 'deleteRemote': - this.sendMessage({ - command: 'deleteRemote', - error: await this.dataSource.deleteRemote(msg.repo, msg.name) - }); - break; - case 'deleteRemoteBranch': - this.sendMessage({ - command: 'deleteRemoteBranch', - error: await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.remote) - }); - break; - case 'deleteTag': - this.sendMessage({ - command: 'deleteTag', - error: await this.dataSource.deleteTag(msg.repo, msg.tagName, msg.deleteOnRemote) - }); - break; - case 'deleteUserDetails': - errorInfos = []; - if (msg.name) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, msg.location)); - } - if (msg.email) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, msg.location)); - } - this.sendMessage({ - command: 'deleteUserDetails', - errors: errorInfos - }); - break; - case 'dropCommit': - this.sendMessage({ - command: 'dropCommit', - error: await this.dataSource.dropCommit(msg.repo, msg.commitHash) - }); - break; - case 'dropStash': - this.sendMessage({ - command: 'dropStash', - error: await this.dataSource.dropStash(msg.repo, msg.selector) - }); - break; - case 'editRemote': - this.sendMessage({ - command: 'editRemote', - error: await this.dataSource.editRemote(msg.repo, msg.nameOld, msg.nameNew, msg.urlOld, msg.urlNew, msg.pushUrlOld, msg.pushUrlNew) - }); - break; - case 'editUserDetails': - errorInfos = [ - await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserName, msg.name, msg.location), - await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserEmail, msg.email, msg.location) - ]; - if (errorInfos[0] === null && errorInfos[1] === null) { - if (msg.deleteLocalName) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, GitConfigLocation.Local)); - } - if (msg.deleteLocalEmail) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, GitConfigLocation.Local)); - } - } - this.sendMessage({ - command: 'editUserDetails', - errors: errorInfos - }); - break; - case 'endCodeReview': - this.extensionState.endCodeReview(msg.repo, msg.id); - break; - case 'exportRepoConfig': - this.sendMessage({ - command: 'exportRepoConfig', - error: await this.repoManager.exportRepoConfig(msg.repo) - }); - break; - case 'fetch': - this.sendMessage({ - command: 'fetch', - error: await this.dataSource.fetch(msg.repo, msg.name, msg.prune, msg.pruneTags) - }); - break; - case 'fetchAvatar': - this.avatarManager.fetchAvatarImage(msg.email, msg.repo, msg.remote, msg.commits); - break; - case 'fetchIntoLocalBranch': - this.sendMessage({ - command: 'fetchIntoLocalBranch', - error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch, msg.force) - }); - break; - case 'loadCommits': - this.loadCommitsRefreshId = msg.refreshId; - this.sendMessage({ - command: 'loadCommits', - refreshId: msg.refreshId, - onlyFollowFirstParent: msg.onlyFollowFirstParent, - ...await this.dataSource.getCommits(msg.repo, msg.branches, msg.authors, msg.maxCommits, msg.showTags, msg.showRemoteBranches, msg.includeCommitsMentionedByReflogs, msg.onlyFollowFirstParent, msg.commitOrdering, msg.remotes, msg.hideRemotes, msg.stashes, msg.simplifyByDecoration) - }); - break; - case 'loadConfig': - this.sendMessage({ - command: 'loadConfig', - repo: msg.repo, - ...await this.dataSource.getConfig(msg.repo, msg.remotes) - }); - break; - case 'loadRepoInfo': - this.loadRepoInfoRefreshId = msg.refreshId; - let repoInfo = await this.dataSource.getRepoInfo(msg.repo, msg.showRemoteBranches, msg.showStashes, msg.hideRemotes), isRepo = true; - if (repoInfo.error) { - // If an error occurred, check to make sure the repo still exists - isRepo = (await this.dataSource.repoRoot(msg.repo)) !== null; - if (!isRepo) repoInfo.error = null; // If the error is caused by the repo no longer existing, clear the error message - } - this.sendMessage({ - command: 'loadRepoInfo', - refreshId: msg.refreshId, - ...repoInfo, - isRepo: isRepo - }); - if (msg.repo !== this.currentRepo) { - this.currentRepo = msg.repo; - this.extensionState.setLastActiveRepo(msg.repo); - this.repoFileWatcher.start(msg.repo); - } - break; - case 'loadRepos': - if (!msg.check || !await this.repoManager.checkReposExist()) { - // If not required to check repos, or no changes were found when checking, respond with repos - this.respondLoadRepos(this.repoManager.getRepos(), null); - } - break; - case 'merge': - this.sendMessage({ - command: 'merge', - actionOn: msg.actionOn, - error: await this.dataSource.merge(msg.repo, msg.obj, msg.actionOn, msg.createNewCommit, msg.allowUnrelatedHistories, msg.squash, msg.noCommit) - }); - break; - case 'openExtensionSettings': - this.sendMessage({ - command: 'openExtensionSettings', - error: await openExtensionSettings() - }); - break; - case 'openExternalDirDiff': - this.sendMessage({ - command: 'openExternalDirDiff', - error: await this.dataSource.openExternalDirDiff(msg.repo, msg.fromHash, msg.toHash, msg.isGui) - }); - break; - case 'openExternalUrl': - this.sendMessage({ - command: 'openExternalUrl', - error: await openExternalUrl(msg.url) - }); - break; - case 'openFile': - this.sendMessage({ - command: 'openFile', - error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource) - }); - break; - case 'openTerminal': - this.sendMessage({ - command: 'openTerminal', - error: await this.dataSource.openGitTerminal(msg.repo, null, msg.name) - }); - break; - case 'popStash': - this.sendMessage({ - command: 'popStash', - error: await this.dataSource.popStash(msg.repo, msg.selector, msg.reinstateIndex) - }); - break; - case 'pruneRemote': - this.sendMessage({ - command: 'pruneRemote', - error: await this.dataSource.pruneRemote(msg.repo, msg.name) - }); - break; - case 'pullBranch': - this.sendMessage({ - command: 'pullBranch', - error: await this.dataSource.pullBranch(msg.repo, msg.branchName, msg.remote, msg.createNewCommit, msg.squash) - }); - break; - case 'pushBranch': - this.sendMessage({ - command: 'pushBranch', - willUpdateBranchConfig: msg.willUpdateBranchConfig, - errors: await this.dataSource.pushBranchToMultipleRemotes(msg.repo, msg.branchName, msg.remotes, msg.setUpstream, msg.mode) - }); - break; - case 'pushStash': - this.sendMessage({ - command: 'pushStash', - error: await this.dataSource.pushStash(msg.repo, msg.message, msg.includeUntracked) - }); - break; - case 'pushTag': - this.sendMessage({ - command: 'pushTag', - repo: msg.repo, - tagName: msg.tagName, - remotes: msg.remotes, - commitHash: msg.commitHash, - errors: await this.dataSource.pushTag(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.skipRemoteCheck) - }); - break; - case 'rebase': - this.sendMessage({ - command: 'rebase', - actionOn: msg.actionOn, - interactive: msg.interactive, - error: await this.dataSource.rebase(msg.repo, msg.obj, msg.actionOn, msg.ignoreDate, msg.interactive) - }); - break; - case 'renameBranch': - this.sendMessage({ - command: 'renameBranch', - error: await this.dataSource.renameBranch(msg.repo, msg.oldName, msg.newName) - }); - break; - case 'rescanForRepos': - if (!(await this.repoManager.searchWorkspaceForRepos())) { - showErrorMessage('No Git repositories were found in the current workspace.'); - } - break; - case 'resetFileToRevision': - this.sendMessage({ - command: 'resetFileToRevision', - error: await this.dataSource.resetFileToRevision(msg.repo, msg.commitHash, msg.filePath) - }); - break; - case 'resetToCommit': - this.sendMessage({ - command: 'resetToCommit', - error: await this.dataSource.resetToCommit(msg.repo, msg.commit, msg.resetMode) - }); - break; - case 'revertCommit': - this.sendMessage({ - command: 'revertCommit', - error: await this.dataSource.revertCommit(msg.repo, msg.commitHash, msg.parentIndex) - }); - break; - case 'setGlobalViewState': - this.sendMessage({ - command: 'setGlobalViewState', - error: await this.extensionState.setGlobalViewState(msg.state) - }); - break; - case 'setRepoState': - this.repoManager.setRepoState(msg.repo, msg.state); - break; - case 'setWorkspaceViewState': - this.sendMessage({ - command: 'setWorkspaceViewState', - error: await this.extensionState.setWorkspaceViewState(msg.state) - }); - break; - case 'showErrorMessage': - showErrorMessage(msg.message); - break; - case 'startCodeReview': - this.sendMessage({ - command: 'startCodeReview', - commitHash: msg.commitHash, - compareWithHash: msg.compareWithHash, - ...await this.extensionState.startCodeReview(msg.repo, msg.id, msg.files, msg.lastViewedFile) - }); - break; - case 'tagDetails': - this.sendMessage({ - command: 'tagDetails', - tagName: msg.tagName, - commitHash: msg.commitHash, - ...await this.dataSource.getTagDetails(msg.repo, msg.tagName) - }); - break; - case 'updateCodeReview': - this.sendMessage({ - command: 'updateCodeReview', - error: await this.extensionState.updateCodeReview(msg.repo, msg.id, msg.remainingFiles, msg.lastViewedFile) - }); - break; - case 'viewDiff': - this.sendMessage({ - command: 'viewDiff', - error: await viewDiff(msg.repo, msg.fromHash, msg.toHash, msg.oldFilePath, msg.newFilePath, msg.type) - }); - break; - case 'viewDiffWithWorkingFile': - this.sendMessage({ - command: 'viewDiffWithWorkingFile', - error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource) - }); - break; - case 'viewFileAtRevision': - this.sendMessage({ - command: 'viewFileAtRevision', - error: await viewFileAtRevision(msg.repo, msg.hash, msg.filePath) - }); - break; - case 'viewScm': - this.sendMessage({ - command: 'viewScm', - error: await viewScm() - }); - break; - } - - this.repoFileWatcher.unmute(); - } - - /** - * Send a message to the front-end. - * @param msg The message to be sent. - */ - private sendMessage(msg: ResponseMessage) { - if (this.isDisposed()) { - this.logger.log('The Git Graph View has already been disposed, ignored sending "' + msg.command + '" message.'); - } else { - this.panel.webview.postMessage(msg).then( - () => { }, - () => { - if (this.isDisposed()) { - this.logger.log('The Git Graph View was disposed while sending "' + msg.command + '" message.'); - } else { - this.logger.logError('Unable to send "' + msg.command + '" message to the Git Graph View.'); - } - } - ); - } - } - - /** - * Update the HTML document loaded in the Webview. - */ - private update() { - this.panel.webview.html = this.getHtmlForWebview(); - } - - /** - * Get the HTML document to be loaded in the Webview. - * @returns The HTML. - */ - private getHtmlForWebview() { - const config = getConfig(), nonce = getNonce(); - const initialState: GitGraphViewInitialState = { - config: { - commitDetailsView: config.commitDetailsView, - commitOrdering: config.commitOrder, - contextMenuActionsVisibility: config.contextMenuActionsVisibility, - customBranchGlobPatterns: config.customBranchGlobPatterns, - customEmojiShortcodeMappings: config.customEmojiShortcodeMappings, - customPullRequestProviders: config.customPullRequestProviders, - dateFormat: config.dateFormat, - defaultColumnVisibility: config.defaultColumnVisibility, - stickyHeader: config.stickyHeader, - dialogDefaults: config.dialogDefaults, - enhancedAccessibility: config.enhancedAccessibility, - fetchAndPrune: config.fetchAndPrune, - fetchAndPruneTags: config.fetchAndPruneTags, - fetchAvatars: config.fetchAvatars && this.extensionState.isAvatarStorageAvailable(), - graph: config.graph, - includeCommitsMentionedByReflogs: config.includeCommitsMentionedByReflogs, - initialLoadCommits: config.initialLoadCommits, - keybindings: config.keybindings, - loadMoreCommits: config.loadMoreCommits, - loadMoreCommitsAutomatically: config.loadMoreCommitsAutomatically, - markdown: config.markdown, - mute: config.muteCommits, - onlyFollowFirstParent: config.onlyFollowFirstParent, - onRepoLoad: config.onRepoLoad, - referenceLabels: config.referenceLabels, - repoDropdownOrder: config.repoDropdownOrder, - showRemoteBranches: config.showRemoteBranches, - simplifyByDecoration: config.simplifyByDecoration, - showStashes: config.showStashes, - showTags: config.showTags, - toolbarButtonVisibility: config.toolbarButtonVisibility - }, - lastActiveRepo: this.extensionState.getLastActiveRepo(), - loadViewTo: this.loadViewTo, - repos: this.repoManager.getRepos(), - loadRepoInfoRefreshId: this.loadRepoInfoRefreshId, - loadCommitsRefreshId: this.loadCommitsRefreshId - }; - const globalState = this.extensionState.getGlobalViewState(); - const workspaceState = this.extensionState.getWorkspaceViewState(); - - let body, numRepos = Object.keys(initialState.repos).length, colorVars = '', colorParams = ''; - for (let i = 0; i < initialState.config.graph.colours.length; i++) { - colorVars += '--git-graph-color' + i + ':' + initialState.config.graph.colours[i] + '; '; - colorParams += '[data-color="' + i + '"]{--git-graph-color:var(--git-graph-color' + i + ');} '; - } - - if (this.dataSource.isGitExecutableUnknown()) { - body = ` -

    Unable to load Git Graph

    -

    ${UNABLE_TO_FIND_GIT_MSG}

    - `; - } else if (numRepos > 0) { - const stickyClassAttr = initialState.config.stickyHeader ? ' class="sticky"' : ''; - let hideRemotes = '', hideSimplify = ''; - if (!config.toolbarButtonVisibility.remotes) { hideRemotes = 'style="display: none"'; } - if (!config.toolbarButtonVisibility.simplify) { hideSimplify = 'style="display: none"'; } - body = ` -
    -
    - Repo: - Branches: - Authors: - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - `; - } else { - body = ` -

    Unable to load Git Graph

    -

    No Git repositories were found in the current workspace when it was last scanned by Git Graph.

    -

    If your repositories are in subfolders of the open workspace folder(s), make sure you have set the Git Graph Setting "git-graph.maxDepthOfRepoSearch" appropriately (read the documentation for more information).

    -

    Re-scan the current workspace for repositories

    - - `; - } - this.isGraphViewLoaded = numRepos > 0; - this.loadViewTo = null; - - return ` - - - - - - - Git Graph - - - ${body} - `; - } - - - /* URI Manipulation Methods */ - - /** - * Get a WebviewUri for a media file included in the extension. - * @param file The file name in the `media` directory. - * @returns The WebviewUri. - */ - private getMediaUri(file: string) { - return this.panel.webview.asWebviewUri(this.getUri('media', file)); - } - - /** - * Get a File Uri for a resource file included in the extension. - * @param file The file name in the `resource` directory. - * @returns The Uri. - */ - private getResourcesUri(file: string) { - return this.getUri('resources', file); - } - - /** - * Get a File Uri for a file included in the extension. - * @param pathComps The path components relative to the root directory of the extension. - * @returns The File Uri. - */ - private getUri(...pathComps: string[]) { - return vscode.Uri.file(path.join(this.extensionPath, ...pathComps)); - } - - - /* Response Construction Methods */ - - /** - * Send the known repositories to the front-end. - * @param repos The set of known repositories. - * @param loadViewTo What to load the view to. - */ - private respondLoadRepos(repos: GitRepoSet, loadViewTo: LoadGitGraphViewTo) { - this.sendMessage({ - command: 'loadRepos', - repos: repos, - lastActiveRepo: this.extensionState.getLastActiveRepo(), - loadViewTo: loadViewTo - }); - } - - /** - * Call the command to scroll to the specified commit to the front-end. - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - * @param openDetails Open details of the specified commit. - * @param persistently Persistently find the commit even if it is not exists. - */ - public respondScrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { - this.sendMessage({ - command: 'scrollToCommit', - hash: hash, - alwaysCenterCommit: alwaysCenterCommit, - flash: flash, - openDetails: openDetails, - persistently: persistently - }); - } -} - -/** - * Standardise the CSP Source provided by Visual Studio Code for use with the Webview. It is idempotent unless called with http/https URI's, in which case it keeps only the authority portion of the http/https URI. This is necessary to be compatible with some web browser environments. - * @param cspSource The value provide by Visual Studio Code. - * @returns The standardised CSP Source. - */ -export function standardiseCspSource(cspSource: string) { - if (cspSource.startsWith('http://') || cspSource.startsWith('https://')) { - const pathIndex = cspSource.indexOf('/', 8), queryIndex = cspSource.indexOf('?', 8), fragmentIndex = cspSource.indexOf('#', 8); - let endOfAuthorityIndex = pathIndex; - if (queryIndex > -1 && (queryIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = queryIndex; - if (fragmentIndex > -1 && (fragmentIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = fragmentIndex; - return endOfAuthorityIndex > -1 ? cspSource.substring(0, endOfAuthorityIndex) : cspSource; - } else { - return cspSource; - } -} +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AvatarManager } from './avatarManager'; +import { getConfig } from './config'; +import { DataSource, GitCommitDetailsData, GitConfigKey } from './dataSource'; +import { ExtensionState } from './extensionState'; +import { Logger } from './logger'; +import { RepoFileWatcher } from './repoFileWatcher'; +import { RepoManager } from './repoManager'; +import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types'; +import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from './utils'; +import { Disposable, toDisposable } from './utils/disposable'; + +/** + * Manages the Git Graph View. + */ +export class GitGraphView extends Disposable { + public static currentPanel: GitGraphView | undefined; + + private readonly panel: vscode.WebviewPanel; + private readonly extensionPath: string; + private readonly avatarManager: AvatarManager; + private readonly dataSource: DataSource; + private readonly extensionState: ExtensionState; + private readonly repoFileWatcher: RepoFileWatcher; + private readonly repoManager: RepoManager; + private readonly logger: Logger; + private isGraphViewLoaded: boolean = false; + public isPanelVisible: boolean = true; + private currentRepo: string | null = null; + private loadViewTo: LoadGitGraphViewTo = null; // Is used by the next call to getHtmlForWebview, and is then reset to null + + private loadRepoInfoRefreshId: number = 0; + private loadCommitsRefreshId: number = 0; + + /** + * If a Git Graph View already exists, show and update it. Otherwise, create a Git Graph View. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param avatarManger The Git Graph AvatarManager instance. + * @param repoManager The Git Graph RepoManager instance. + * @param logger The Git Graph Logger instance. + * @param loadViewTo What to load the view to. + */ + public static createOrShow(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo) { + const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; + + if (GitGraphView.currentPanel) { + // If Git Graph panel already exists + if (GitGraphView.currentPanel.isPanelVisible) { + // If the Git Graph panel is visible + if (loadViewTo !== null) { + GitGraphView.currentPanel.respondLoadRepos(repoManager.getRepos(), loadViewTo); + } + } else { + // If the Git Graph panel is not visible + GitGraphView.currentPanel.loadViewTo = loadViewTo; + } + GitGraphView.currentPanel.panel.reveal(column); + } else { + // If Git Graph panel doesn't already exist + GitGraphView.currentPanel = new GitGraphView(extensionPath, dataSource, extensionState, avatarManager, repoManager, logger, loadViewTo, column); + } + } + + /** + * Scroll the view to a commit (if it exists). + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public static scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + if (GitGraphView.currentPanel) { + GitGraphView.currentPanel.respondScrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); + } + } + + /** + * Creates a Git Graph View. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param avatarManger The Git Graph AvatarManager instance. + * @param repoManager The Git Graph RepoManager instance. + * @param logger The Git Graph Logger instance. + * @param loadViewTo What to load the view to. + * @param column The column the view should be loaded in. + */ + private constructor(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo, column: vscode.ViewColumn | undefined) { + super(); + this.extensionPath = extensionPath; + this.avatarManager = avatarManager; + this.dataSource = dataSource; + this.extensionState = extensionState; + this.repoManager = repoManager; + this.logger = logger; + this.loadViewTo = loadViewTo; + + const config = getConfig(); + this.panel = vscode.window.createWebviewPanel('git-graph', 'Git Graph', column || vscode.ViewColumn.One, { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(path.join(extensionPath, 'media'))], + retainContextWhenHidden: config.retainContextWhenHidden + }); + this.panel.iconPath = config.tabIconColourTheme === TabIconColourTheme.Colour + ? this.getResourcesUri('webview-icon.svg') + : { + light: this.getResourcesUri('webview-icon-light.svg'), + dark: this.getResourcesUri('webview-icon-dark.svg') + }; + + + this.registerDisposables( + // Dispose Git Graph View resources when disposed + toDisposable(() => { + GitGraphView.currentPanel = undefined; + this.repoFileWatcher.stop(); + }), + + // Dispose this Git Graph View when the Webview Panel is disposed + this.panel.onDidDispose(() => this.dispose()), + + // Register a callback that is called when the view is shown or hidden + this.panel.onDidChangeViewState(() => { + if (this.panel.visible !== this.isPanelVisible) { + if (this.panel.visible) { + this.update(); + } else { + this.currentRepo = null; + this.repoFileWatcher.stop(); + } + this.isPanelVisible = this.panel.visible; + } + }), + + // Subscribe to events triggered when a repository is added or deleted from Git Graph + repoManager.onDidChangeRepos((event) => { + if (!this.panel.visible) return; + const loadViewTo = event.loadRepo !== null ? { repo: event.loadRepo } : null; + if ((event.numRepos === 0 && this.isGraphViewLoaded) || (event.numRepos > 0 && !this.isGraphViewLoaded)) { + this.loadViewTo = loadViewTo; + this.update(); + } else { + this.respondLoadRepos(event.repos, loadViewTo); + } + }), + + // Subscribe to events triggered when an avatar is available + avatarManager.onAvatar((event) => { + this.sendMessage({ + command: 'fetchAvatar', + email: event.email, + image: event.image + }); + }), + + // Respond to messages sent from the Webview + this.panel.webview.onDidReceiveMessage((msg) => this.respondToMessage(msg)), + + // Dispose the Webview Panel when disposed + this.panel + ); + + // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View + this.repoFileWatcher = new RepoFileWatcher(logger, () => { + if (this.panel.visible) { + this.sendMessage({ command: 'refresh' }); + } + }); + + // Render the content of the Webview + this.update(); + + this.logger.log('Created Git Graph View' + (loadViewTo !== null ? ' (active repo: ' + loadViewTo.repo + ')' : '')); + } + + /** + * Respond to a message sent from the front-end. + * @param msg The message that was received. + */ + private async respondToMessage(msg: RequestMessage) { + this.repoFileWatcher.mute(); + let errorInfos: ErrorInfo[]; + + switch (msg.command) { + case 'addRemote': + this.sendMessage({ + command: 'addRemote', + error: await this.dataSource.addRemote(msg.repo, msg.name, msg.url, msg.pushUrl, msg.fetch) + }); + break; + case 'addTag': + errorInfos = [await this.dataSource.addTag(msg.repo, msg.tagName, msg.commitHash, msg.type, msg.message, msg.force)]; + if (errorInfos[0] === null && msg.pushToRemote !== null) { + errorInfos.push(...await this.dataSource.pushTag(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.pushSkipRemoteCheck)); + } + this.sendMessage({ + command: 'addTag', + repo: msg.repo, + tagName: msg.tagName, + pushToRemote: msg.pushToRemote, + commitHash: msg.commitHash, + errors: errorInfos + }); + break; + case 'applyStash': + this.sendMessage({ + command: 'applyStash', + error: await this.dataSource.applyStash(msg.repo, msg.selector, msg.reinstateIndex) + }); + break; + case 'branchFromStash': + this.sendMessage({ + command: 'branchFromStash', + error: await this.dataSource.branchFromStash(msg.repo, msg.selector, msg.branchName) + }); + break; + case 'checkoutBranch': + errorInfos = [await this.dataSource.checkoutBranch(msg.repo, msg.branchName, msg.remoteBranch)]; + if (errorInfos[0] === null && msg.pullAfterwards !== null) { + errorInfos.push(await this.dataSource.pullBranch(msg.repo, msg.pullAfterwards.branchName, msg.pullAfterwards.remote, msg.pullAfterwards.createNewCommit, msg.pullAfterwards.squash)); + } + this.sendMessage({ + command: 'checkoutBranch', + pullAfterwards: msg.pullAfterwards, + errors: errorInfos + }); + break; + case 'checkoutCommit': + this.sendMessage({ + command: 'checkoutCommit', + error: await this.dataSource.checkoutCommit(msg.repo, msg.commitHash) + }); + break; + case 'cherrypickCommit': + errorInfos = [await this.dataSource.cherrypickCommit(msg.repo, msg.commitHash, msg.parentIndex, msg.recordOrigin, msg.noCommit)]; + if (errorInfos[0] === null && msg.noCommit) { + errorInfos.push(await viewScm()); + } + this.sendMessage({ command: 'cherrypickCommit', errors: errorInfos }); + break; + case 'cleanUntrackedFiles': + this.sendMessage({ + command: 'cleanUntrackedFiles', + error: await this.dataSource.cleanUntrackedFiles(msg.repo, msg.directories) + }); + break; + case 'commitDetails': + let data = await Promise.all([ + msg.commitHash === UNCOMMITTED + ? this.dataSource.getUncommittedDetails(msg.repo) + : msg.stash === null + ? this.dataSource.getCommitDetails(msg.repo, msg.commitHash, msg.hasParents) + : this.dataSource.getStashDetails(msg.repo, msg.commitHash, msg.stash), + msg.avatarEmail !== null ? this.avatarManager.getAvatarImage(msg.avatarEmail) : Promise.resolve(null) + ]); + this.sendMessage({ + command: 'commitDetails', + ...data[0], + avatar: data[1], + codeReview: msg.commitHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.commitHash) : null, + refresh: msg.refresh + }); + break; + case 'compareCommits': + this.sendMessage({ + command: 'compareCommits', + commitHash: msg.commitHash, + compareWithHash: msg.compareWithHash, + ...await this.dataSource.getCommitComparison(msg.repo, msg.fromHash, msg.toHash), + codeReview: msg.toHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.fromHash + '-' + msg.toHash) : null, + refresh: msg.refresh + }); + break; + case 'copyFilePath': + this.sendMessage({ + command: 'copyFilePath', + error: await copyFilePathToClipboard(msg.repo, msg.filePath, msg.absolute) + }); + break; + case 'copyToClipboard': + this.sendMessage({ + command: 'copyToClipboard', + type: msg.type, + error: await copyToClipboard(msg.data) + }); + break; + case 'createArchive': + this.sendMessage({ + command: 'createArchive', + error: await archive(msg.repo, msg.ref, this.dataSource) + }); + break; + case 'createBranch': + this.sendMessage({ + command: 'createBranch', + errors: await this.dataSource.createBranch(msg.repo, msg.branchName, msg.commitHash, msg.checkout, msg.force) + }); + break; + case 'createPullRequest': + errorInfos = [msg.push ? await this.dataSource.pushBranch(msg.repo, msg.sourceBranch, msg.sourceRemote, true, GitPushBranchMode.Normal) : null]; + if (errorInfos[0] === null) { + errorInfos.push(await createPullRequest(msg.config, msg.sourceOwner, msg.sourceRepo, msg.sourceBranch)); + } + this.sendMessage({ + command: 'createPullRequest', + push: msg.push, + errors: errorInfos + }); + break; + case 'deleteBranch': + errorInfos = [await this.dataSource.deleteBranch(msg.repo, msg.branchName, msg.forceDelete)]; + if (errorInfos[0] === null) { + for (let i = 0; i < msg.deleteOnRemotes.length; i++) { + errorInfos.push(await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.deleteOnRemotes[i])); + } + } + this.sendMessage({ + command: 'deleteBranch', + repo: msg.repo, + branchName: msg.branchName, + deleteOnRemotes: msg.deleteOnRemotes, + errors: errorInfos + }); + break; + case 'deleteRemote': + this.sendMessage({ + command: 'deleteRemote', + error: await this.dataSource.deleteRemote(msg.repo, msg.name) + }); + break; + case 'deleteRemoteBranch': + this.sendMessage({ + command: 'deleteRemoteBranch', + error: await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.remote) + }); + break; + case 'deleteTag': + this.sendMessage({ + command: 'deleteTag', + error: await this.dataSource.deleteTag(msg.repo, msg.tagName, msg.deleteOnRemote) + }); + break; + case 'deleteUserDetails': + errorInfos = []; + if (msg.name) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, msg.location)); + } + if (msg.email) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, msg.location)); + } + this.sendMessage({ + command: 'deleteUserDetails', + errors: errorInfos + }); + break; + case 'dropCommit': + this.sendMessage({ + command: 'dropCommit', + error: await this.dataSource.dropCommit(msg.repo, msg.commitHash) + }); + break; + case 'dropStash': + this.sendMessage({ + command: 'dropStash', + error: await this.dataSource.dropStash(msg.repo, msg.selector) + }); + break; + case 'editRemote': + this.sendMessage({ + command: 'editRemote', + error: await this.dataSource.editRemote(msg.repo, msg.nameOld, msg.nameNew, msg.urlOld, msg.urlNew, msg.pushUrlOld, msg.pushUrlNew) + }); + break; + case 'editUserDetails': + errorInfos = [ + await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserName, msg.name, msg.location), + await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserEmail, msg.email, msg.location) + ]; + if (errorInfos[0] === null && errorInfos[1] === null) { + if (msg.deleteLocalName) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, GitConfigLocation.Local)); + } + if (msg.deleteLocalEmail) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, GitConfigLocation.Local)); + } + } + this.sendMessage({ + command: 'editUserDetails', + errors: errorInfos + }); + break; + case 'endCodeReview': + this.extensionState.endCodeReview(msg.repo, msg.id); + break; + case 'exportRepoConfig': + this.sendMessage({ + command: 'exportRepoConfig', + error: await this.repoManager.exportRepoConfig(msg.repo) + }); + break; + case 'fetch': + this.sendMessage({ + command: 'fetch', + error: await this.dataSource.fetch(msg.repo, msg.name, msg.prune, msg.pruneTags) + }); + break; + case 'fetchAvatar': + this.avatarManager.fetchAvatarImage(msg.email, msg.repo, msg.remote, msg.commits); + break; + case 'fetchIntoLocalBranch': + this.sendMessage({ + command: 'fetchIntoLocalBranch', + error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch, msg.force) + }); + break; + case 'loadCommits': + this.loadCommitsRefreshId = msg.refreshId; + this.sendMessage({ + command: 'loadCommits', + refreshId: msg.refreshId, + onlyFollowFirstParent: msg.onlyFollowFirstParent, + ...await this.dataSource.getCommits(msg.repo, msg.branches, msg.authors, msg.maxCommits, msg.showTags, msg.showRemoteBranches, msg.includeCommitsMentionedByReflogs, msg.onlyFollowFirstParent, msg.commitOrdering, msg.remotes, msg.hideRemotes, msg.stashes, msg.simplifyByDecoration) + }); + break; + case 'loadConfig': + this.sendMessage({ + command: 'loadConfig', + repo: msg.repo, + ...await this.dataSource.getConfig(msg.repo, msg.remotes) + }); + break; + case 'loadRepoInfo': + this.loadRepoInfoRefreshId = msg.refreshId; + let repoInfo = await this.dataSource.getRepoInfo(msg.repo, msg.showRemoteBranches, msg.showStashes, msg.hideRemotes), isRepo = true; + if (repoInfo.error) { + // If an error occurred, check to make sure the repo still exists + isRepo = (await this.dataSource.repoRoot(msg.repo)) !== null; + if (!isRepo) repoInfo.error = null; // If the error is caused by the repo no longer existing, clear the error message + } + this.sendMessage({ + command: 'loadRepoInfo', + refreshId: msg.refreshId, + ...repoInfo, + isRepo: isRepo + }); + if (msg.repo !== this.currentRepo) { + this.currentRepo = msg.repo; + this.extensionState.setLastActiveRepo(msg.repo); + this.repoFileWatcher.start(msg.repo); + } + break; + case 'loadRepos': + if (!msg.check || !await this.repoManager.checkReposExist()) { + // If not required to check repos, or no changes were found when checking, respond with repos + this.respondLoadRepos(this.repoManager.getRepos(), null); + } + break; + case 'merge': + this.sendMessage({ + command: 'merge', + actionOn: msg.actionOn, + error: await this.dataSource.merge(msg.repo, msg.obj, msg.actionOn, msg.createNewCommit, msg.allowUnrelatedHistories, msg.squash, msg.noCommit) + }); + break; + case 'openExtensionSettings': + this.sendMessage({ + command: 'openExtensionSettings', + error: await openExtensionSettings() + }); + break; + case 'openExternalDirDiff': + this.sendMessage({ + command: 'openExternalDirDiff', + error: await this.dataSource.openExternalDirDiff(msg.repo, msg.fromHash, msg.toHash, msg.isGui) + }); + break; + case 'openExternalUrl': + this.sendMessage({ + command: 'openExternalUrl', + error: await openExternalUrl(msg.url) + }); + break; + case 'openFile': + this.sendMessage({ + command: 'openFile', + error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource) + }); + break; + case 'openTerminal': + this.sendMessage({ + command: 'openTerminal', + error: await this.dataSource.openGitTerminal(msg.repo, null, msg.name) + }); + break; + case 'popStash': + this.sendMessage({ + command: 'popStash', + error: await this.dataSource.popStash(msg.repo, msg.selector, msg.reinstateIndex) + }); + break; + case 'pruneRemote': + this.sendMessage({ + command: 'pruneRemote', + error: await this.dataSource.pruneRemote(msg.repo, msg.name) + }); + break; + case 'pullBranch': + this.sendMessage({ + command: 'pullBranch', + error: await this.dataSource.pullBranch(msg.repo, msg.branchName, msg.remote, msg.createNewCommit, msg.squash) + }); + break; + case 'pushBranch': + this.sendMessage({ + command: 'pushBranch', + willUpdateBranchConfig: msg.willUpdateBranchConfig, + errors: await this.dataSource.pushBranchToMultipleRemotes(msg.repo, msg.branchName, msg.remotes, msg.setUpstream, msg.mode) + }); + break; + case 'pushStash': + this.sendMessage({ + command: 'pushStash', + error: await this.dataSource.pushStash(msg.repo, msg.message, msg.includeUntracked) + }); + break; + case 'pushTag': + this.sendMessage({ + command: 'pushTag', + repo: msg.repo, + tagName: msg.tagName, + remotes: msg.remotes, + commitHash: msg.commitHash, + errors: await this.dataSource.pushTag(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.skipRemoteCheck) + }); + break; + case 'rebase': + this.sendMessage({ + command: 'rebase', + actionOn: msg.actionOn, + interactive: msg.interactive, + error: await this.dataSource.rebase(msg.repo, msg.obj, msg.actionOn, msg.ignoreDate, msg.interactive) + }); + break; + case 'renameBranch': + this.sendMessage({ + command: 'renameBranch', + error: await this.dataSource.renameBranch(msg.repo, msg.oldName, msg.newName) + }); + break; + case 'rescanForRepos': + if (!(await this.repoManager.searchWorkspaceForRepos())) { + showErrorMessage('No Git repositories were found in the current workspace.'); + } + break; + case 'resetFileToRevision': + this.sendMessage({ + command: 'resetFileToRevision', + error: await this.dataSource.resetFileToRevision(msg.repo, msg.commitHash, msg.filePath) + }); + break; + case 'resetToCommit': + this.sendMessage({ + command: 'resetToCommit', + error: await this.dataSource.resetToCommit(msg.repo, msg.commit, msg.resetMode) + }); + break; + case 'revertCommit': + this.sendMessage({ + command: 'revertCommit', + error: await this.dataSource.revertCommit(msg.repo, msg.commitHash, msg.parentIndex) + }); + break; + case 'setGlobalViewState': + this.sendMessage({ + command: 'setGlobalViewState', + error: await this.extensionState.setGlobalViewState(msg.state) + }); + break; + case 'setRepoState': + this.repoManager.setRepoState(msg.repo, msg.state); + break; + case 'setWorkspaceViewState': + this.sendMessage({ + command: 'setWorkspaceViewState', + error: await this.extensionState.setWorkspaceViewState(msg.state) + }); + break; + case 'showErrorMessage': + showErrorMessage(msg.message); + break; + case 'startCodeReview': + this.sendMessage({ + command: 'startCodeReview', + commitHash: msg.commitHash, + compareWithHash: msg.compareWithHash, + ...await this.extensionState.startCodeReview(msg.repo, msg.id, msg.files, msg.lastViewedFile) + }); + break; + case 'tagDetails': + this.sendMessage({ + command: 'tagDetails', + tagName: msg.tagName, + commitHash: msg.commitHash, + ...await this.dataSource.getTagDetails(msg.repo, msg.tagName) + }); + break; + case 'updateCodeReview': + this.sendMessage({ + command: 'updateCodeReview', + error: await this.extensionState.updateCodeReview(msg.repo, msg.id, msg.remainingFiles, msg.lastViewedFile) + }); + break; + case 'viewDiff': + this.sendMessage({ + command: 'viewDiff', + error: await viewDiff(msg.repo, msg.fromHash, msg.toHash, msg.oldFilePath, msg.newFilePath, msg.type) + }); + break; + case 'viewDiffWithWorkingFile': + this.sendMessage({ + command: 'viewDiffWithWorkingFile', + error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource) + }); + break; + case 'viewFileAtRevision': + this.sendMessage({ + command: 'viewFileAtRevision', + error: await viewFileAtRevision(msg.repo, msg.hash, msg.filePath) + }); + break; + case 'viewScm': + this.sendMessage({ + command: 'viewScm', + error: await viewScm() + }); + break; + } + + this.repoFileWatcher.unmute(); + } + + /** + * Send a message to the front-end. + * @param msg The message to be sent. + */ + private sendMessage(msg: ResponseMessage) { + if (this.isDisposed()) { + this.logger.log('The Git Graph View has already been disposed, ignored sending "' + msg.command + '" message.'); + } else { + this.panel.webview.postMessage(msg).then( + () => { }, + () => { + if (this.isDisposed()) { + this.logger.log('The Git Graph View was disposed while sending "' + msg.command + '" message.'); + } else { + this.logger.logError('Unable to send "' + msg.command + '" message to the Git Graph View.'); + } + } + ); + } + } + + /** + * Update the HTML document loaded in the Webview. + */ + private update() { + this.panel.webview.html = this.getHtmlForWebview(); + } + + /** + * Get the HTML document to be loaded in the Webview. + * @returns The HTML. + */ + private getHtmlForWebview() { + const config = getConfig(), nonce = getNonce(); + const initialState: GitGraphViewInitialState = { + config: { + commitDetailsView: config.commitDetailsView, + commitOrdering: config.commitOrder, + contextMenuActionsVisibility: config.contextMenuActionsVisibility, + customBranchGlobPatterns: config.customBranchGlobPatterns, + customEmojiShortcodeMappings: config.customEmojiShortcodeMappings, + customPullRequestProviders: config.customPullRequestProviders, + dateFormat: config.dateFormat, + defaultColumnVisibility: config.defaultColumnVisibility, + stickyHeader: config.stickyHeader, + dialogDefaults: config.dialogDefaults, + enhancedAccessibility: config.enhancedAccessibility, + fetchAndPrune: config.fetchAndPrune, + fetchAndPruneTags: config.fetchAndPruneTags, + fetchAvatars: config.fetchAvatars && this.extensionState.isAvatarStorageAvailable(), + graph: config.graph, + includeCommitsMentionedByReflogs: config.includeCommitsMentionedByReflogs, + initialLoadCommits: config.initialLoadCommits, + keybindings: config.keybindings, + loadMoreCommits: config.loadMoreCommits, + loadMoreCommitsAutomatically: config.loadMoreCommitsAutomatically, + markdown: config.markdown, + mute: config.muteCommits, + onlyFollowFirstParent: config.onlyFollowFirstParent, + onRepoLoad: config.onRepoLoad, + referenceLabels: config.referenceLabels, + repoDropdownOrder: config.repoDropdownOrder, + showRemoteBranches: config.showRemoteBranches, + simplifyByDecoration: config.simplifyByDecoration, + showStashes: config.showStashes, + showTags: config.showTags, + toolbarButtonVisibility: config.toolbarButtonVisibility + }, + lastActiveRepo: this.extensionState.getLastActiveRepo(), + loadViewTo: this.loadViewTo, + repos: this.repoManager.getRepos(), + loadRepoInfoRefreshId: this.loadRepoInfoRefreshId, + loadCommitsRefreshId: this.loadCommitsRefreshId + }; + const globalState = this.extensionState.getGlobalViewState(); + const workspaceState = this.extensionState.getWorkspaceViewState(); + + let body, numRepos = Object.keys(initialState.repos).length, colorVars = '', colorParams = ''; + for (let i = 0; i < initialState.config.graph.colours.length; i++) { + colorVars += '--git-graph-color' + i + ':' + initialState.config.graph.colours[i] + '; '; + colorParams += '[data-color="' + i + '"]{--git-graph-color:var(--git-graph-color' + i + ');} '; + } + + if (this.dataSource.isGitExecutableUnknown()) { + body = ` +

    Unable to load Git Graph

    +

    ${UNABLE_TO_FIND_GIT_MSG}

    + `; + } else if (numRepos > 0) { + const stickyClassAttr = initialState.config.stickyHeader ? ' class="sticky"' : ''; + let hideRemotes = '', hideSimplify = ''; + if (!config.toolbarButtonVisibility.remotes) { hideRemotes = 'style="display: none"'; } + if (!config.toolbarButtonVisibility.simplify) { hideSimplify = 'style="display: none"'; } + body = ` +
    +
    + Repo: + Branches: + Authors: + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + `; + } else { + body = ` +

    Unable to load Git Graph

    +

    No Git repositories were found in the current workspace when it was last scanned by Git Graph.

    +

    If your repositories are in subfolders of the open workspace folder(s), make sure you have set the Git Graph Setting "git-graph.maxDepthOfRepoSearch" appropriately (read the documentation for more information).

    +

    Re-scan the current workspace for repositories

    + + `; + } + this.isGraphViewLoaded = numRepos > 0; + this.loadViewTo = null; + + return ` + + + + + + + Git Graph + + + ${body} + `; + } + + + /* URI Manipulation Methods */ + + /** + * Get a WebviewUri for a media file included in the extension. + * @param file The file name in the `media` directory. + * @returns The WebviewUri. + */ + private getMediaUri(file: string) { + return this.panel.webview.asWebviewUri(this.getUri('media', file)); + } + + /** + * Get a File Uri for a resource file included in the extension. + * @param file The file name in the `resource` directory. + * @returns The Uri. + */ + private getResourcesUri(file: string) { + return this.getUri('resources', file); + } + + /** + * Get a File Uri for a file included in the extension. + * @param pathComps The path components relative to the root directory of the extension. + * @returns The File Uri. + */ + private getUri(...pathComps: string[]) { + return vscode.Uri.file(path.join(this.extensionPath, ...pathComps)); + } + + + /* Response Construction Methods */ + + /** + * Send the known repositories to the front-end. + * @param repos The set of known repositories. + * @param loadViewTo What to load the view to. + */ + private respondLoadRepos(repos: GitRepoSet, loadViewTo: LoadGitGraphViewTo) { + this.sendMessage({ + command: 'loadRepos', + repos: repos, + lastActiveRepo: this.extensionState.getLastActiveRepo(), + loadViewTo: loadViewTo + }); + } + + /** + * Call the command to scroll to the specified commit to the front-end. + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public respondScrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + this.sendMessage({ + command: 'scrollToCommit', + hash: hash, + alwaysCenterCommit: alwaysCenterCommit, + flash: flash, + openDetails: openDetails, + persistently: persistently + }); + } +} + +/** + * Standardise the CSP Source provided by Visual Studio Code for use with the Webview. It is idempotent unless called with http/https URI's, in which case it keeps only the authority portion of the http/https URI. This is necessary to be compatible with some web browser environments. + * @param cspSource The value provide by Visual Studio Code. + * @returns The standardised CSP Source. + */ +export function standardiseCspSource(cspSource: string) { + if (cspSource.startsWith('http://') || cspSource.startsWith('https://')) { + const pathIndex = cspSource.indexOf('/', 8), queryIndex = cspSource.indexOf('?', 8), fragmentIndex = cspSource.indexOf('#', 8); + let endOfAuthorityIndex = pathIndex; + if (queryIndex > -1 && (queryIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = queryIndex; + if (fragmentIndex > -1 && (fragmentIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = fragmentIndex; + return endOfAuthorityIndex > -1 ? cspSource.substring(0, endOfAuthorityIndex) : cspSource; + } else { + return cspSource; + } +} From d014d27c6a4c38ea6752b238c674b8f2199cd1d5 Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 03:47:05 +0200 Subject: [PATCH 3/9] fix: wrong repository definition (cherry picked from commit f7222d88bc2c4ff46cee2ca6bab0d437090f3cd4) --- src/commands.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 0c2794a2..8e55e03a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -365,24 +365,8 @@ export class CommandManager extends Disposable { if (commitHash !== '') { if (GitGraphView.currentPanel) { - const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports; - if (!gitExtension) { - showErrorMessage('Unable to load Git extension.'); - return; - } - - // Get the API from the Git extension - const api = gitExtension.getAPI(1); - - // Access the first repository (assuming there is one) - const repository = api.repositories[0]; - if (!repository) { - showErrorMessage('No Git repository found.'); - return; - } - GitGraphView.currentPanel.isPanelVisible = true; - this.view(repository); + this.view(undefined); GitGraphView.scrollToCommit(commitHash, true, false, true, true); } else { this.view(undefined); From 02891f195a390aa1f70189e3e8a7204bf6156aab Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 03:48:01 +0200 Subject: [PATCH 4/9] perf: delete timer and rework recursion to callbacks for Go To Commit (cherry picked from commit 3a2d3ee80c2eaf4e60ff8411371e8b695280f157) --- src/commands.ts | 8 +++----- web/main.ts | 42 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 8e55e03a..e91aca04 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -364,15 +364,13 @@ export class CommandManager extends Disposable { } if (commitHash !== '') { - if (GitGraphView.currentPanel) { + if (GitGraphView.currentPanel) { // graph exist GitGraphView.currentPanel.isPanelVisible = true; this.view(undefined); GitGraphView.scrollToCommit(commitHash, true, false, true, true); - } else { + } else { // graph is creating this.view(undefined); - setTimeout(() => { - GitGraphView.scrollToCommit(commitHash, true, false, true, true); - }, 4000); + GitGraphView.scrollToCommit(commitHash, true, false, true, true); } return; } else { diff --git a/web/main.ts b/web/main.ts index de1411d1..b5618e67 100644 --- a/web/main.ts +++ b/web/main.ts @@ -28,6 +28,14 @@ class GitGraphView { }; private loadViewTo: GG.LoadGitGraphViewTo = null; + public scrollToCommitArgs: { + hash: string, + alwaysCenterCommit: boolean, + flash: boolean, + openDetails: boolean, + persistently: boolean + }; + private readonly graph: Graph; private readonly config: Config; @@ -73,6 +81,14 @@ class GitGraphView { requestingConfig: false }; + this.scrollToCommitArgs = { + hash: '', + alwaysCenterCommit: false, + flash: false, + openDetails: false, + persistently: false + }; + this.controlsElem = document.getElementById('controls')!; this.tableElem = document.getElementById('commitTable')!; this.tableColHeadersElem = document.getElementById('tableColHeaders')!; @@ -439,6 +455,10 @@ class GitGraphView { } this.finaliseRepoLoad(true); + + if (this.scrollToCommitArgs.persistently) { + this.scrollToCommit(this.scrollToCommitArgs.hash, this.scrollToCommitArgs.alwaysCenterCommit, this.scrollToCommitArgs.flash, this.scrollToCommitArgs.openDetails, this.scrollToCommitArgs.persistently); + } } private finaliseRepoLoad(didLoadRepoData: boolean) { @@ -1984,6 +2004,8 @@ class GitGraphView { * @param persistently Persistently find the commit even if it is not exists. */ public scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + this.scrollToCommitArgs.persistently = false; + const elem = findCommitElemWithId(getCommitElems(), this.getCommitId(hash)); if (elem === null) { if (persistently) { @@ -1995,10 +2017,13 @@ class GitGraphView { const lastCommit = commits[commits.length - 1]; lastCommit.scrollIntoView(); - // Recursive call - setTimeout(() => { - this.scrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); - }, 500); + this.scrollToCommitArgs = { + hash: hash, + alwaysCenterCommit: alwaysCenterCommit, + flash: flash, + openDetails: openDetails, + persistently: persistently + }; } // Do nothing return; @@ -3488,7 +3513,14 @@ window.addEventListener('load', () => { gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); break; case 'scrollToCommit': - gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); + gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); // if graph exist + gitGraph.scrollToCommitArgs = { // if graph is creating + hash: msg.hash, + alwaysCenterCommit: msg.alwaysCenterCommit, + flash: msg.flash, + openDetails: msg.openDetails, + persistently: msg.persistently + }; break; case 'merge': refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); From 5134e068af86ae4eb0a18e88c289278677b85e9b Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 03:49:32 +0200 Subject: [PATCH 5/9] fix: implemented Go To Commit for VS Code git schemas (cherry picked from commit 46926ba4609d41857aea7a0074e722e77c188232) --- package.json | 6 +++--- src/commands.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 814df118..9f6bae69 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "command": "git-graph.goToCommit", "title": "Go To Commit", "icon": "$(git-commit)", - "enablement": "isInDiffEditor" + "enablement": "isInDiffEditor || resourceScheme == scm-history-item" }, { "category": "Git Graph", @@ -1527,7 +1527,7 @@ "commandPalette": [ { "command": "git-graph.goToCommit", - "when": "isInDiffEditor" + "when": "isInDiffEditor || resourceScheme == scm-history-item" }, { "command": "git-graph.openFile", @@ -1538,7 +1538,7 @@ { "command": "git-graph.goToCommit", "group": "navigation@-150", - "when": "isInDiffEditor" + "when": "isInDiffEditor || resourceScheme == scm-history-item" }, { "command": "git-graph.openFile", diff --git a/src/commands.ts b/src/commands.ts index e91aca04..09cb42e7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -353,15 +353,18 @@ export class CommandManager extends Disposable { */ private goToCommit(arg?: vscode.Uri) { const uri = arg || vscode.window.activeTextEditor?.document.uri; - if (typeof uri === 'object' && uri && uri.query) { + if (typeof uri === 'object' && uri) { let commitHash = ''; if (uri.scheme === 'git-graph') { commitHash = decodeDiffDocUri(uri).commit; } - if (uri.scheme === 'gitlens') { + if (uri.scheme === 'git' || uri.scheme === 'gitlens') { commitHash = JSON.parse(uri.query).ref; } + if (uri.scheme === 'scm-history-item') { + commitHash = uri.path.split('..')[1]; + } if (commitHash !== '') { if (GitGraphView.currentPanel) { // graph exist From f3422adef7113d1f771a91c085841460e16129a3 Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Wed, 23 Apr 2025 04:08:39 +0200 Subject: [PATCH 6/9] revert: convert linebreaks from `LF` to `CRLF` --- src/commands.ts | 888 ++--- src/gitGraphView.ts | 1742 ++++----- web/main.ts | 8366 +++++++++++++++++++++---------------------- 3 files changed, 5498 insertions(+), 5498 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 09cb42e7..55f085cd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,444 +1,444 @@ -import * as os from 'os'; -import * as vscode from 'vscode'; -import { AvatarManager } from './avatarManager'; -import { getConfig } from './config'; -import { DataSource } from './dataSource'; -import { DiffDocProvider, decodeDiffDocUri } from './diffDocProvider'; -import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; -import { GitGraphView } from './gitGraphView'; -import { Logger } from './logger'; -import { RepoManager } from './repoManager'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; -import { Disposable } from './utils/disposable'; -import { Event } from './utils/event'; - -/** - * Manages the registration and execution of Git Graph Commands. - */ -export class CommandManager extends Disposable { - private readonly context: vscode.ExtensionContext; - private readonly avatarManager: AvatarManager; - private readonly dataSource: DataSource; - private readonly extensionState: ExtensionState; - private readonly logger: Logger; - private readonly repoManager: RepoManager; - private gitExecutable: GitExecutable | null; - - /** - * Creates the Git Graph Command Manager. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param avatarManger The Git Graph AvatarManager instance. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param repoManager The Git Graph RepoManager instance. - * @param gitExecutable The Git executable available to Git Graph at startup. - * @param onDidChangeGitExecutable The Event emitting the Git executable for Git Graph to use. - * @param logger The Git Graph Logger instance. - */ - constructor(context: vscode.ExtensionContext, avatarManger: AvatarManager, dataSource: DataSource, extensionState: ExtensionState, repoManager: RepoManager, gitExecutable: GitExecutable | null, onDidChangeGitExecutable: Event, logger: Logger) { - super(); - this.context = context; - this.avatarManager = avatarManger; - this.dataSource = dataSource; - this.extensionState = extensionState; - this.logger = logger; - this.repoManager = repoManager; - this.gitExecutable = gitExecutable; - - // Register Extension Commands - this.registerCommand('git-graph.view', (arg) => this.view(arg)); - this.registerCommand('git-graph.addGitRepository', () => this.addGitRepository()); - this.registerCommand('git-graph.removeGitRepository', () => this.removeGitRepository()); - this.registerCommand('git-graph.clearAvatarCache', () => this.clearAvatarCache()); - this.registerCommand('git-graph.fetch', () => this.fetch()); - this.registerCommand('git-graph.endAllWorkspaceCodeReviews', () => this.endAllWorkspaceCodeReviews()); - this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); - this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); - this.registerCommand('git-graph.version', () => this.version()); - this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); - this.registerCommand('git-graph.goToCommit', (arg) => this.goToCommit(arg)); - - this.registerDisposable( - onDidChangeGitExecutable((gitExecutable) => { - this.gitExecutable = gitExecutable; - }) - ); - - // Register Extension Contexts - try { - this.registerContext('git-graph:codiconsSupported', doesVersionMeetRequirement(vscode.version, VsCodeVersionRequirement.Codicons)); - } catch (_) { - this.logger.logError('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); - } - } - - /** - * Register a Git Graph command with Visual Studio Code. - * @param command A unique identifier for the command. - * @param callback A command handler function. - */ - private registerCommand(command: string, callback: (...args: any[]) => any) { - this.registerDisposable( - vscode.commands.registerCommand(command, (...args: any[]) => { - this.logger.log('Command Invoked: ' + command); - callback(...args); - }) - ); - } - - /** - * Register a context with Visual Studio Code. - * @param key The Context Key. - * @param value The Context Value. - */ - private registerContext(key: string, value: any) { - return vscode.commands.executeCommand('setContext', key, value).then( - () => this.logger.log('Successfully set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"'), - () => this.logger.logError('Failed to set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"') - ); - } - - - /* Commands */ - - /** - * The method run when the `git-graph.view` command is invoked. - * @param arg An optional argument passed to the command (when invoked from the Visual Studio Code Git Extension). - */ - private async view(arg: any) { - let loadRepo: string | null = null; - - if (typeof arg === 'object' && arg.rootUri) { - // If command is run from the Visual Studio Code Source Control View, load the specific repo - const repoPath = getPathFromUri(arg.rootUri); - loadRepo = await this.repoManager.getKnownRepo(repoPath); - if (loadRepo === null) { - // The repo is not currently known, add it - loadRepo = (await this.repoManager.registerRepo(await resolveToSymbolicPath(repoPath), true)).root; - } - } else if (getConfig().openToTheRepoOfTheActiveTextEditorDocument && vscode.window.activeTextEditor) { - // If the config setting is enabled, load the repo containing the active text editor document - loadRepo = this.repoManager.getRepoContainingFile(getPathFromUri(vscode.window.activeTextEditor.document.uri)); - } - - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, loadRepo !== null ? { repo: loadRepo } : null); - } - - /** - * The method run when the `git-graph.addGitRepository` command is invoked. - */ - private addGitRepository() { - if (this.gitExecutable === null) { - showErrorMessage(UNABLE_TO_FIND_GIT_MSG); - return; - } - - vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false }).then(uris => { - if (uris && uris.length > 0) { - let path = getPathFromUri(uris[0]); - if (isPathInWorkspace(path)) { - this.repoManager.registerRepo(path, false).then(status => { - if (status.error === null) { - showInformationMessage('The repository "' + status.root! + '" was added to Git Graph.'); - } else { - showErrorMessage(status.error + ' Therefore it could not be added to Git Graph.'); - } - }); - } else { - showErrorMessage('The folder "' + path + '" is not within the opened Visual Studio Code workspace, and therefore could not be added to Git Graph.'); - } - } - }, () => { }); - } - - /** - * The method run when the `git-graph.removeGitRepository` command is invoked. - */ - private removeGitRepository() { - if (this.gitExecutable === null) { - showErrorMessage(UNABLE_TO_FIND_GIT_MSG); - return; - } - - const repos = this.repoManager.getRepos(); - const items: vscode.QuickPickItem[] = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder).map((path) => ({ - label: repos[path].name || getRepoName(path), - description: path - })); - - vscode.window.showQuickPick(items, { - placeHolder: 'Select a repository to remove from Git Graph:', - canPickMany: false - }).then((item) => { - if (item && item.description !== undefined) { - if (this.repoManager.ignoreRepo(item.description)) { - showInformationMessage('The repository "' + item.label + '" was removed from Git Graph.'); - } else { - showErrorMessage('The repository "' + item.label + '" is not known to Git Graph.'); - } - } - }, () => { }); - } - - /** - * The method run when the `git-graph.clearAvatarCache` command is invoked. - */ - private clearAvatarCache() { - this.avatarManager.clearCache().then((errorInfo) => { - if (errorInfo === null) { - showInformationMessage('The Avatar Cache was successfully cleared.'); - } else { - showErrorMessage(errorInfo); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Clear Avatar Cache".'); - }); - } - - /** - * The method run when the `git-graph.fetch` command is invoked. - */ - private fetch() { - const repos = this.repoManager.getRepos(); - const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); - - if (repoPaths.length > 1) { - const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ - label: repos[path].name || getRepoName(path), - description: path - })); - - const lastActiveRepo = this.extensionState.getLastActiveRepo(); - if (lastActiveRepo !== null) { - let lastActiveRepoIndex = items.findIndex((item) => item.description === lastActiveRepo); - if (lastActiveRepoIndex > -1) { - const item = items.splice(lastActiveRepoIndex, 1)[0]; - items.unshift(item); - } - } - - vscode.window.showQuickPick(items, { - placeHolder: 'Select the repository you want to open in Git Graph, and fetch from remote(s):', - canPickMany: false - }).then((item) => { - if (item && item.description) { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: item.description, - runCommandOnLoad: 'fetch' - }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Fetch from Remote(s)".'); - }); - } else if (repoPaths.length === 1) { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: repoPaths[0], - runCommandOnLoad: 'fetch' - }); - } else { - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, null); - } - } - - /** - * The method run when the `git-graph.endAllWorkspaceCodeReviews` command is invoked. - */ - private endAllWorkspaceCodeReviews() { - this.extensionState.endAllWorkspaceCodeReviews(); - showInformationMessage('Ended All Code Reviews in Workspace'); - } - - /** - * The method run when the `git-graph.endSpecificWorkspaceCodeReview` command is invoked. - */ - private endSpecificWorkspaceCodeReview() { - const codeReviews = this.extensionState.getCodeReviews(); - if (Object.keys(codeReviews).length === 0) { - showErrorMessage('There are no Code Reviews in progress within the current workspace.'); - return; - } - - vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { - placeHolder: 'Select the Code Review you want to end:', - canPickMany: false - }).then((item) => { - if (item) { - this.extensionState.endCodeReview(item.codeReviewRepo, item.codeReviewId).then((errorInfo) => { - if (errorInfo === null) { - showInformationMessage('Successfully ended Code Review "' + item.label + '".'); - } else { - showErrorMessage(errorInfo); - } - }, () => { }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "End a specific Code Review in Workspace...".'); - }); - } - - /** - * The method run when the `git-graph.resumeWorkspaceCodeReview` command is invoked. - */ - private resumeWorkspaceCodeReview() { - const codeReviews = this.extensionState.getCodeReviews(); - if (Object.keys(codeReviews).length === 0) { - showErrorMessage('There are no Code Reviews in progress within the current workspace.'); - return; - } - - vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { - placeHolder: 'Select the Code Review you want to resume:', - canPickMany: false - }).then((item) => { - if (item) { - const commitHashes = item.codeReviewId.split('-'); - GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { - repo: item.codeReviewRepo, - commitDetails: { - commitHash: commitHashes[commitHashes.length > 1 ? 1 : 0], - compareWithHash: commitHashes.length > 1 ? commitHashes[0] : null - } - }); - } - }, () => { - showErrorMessage('An unexpected error occurred while running the command "Resume a specific Code Review in Workspace...".'); - }); - } - - /** - * The method run when the `git-graph.version` command is invoked. - */ - private async version() { - try { - const gitGraphVersion = await getExtensionVersion(this.context); - const information = 'Git Graph: ' + gitGraphVersion + '\nVisual Studio Code: ' + vscode.version + '\nOS: ' + os.type() + ' ' + os.arch() + ' ' + os.release() + '\nGit: ' + (this.gitExecutable !== null ? this.gitExecutable.version : '(none)'); - vscode.window.showInformationMessage(information, { modal: true }, 'Copy').then((selectedItem) => { - if (selectedItem === 'Copy') { - copyToClipboard(information).then((result) => { - if (result !== null) { - showErrorMessage(result); - } - }); - } - }, () => { }); - } catch (_) { - showErrorMessage('An unexpected error occurred while retrieving version information.'); - } - } - - /** - * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). - * The method run when the `git-graph.openFile` command is invoked. - * @param arg The Git Graph URI. - */ - private openFile(arg?: vscode.Uri) { - const uri = arg || vscode.window.activeTextEditor?.document.uri; - if (typeof uri === 'object' && uri && uri.scheme === DiffDocProvider.scheme) { - // A Git Graph URI has been provided - const request = decodeDiffDocUri(uri); - return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { - if (errorInfo !== null) { - return showErrorMessage('Unable to Open File: ' + errorInfo); - } - }); - } else { - return showErrorMessage('Unable to Open File: The command was not called with the required arguments.'); - } - } - - /** - * Opens a position commit in Git Graph, based on a Git Graph URI (from the Diff View). - * The method run when the `git-graph.goToCommit` command is invoked. - * @param arg The Git Graph URI. - */ - private goToCommit(arg?: vscode.Uri) { - const uri = arg || vscode.window.activeTextEditor?.document.uri; - if (typeof uri === 'object' && uri) { - let commitHash = ''; - - if (uri.scheme === 'git-graph') { - commitHash = decodeDiffDocUri(uri).commit; - } - if (uri.scheme === 'git' || uri.scheme === 'gitlens') { - commitHash = JSON.parse(uri.query).ref; - } - if (uri.scheme === 'scm-history-item') { - commitHash = uri.path.split('..')[1]; - } - - if (commitHash !== '') { - if (GitGraphView.currentPanel) { // graph exist - GitGraphView.currentPanel.isPanelVisible = true; - this.view(undefined); - GitGraphView.scrollToCommit(commitHash, true, false, true, true); - } else { // graph is creating - this.view(undefined); - GitGraphView.scrollToCommit(commitHash, true, false, true, true); - } - return; - } else { - return showErrorMessage('Unable Go To Commit: The commit hash not found.'); - } - } else { - return showErrorMessage('Unable Go To Commit: The command was not called with the required arguments.'); - } - } - - - /* Helper Methods */ - - /** - * Transform a set of Code Reviews into a list of Quick Pick items for use with `vscode.window.showQuickPick`. - * @param codeReviews A set of Code Reviews. - * @returns A list of Quick Pick items. - */ - private getCodeReviewQuickPickItems(codeReviews: CodeReviews): Promise { - const repos = this.repoManager.getRepos(); - const enrichedCodeReviews: { repo: string, id: string, review: CodeReviewData, fromCommitHash: string, toCommitHash: string }[] = []; - const fetchCommits: { repo: string, commitHash: string }[] = []; - - Object.keys(codeReviews).forEach((repo) => { - if (typeof repos[repo] === 'undefined') return; - Object.keys(codeReviews[repo]).forEach((id) => { - const commitHashes = id.split('-'); - commitHashes.forEach((commitHash) => fetchCommits.push({ repo: repo, commitHash: commitHash })); - enrichedCodeReviews.push({ - repo: repo, id: id, review: codeReviews[repo][id], - fromCommitHash: commitHashes[0], toCommitHash: commitHashes[commitHashes.length > 1 ? 1 : 0] - }); - }); - }); - - return Promise.all(fetchCommits.map((fetch) => this.dataSource.getCommitSubject(fetch.repo, fetch.commitHash))).then( - (subjects) => { - const commitSubjects: { [repo: string]: { [commitHash: string]: string } } = {}; - subjects.forEach((subject, i) => { - if (typeof commitSubjects[fetchCommits[i].repo] === 'undefined') { - commitSubjects[fetchCommits[i].repo] = {}; - } - commitSubjects[fetchCommits[i].repo][fetchCommits[i].commitHash] = subject !== null ? subject : ''; - }); - - return enrichedCodeReviews.sort((a, b) => b.review.lastActive - a.review.lastActive).map((codeReview) => { - const fromSubject = commitSubjects[codeReview.repo][codeReview.fromCommitHash]; - const toSubject = commitSubjects[codeReview.repo][codeReview.toCommitHash]; - const isComparison = codeReview.fromCommitHash !== codeReview.toCommitHash; - return { - codeReviewRepo: codeReview.repo, - codeReviewId: codeReview.id, - label: (repos[codeReview.repo].name || getRepoName(codeReview.repo)) + ': ' + abbrevCommit(codeReview.fromCommitHash) + (isComparison ? ' ↔ ' + abbrevCommit(codeReview.toCommitHash) : ''), - description: getRelativeTimeDiff(Math.round(codeReview.review.lastActive / 1000)), - detail: isComparison - ? abbrevText(fromSubject, 50) + ' ↔ ' + abbrevText(toSubject, 50) - : fromSubject - }; - }); - } - ); - } -} - -interface CodeReviewQuickPickItem extends vscode.QuickPickItem { - codeReviewRepo: string; - codeReviewId: string; -} +import * as os from 'os'; +import * as vscode from 'vscode'; +import { AvatarManager } from './avatarManager'; +import { getConfig } from './config'; +import { DataSource } from './dataSource'; +import { DiffDocProvider, decodeDiffDocUri } from './diffDocProvider'; +import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; +import { GitGraphView } from './gitGraphView'; +import { Logger } from './logger'; +import { RepoManager } from './repoManager'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; +import { Disposable } from './utils/disposable'; +import { Event } from './utils/event'; + +/** + * Manages the registration and execution of Git Graph Commands. + */ +export class CommandManager extends Disposable { + private readonly context: vscode.ExtensionContext; + private readonly avatarManager: AvatarManager; + private readonly dataSource: DataSource; + private readonly extensionState: ExtensionState; + private readonly logger: Logger; + private readonly repoManager: RepoManager; + private gitExecutable: GitExecutable | null; + + /** + * Creates the Git Graph Command Manager. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param avatarManger The Git Graph AvatarManager instance. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param repoManager The Git Graph RepoManager instance. + * @param gitExecutable The Git executable available to Git Graph at startup. + * @param onDidChangeGitExecutable The Event emitting the Git executable for Git Graph to use. + * @param logger The Git Graph Logger instance. + */ + constructor(context: vscode.ExtensionContext, avatarManger: AvatarManager, dataSource: DataSource, extensionState: ExtensionState, repoManager: RepoManager, gitExecutable: GitExecutable | null, onDidChangeGitExecutable: Event, logger: Logger) { + super(); + this.context = context; + this.avatarManager = avatarManger; + this.dataSource = dataSource; + this.extensionState = extensionState; + this.logger = logger; + this.repoManager = repoManager; + this.gitExecutable = gitExecutable; + + // Register Extension Commands + this.registerCommand('git-graph.view', (arg) => this.view(arg)); + this.registerCommand('git-graph.addGitRepository', () => this.addGitRepository()); + this.registerCommand('git-graph.removeGitRepository', () => this.removeGitRepository()); + this.registerCommand('git-graph.clearAvatarCache', () => this.clearAvatarCache()); + this.registerCommand('git-graph.fetch', () => this.fetch()); + this.registerCommand('git-graph.endAllWorkspaceCodeReviews', () => this.endAllWorkspaceCodeReviews()); + this.registerCommand('git-graph.endSpecificWorkspaceCodeReview', () => this.endSpecificWorkspaceCodeReview()); + this.registerCommand('git-graph.resumeWorkspaceCodeReview', () => this.resumeWorkspaceCodeReview()); + this.registerCommand('git-graph.version', () => this.version()); + this.registerCommand('git-graph.openFile', (arg) => this.openFile(arg)); + this.registerCommand('git-graph.goToCommit', (arg) => this.goToCommit(arg)); + + this.registerDisposable( + onDidChangeGitExecutable((gitExecutable) => { + this.gitExecutable = gitExecutable; + }) + ); + + // Register Extension Contexts + try { + this.registerContext('git-graph:codiconsSupported', doesVersionMeetRequirement(vscode.version, VsCodeVersionRequirement.Codicons)); + } catch (_) { + this.logger.logError('Unable to set Visual Studio Code Context "git-graph:codiconsSupported"'); + } + } + + /** + * Register a Git Graph command with Visual Studio Code. + * @param command A unique identifier for the command. + * @param callback A command handler function. + */ + private registerCommand(command: string, callback: (...args: any[]) => any) { + this.registerDisposable( + vscode.commands.registerCommand(command, (...args: any[]) => { + this.logger.log('Command Invoked: ' + command); + callback(...args); + }) + ); + } + + /** + * Register a context with Visual Studio Code. + * @param key The Context Key. + * @param value The Context Value. + */ + private registerContext(key: string, value: any) { + return vscode.commands.executeCommand('setContext', key, value).then( + () => this.logger.log('Successfully set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"'), + () => this.logger.logError('Failed to set Visual Studio Code Context "' + key + '" to "' + JSON.stringify(value) + '"') + ); + } + + + /* Commands */ + + /** + * The method run when the `git-graph.view` command is invoked. + * @param arg An optional argument passed to the command (when invoked from the Visual Studio Code Git Extension). + */ + private async view(arg: any) { + let loadRepo: string | null = null; + + if (typeof arg === 'object' && arg.rootUri) { + // If command is run from the Visual Studio Code Source Control View, load the specific repo + const repoPath = getPathFromUri(arg.rootUri); + loadRepo = await this.repoManager.getKnownRepo(repoPath); + if (loadRepo === null) { + // The repo is not currently known, add it + loadRepo = (await this.repoManager.registerRepo(await resolveToSymbolicPath(repoPath), true)).root; + } + } else if (getConfig().openToTheRepoOfTheActiveTextEditorDocument && vscode.window.activeTextEditor) { + // If the config setting is enabled, load the repo containing the active text editor document + loadRepo = this.repoManager.getRepoContainingFile(getPathFromUri(vscode.window.activeTextEditor.document.uri)); + } + + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, loadRepo !== null ? { repo: loadRepo } : null); + } + + /** + * The method run when the `git-graph.addGitRepository` command is invoked. + */ + private addGitRepository() { + if (this.gitExecutable === null) { + showErrorMessage(UNABLE_TO_FIND_GIT_MSG); + return; + } + + vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false }).then(uris => { + if (uris && uris.length > 0) { + let path = getPathFromUri(uris[0]); + if (isPathInWorkspace(path)) { + this.repoManager.registerRepo(path, false).then(status => { + if (status.error === null) { + showInformationMessage('The repository "' + status.root! + '" was added to Git Graph.'); + } else { + showErrorMessage(status.error + ' Therefore it could not be added to Git Graph.'); + } + }); + } else { + showErrorMessage('The folder "' + path + '" is not within the opened Visual Studio Code workspace, and therefore could not be added to Git Graph.'); + } + } + }, () => { }); + } + + /** + * The method run when the `git-graph.removeGitRepository` command is invoked. + */ + private removeGitRepository() { + if (this.gitExecutable === null) { + showErrorMessage(UNABLE_TO_FIND_GIT_MSG); + return; + } + + const repos = this.repoManager.getRepos(); + const items: vscode.QuickPickItem[] = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder).map((path) => ({ + label: repos[path].name || getRepoName(path), + description: path + })); + + vscode.window.showQuickPick(items, { + placeHolder: 'Select a repository to remove from Git Graph:', + canPickMany: false + }).then((item) => { + if (item && item.description !== undefined) { + if (this.repoManager.ignoreRepo(item.description)) { + showInformationMessage('The repository "' + item.label + '" was removed from Git Graph.'); + } else { + showErrorMessage('The repository "' + item.label + '" is not known to Git Graph.'); + } + } + }, () => { }); + } + + /** + * The method run when the `git-graph.clearAvatarCache` command is invoked. + */ + private clearAvatarCache() { + this.avatarManager.clearCache().then((errorInfo) => { + if (errorInfo === null) { + showInformationMessage('The Avatar Cache was successfully cleared.'); + } else { + showErrorMessage(errorInfo); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Clear Avatar Cache".'); + }); + } + + /** + * The method run when the `git-graph.fetch` command is invoked. + */ + private fetch() { + const repos = this.repoManager.getRepos(); + const repoPaths = getSortedRepositoryPaths(repos, getConfig().repoDropdownOrder); + + if (repoPaths.length > 1) { + const items: vscode.QuickPickItem[] = repoPaths.map((path) => ({ + label: repos[path].name || getRepoName(path), + description: path + })); + + const lastActiveRepo = this.extensionState.getLastActiveRepo(); + if (lastActiveRepo !== null) { + let lastActiveRepoIndex = items.findIndex((item) => item.description === lastActiveRepo); + if (lastActiveRepoIndex > -1) { + const item = items.splice(lastActiveRepoIndex, 1)[0]; + items.unshift(item); + } + } + + vscode.window.showQuickPick(items, { + placeHolder: 'Select the repository you want to open in Git Graph, and fetch from remote(s):', + canPickMany: false + }).then((item) => { + if (item && item.description) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: item.description, + runCommandOnLoad: 'fetch' + }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Fetch from Remote(s)".'); + }); + } else if (repoPaths.length === 1) { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: repoPaths[0], + runCommandOnLoad: 'fetch' + }); + } else { + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, null); + } + } + + /** + * The method run when the `git-graph.endAllWorkspaceCodeReviews` command is invoked. + */ + private endAllWorkspaceCodeReviews() { + this.extensionState.endAllWorkspaceCodeReviews(); + showInformationMessage('Ended All Code Reviews in Workspace'); + } + + /** + * The method run when the `git-graph.endSpecificWorkspaceCodeReview` command is invoked. + */ + private endSpecificWorkspaceCodeReview() { + const codeReviews = this.extensionState.getCodeReviews(); + if (Object.keys(codeReviews).length === 0) { + showErrorMessage('There are no Code Reviews in progress within the current workspace.'); + return; + } + + vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { + placeHolder: 'Select the Code Review you want to end:', + canPickMany: false + }).then((item) => { + if (item) { + this.extensionState.endCodeReview(item.codeReviewRepo, item.codeReviewId).then((errorInfo) => { + if (errorInfo === null) { + showInformationMessage('Successfully ended Code Review "' + item.label + '".'); + } else { + showErrorMessage(errorInfo); + } + }, () => { }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "End a specific Code Review in Workspace...".'); + }); + } + + /** + * The method run when the `git-graph.resumeWorkspaceCodeReview` command is invoked. + */ + private resumeWorkspaceCodeReview() { + const codeReviews = this.extensionState.getCodeReviews(); + if (Object.keys(codeReviews).length === 0) { + showErrorMessage('There are no Code Reviews in progress within the current workspace.'); + return; + } + + vscode.window.showQuickPick(this.getCodeReviewQuickPickItems(codeReviews), { + placeHolder: 'Select the Code Review you want to resume:', + canPickMany: false + }).then((item) => { + if (item) { + const commitHashes = item.codeReviewId.split('-'); + GitGraphView.createOrShow(this.context.extensionPath, this.dataSource, this.extensionState, this.avatarManager, this.repoManager, this.logger, { + repo: item.codeReviewRepo, + commitDetails: { + commitHash: commitHashes[commitHashes.length > 1 ? 1 : 0], + compareWithHash: commitHashes.length > 1 ? commitHashes[0] : null + } + }); + } + }, () => { + showErrorMessage('An unexpected error occurred while running the command "Resume a specific Code Review in Workspace...".'); + }); + } + + /** + * The method run when the `git-graph.version` command is invoked. + */ + private async version() { + try { + const gitGraphVersion = await getExtensionVersion(this.context); + const information = 'Git Graph: ' + gitGraphVersion + '\nVisual Studio Code: ' + vscode.version + '\nOS: ' + os.type() + ' ' + os.arch() + ' ' + os.release() + '\nGit: ' + (this.gitExecutable !== null ? this.gitExecutable.version : '(none)'); + vscode.window.showInformationMessage(information, { modal: true }, 'Copy').then((selectedItem) => { + if (selectedItem === 'Copy') { + copyToClipboard(information).then((result) => { + if (result !== null) { + showErrorMessage(result); + } + }); + } + }, () => { }); + } catch (_) { + showErrorMessage('An unexpected error occurred while retrieving version information.'); + } + } + + /** + * Opens a file in Visual Studio Code, based on a Git Graph URI (from the Diff View). + * The method run when the `git-graph.openFile` command is invoked. + * @param arg The Git Graph URI. + */ + private openFile(arg?: vscode.Uri) { + const uri = arg || vscode.window.activeTextEditor?.document.uri; + if (typeof uri === 'object' && uri && uri.scheme === DiffDocProvider.scheme) { + // A Git Graph URI has been provided + const request = decodeDiffDocUri(uri); + return openFile(request.repo, request.filePath, request.commit, this.dataSource, vscode.ViewColumn.Active).then((errorInfo) => { + if (errorInfo !== null) { + return showErrorMessage('Unable to Open File: ' + errorInfo); + } + }); + } else { + return showErrorMessage('Unable to Open File: The command was not called with the required arguments.'); + } + } + + /** + * Opens a position commit in Git Graph, based on a Git Graph URI (from the Diff View). + * The method run when the `git-graph.goToCommit` command is invoked. + * @param arg The Git Graph URI. + */ + private goToCommit(arg?: vscode.Uri) { + const uri = arg || vscode.window.activeTextEditor?.document.uri; + if (typeof uri === 'object' && uri) { + let commitHash = ''; + + if (uri.scheme === 'git-graph') { + commitHash = decodeDiffDocUri(uri).commit; + } + if (uri.scheme === 'git' || uri.scheme === 'gitlens') { + commitHash = JSON.parse(uri.query).ref; + } + if (uri.scheme === 'scm-history-item') { + commitHash = uri.path.split('..')[1]; + } + + if (commitHash !== '') { + if (GitGraphView.currentPanel) { // graph exist + GitGraphView.currentPanel.isPanelVisible = true; + this.view(undefined); + GitGraphView.scrollToCommit(commitHash, true, false, true, true); + } else { // graph is creating + this.view(undefined); + GitGraphView.scrollToCommit(commitHash, true, false, true, true); + } + return; + } else { + return showErrorMessage('Unable Go To Commit: The commit hash not found.'); + } + } else { + return showErrorMessage('Unable Go To Commit: The command was not called with the required arguments.'); + } + } + + + /* Helper Methods */ + + /** + * Transform a set of Code Reviews into a list of Quick Pick items for use with `vscode.window.showQuickPick`. + * @param codeReviews A set of Code Reviews. + * @returns A list of Quick Pick items. + */ + private getCodeReviewQuickPickItems(codeReviews: CodeReviews): Promise { + const repos = this.repoManager.getRepos(); + const enrichedCodeReviews: { repo: string, id: string, review: CodeReviewData, fromCommitHash: string, toCommitHash: string }[] = []; + const fetchCommits: { repo: string, commitHash: string }[] = []; + + Object.keys(codeReviews).forEach((repo) => { + if (typeof repos[repo] === 'undefined') return; + Object.keys(codeReviews[repo]).forEach((id) => { + const commitHashes = id.split('-'); + commitHashes.forEach((commitHash) => fetchCommits.push({ repo: repo, commitHash: commitHash })); + enrichedCodeReviews.push({ + repo: repo, id: id, review: codeReviews[repo][id], + fromCommitHash: commitHashes[0], toCommitHash: commitHashes[commitHashes.length > 1 ? 1 : 0] + }); + }); + }); + + return Promise.all(fetchCommits.map((fetch) => this.dataSource.getCommitSubject(fetch.repo, fetch.commitHash))).then( + (subjects) => { + const commitSubjects: { [repo: string]: { [commitHash: string]: string } } = {}; + subjects.forEach((subject, i) => { + if (typeof commitSubjects[fetchCommits[i].repo] === 'undefined') { + commitSubjects[fetchCommits[i].repo] = {}; + } + commitSubjects[fetchCommits[i].repo][fetchCommits[i].commitHash] = subject !== null ? subject : ''; + }); + + return enrichedCodeReviews.sort((a, b) => b.review.lastActive - a.review.lastActive).map((codeReview) => { + const fromSubject = commitSubjects[codeReview.repo][codeReview.fromCommitHash]; + const toSubject = commitSubjects[codeReview.repo][codeReview.toCommitHash]; + const isComparison = codeReview.fromCommitHash !== codeReview.toCommitHash; + return { + codeReviewRepo: codeReview.repo, + codeReviewId: codeReview.id, + label: (repos[codeReview.repo].name || getRepoName(codeReview.repo)) + ': ' + abbrevCommit(codeReview.fromCommitHash) + (isComparison ? ' ↔ ' + abbrevCommit(codeReview.toCommitHash) : ''), + description: getRelativeTimeDiff(Math.round(codeReview.review.lastActive / 1000)), + detail: isComparison + ? abbrevText(fromSubject, 50) + ' ↔ ' + abbrevText(toSubject, 50) + : fromSubject + }; + }); + } + ); + } +} + +interface CodeReviewQuickPickItem extends vscode.QuickPickItem { + codeReviewRepo: string; + codeReviewId: string; +} diff --git a/src/gitGraphView.ts b/src/gitGraphView.ts index e7f0e6b3..ff7cf771 100644 --- a/src/gitGraphView.ts +++ b/src/gitGraphView.ts @@ -1,871 +1,871 @@ -import * as path from 'path'; -import * as vscode from 'vscode'; -import { AvatarManager } from './avatarManager'; -import { getConfig } from './config'; -import { DataSource, GitCommitDetailsData, GitConfigKey } from './dataSource'; -import { ExtensionState } from './extensionState'; -import { Logger } from './logger'; -import { RepoFileWatcher } from './repoFileWatcher'; -import { RepoManager } from './repoManager'; -import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types'; -import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from './utils'; -import { Disposable, toDisposable } from './utils/disposable'; - -/** - * Manages the Git Graph View. - */ -export class GitGraphView extends Disposable { - public static currentPanel: GitGraphView | undefined; - - private readonly panel: vscode.WebviewPanel; - private readonly extensionPath: string; - private readonly avatarManager: AvatarManager; - private readonly dataSource: DataSource; - private readonly extensionState: ExtensionState; - private readonly repoFileWatcher: RepoFileWatcher; - private readonly repoManager: RepoManager; - private readonly logger: Logger; - private isGraphViewLoaded: boolean = false; - public isPanelVisible: boolean = true; - private currentRepo: string | null = null; - private loadViewTo: LoadGitGraphViewTo = null; // Is used by the next call to getHtmlForWebview, and is then reset to null - - private loadRepoInfoRefreshId: number = 0; - private loadCommitsRefreshId: number = 0; - - /** - * If a Git Graph View already exists, show and update it. Otherwise, create a Git Graph View. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param avatarManger The Git Graph AvatarManager instance. - * @param repoManager The Git Graph RepoManager instance. - * @param logger The Git Graph Logger instance. - * @param loadViewTo What to load the view to. - */ - public static createOrShow(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo) { - const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; - - if (GitGraphView.currentPanel) { - // If Git Graph panel already exists - if (GitGraphView.currentPanel.isPanelVisible) { - // If the Git Graph panel is visible - if (loadViewTo !== null) { - GitGraphView.currentPanel.respondLoadRepos(repoManager.getRepos(), loadViewTo); - } - } else { - // If the Git Graph panel is not visible - GitGraphView.currentPanel.loadViewTo = loadViewTo; - } - GitGraphView.currentPanel.panel.reveal(column); - } else { - // If Git Graph panel doesn't already exist - GitGraphView.currentPanel = new GitGraphView(extensionPath, dataSource, extensionState, avatarManager, repoManager, logger, loadViewTo, column); - } - } - - /** - * Scroll the view to a commit (if it exists). - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - * @param openDetails Open details of the specified commit. - * @param persistently Persistently find the commit even if it is not exists. - */ - public static scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { - if (GitGraphView.currentPanel) { - GitGraphView.currentPanel.respondScrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); - } - } - - /** - * Creates a Git Graph View. - * @param extensionPath The absolute file path of the directory containing the extension. - * @param dataSource The Git Graph DataSource instance. - * @param extensionState The Git Graph ExtensionState instance. - * @param avatarManger The Git Graph AvatarManager instance. - * @param repoManager The Git Graph RepoManager instance. - * @param logger The Git Graph Logger instance. - * @param loadViewTo What to load the view to. - * @param column The column the view should be loaded in. - */ - private constructor(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo, column: vscode.ViewColumn | undefined) { - super(); - this.extensionPath = extensionPath; - this.avatarManager = avatarManager; - this.dataSource = dataSource; - this.extensionState = extensionState; - this.repoManager = repoManager; - this.logger = logger; - this.loadViewTo = loadViewTo; - - const config = getConfig(); - this.panel = vscode.window.createWebviewPanel('git-graph', 'Git Graph', column || vscode.ViewColumn.One, { - enableScripts: true, - localResourceRoots: [vscode.Uri.file(path.join(extensionPath, 'media'))], - retainContextWhenHidden: config.retainContextWhenHidden - }); - this.panel.iconPath = config.tabIconColourTheme === TabIconColourTheme.Colour - ? this.getResourcesUri('webview-icon.svg') - : { - light: this.getResourcesUri('webview-icon-light.svg'), - dark: this.getResourcesUri('webview-icon-dark.svg') - }; - - - this.registerDisposables( - // Dispose Git Graph View resources when disposed - toDisposable(() => { - GitGraphView.currentPanel = undefined; - this.repoFileWatcher.stop(); - }), - - // Dispose this Git Graph View when the Webview Panel is disposed - this.panel.onDidDispose(() => this.dispose()), - - // Register a callback that is called when the view is shown or hidden - this.panel.onDidChangeViewState(() => { - if (this.panel.visible !== this.isPanelVisible) { - if (this.panel.visible) { - this.update(); - } else { - this.currentRepo = null; - this.repoFileWatcher.stop(); - } - this.isPanelVisible = this.panel.visible; - } - }), - - // Subscribe to events triggered when a repository is added or deleted from Git Graph - repoManager.onDidChangeRepos((event) => { - if (!this.panel.visible) return; - const loadViewTo = event.loadRepo !== null ? { repo: event.loadRepo } : null; - if ((event.numRepos === 0 && this.isGraphViewLoaded) || (event.numRepos > 0 && !this.isGraphViewLoaded)) { - this.loadViewTo = loadViewTo; - this.update(); - } else { - this.respondLoadRepos(event.repos, loadViewTo); - } - }), - - // Subscribe to events triggered when an avatar is available - avatarManager.onAvatar((event) => { - this.sendMessage({ - command: 'fetchAvatar', - email: event.email, - image: event.image - }); - }), - - // Respond to messages sent from the Webview - this.panel.webview.onDidReceiveMessage((msg) => this.respondToMessage(msg)), - - // Dispose the Webview Panel when disposed - this.panel - ); - - // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View - this.repoFileWatcher = new RepoFileWatcher(logger, () => { - if (this.panel.visible) { - this.sendMessage({ command: 'refresh' }); - } - }); - - // Render the content of the Webview - this.update(); - - this.logger.log('Created Git Graph View' + (loadViewTo !== null ? ' (active repo: ' + loadViewTo.repo + ')' : '')); - } - - /** - * Respond to a message sent from the front-end. - * @param msg The message that was received. - */ - private async respondToMessage(msg: RequestMessage) { - this.repoFileWatcher.mute(); - let errorInfos: ErrorInfo[]; - - switch (msg.command) { - case 'addRemote': - this.sendMessage({ - command: 'addRemote', - error: await this.dataSource.addRemote(msg.repo, msg.name, msg.url, msg.pushUrl, msg.fetch) - }); - break; - case 'addTag': - errorInfos = [await this.dataSource.addTag(msg.repo, msg.tagName, msg.commitHash, msg.type, msg.message, msg.force)]; - if (errorInfos[0] === null && msg.pushToRemote !== null) { - errorInfos.push(...await this.dataSource.pushTag(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.pushSkipRemoteCheck)); - } - this.sendMessage({ - command: 'addTag', - repo: msg.repo, - tagName: msg.tagName, - pushToRemote: msg.pushToRemote, - commitHash: msg.commitHash, - errors: errorInfos - }); - break; - case 'applyStash': - this.sendMessage({ - command: 'applyStash', - error: await this.dataSource.applyStash(msg.repo, msg.selector, msg.reinstateIndex) - }); - break; - case 'branchFromStash': - this.sendMessage({ - command: 'branchFromStash', - error: await this.dataSource.branchFromStash(msg.repo, msg.selector, msg.branchName) - }); - break; - case 'checkoutBranch': - errorInfos = [await this.dataSource.checkoutBranch(msg.repo, msg.branchName, msg.remoteBranch)]; - if (errorInfos[0] === null && msg.pullAfterwards !== null) { - errorInfos.push(await this.dataSource.pullBranch(msg.repo, msg.pullAfterwards.branchName, msg.pullAfterwards.remote, msg.pullAfterwards.createNewCommit, msg.pullAfterwards.squash)); - } - this.sendMessage({ - command: 'checkoutBranch', - pullAfterwards: msg.pullAfterwards, - errors: errorInfos - }); - break; - case 'checkoutCommit': - this.sendMessage({ - command: 'checkoutCommit', - error: await this.dataSource.checkoutCommit(msg.repo, msg.commitHash) - }); - break; - case 'cherrypickCommit': - errorInfos = [await this.dataSource.cherrypickCommit(msg.repo, msg.commitHash, msg.parentIndex, msg.recordOrigin, msg.noCommit)]; - if (errorInfos[0] === null && msg.noCommit) { - errorInfos.push(await viewScm()); - } - this.sendMessage({ command: 'cherrypickCommit', errors: errorInfos }); - break; - case 'cleanUntrackedFiles': - this.sendMessage({ - command: 'cleanUntrackedFiles', - error: await this.dataSource.cleanUntrackedFiles(msg.repo, msg.directories) - }); - break; - case 'commitDetails': - let data = await Promise.all([ - msg.commitHash === UNCOMMITTED - ? this.dataSource.getUncommittedDetails(msg.repo) - : msg.stash === null - ? this.dataSource.getCommitDetails(msg.repo, msg.commitHash, msg.hasParents) - : this.dataSource.getStashDetails(msg.repo, msg.commitHash, msg.stash), - msg.avatarEmail !== null ? this.avatarManager.getAvatarImage(msg.avatarEmail) : Promise.resolve(null) - ]); - this.sendMessage({ - command: 'commitDetails', - ...data[0], - avatar: data[1], - codeReview: msg.commitHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.commitHash) : null, - refresh: msg.refresh - }); - break; - case 'compareCommits': - this.sendMessage({ - command: 'compareCommits', - commitHash: msg.commitHash, - compareWithHash: msg.compareWithHash, - ...await this.dataSource.getCommitComparison(msg.repo, msg.fromHash, msg.toHash), - codeReview: msg.toHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.fromHash + '-' + msg.toHash) : null, - refresh: msg.refresh - }); - break; - case 'copyFilePath': - this.sendMessage({ - command: 'copyFilePath', - error: await copyFilePathToClipboard(msg.repo, msg.filePath, msg.absolute) - }); - break; - case 'copyToClipboard': - this.sendMessage({ - command: 'copyToClipboard', - type: msg.type, - error: await copyToClipboard(msg.data) - }); - break; - case 'createArchive': - this.sendMessage({ - command: 'createArchive', - error: await archive(msg.repo, msg.ref, this.dataSource) - }); - break; - case 'createBranch': - this.sendMessage({ - command: 'createBranch', - errors: await this.dataSource.createBranch(msg.repo, msg.branchName, msg.commitHash, msg.checkout, msg.force) - }); - break; - case 'createPullRequest': - errorInfos = [msg.push ? await this.dataSource.pushBranch(msg.repo, msg.sourceBranch, msg.sourceRemote, true, GitPushBranchMode.Normal) : null]; - if (errorInfos[0] === null) { - errorInfos.push(await createPullRequest(msg.config, msg.sourceOwner, msg.sourceRepo, msg.sourceBranch)); - } - this.sendMessage({ - command: 'createPullRequest', - push: msg.push, - errors: errorInfos - }); - break; - case 'deleteBranch': - errorInfos = [await this.dataSource.deleteBranch(msg.repo, msg.branchName, msg.forceDelete)]; - if (errorInfos[0] === null) { - for (let i = 0; i < msg.deleteOnRemotes.length; i++) { - errorInfos.push(await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.deleteOnRemotes[i])); - } - } - this.sendMessage({ - command: 'deleteBranch', - repo: msg.repo, - branchName: msg.branchName, - deleteOnRemotes: msg.deleteOnRemotes, - errors: errorInfos - }); - break; - case 'deleteRemote': - this.sendMessage({ - command: 'deleteRemote', - error: await this.dataSource.deleteRemote(msg.repo, msg.name) - }); - break; - case 'deleteRemoteBranch': - this.sendMessage({ - command: 'deleteRemoteBranch', - error: await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.remote) - }); - break; - case 'deleteTag': - this.sendMessage({ - command: 'deleteTag', - error: await this.dataSource.deleteTag(msg.repo, msg.tagName, msg.deleteOnRemote) - }); - break; - case 'deleteUserDetails': - errorInfos = []; - if (msg.name) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, msg.location)); - } - if (msg.email) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, msg.location)); - } - this.sendMessage({ - command: 'deleteUserDetails', - errors: errorInfos - }); - break; - case 'dropCommit': - this.sendMessage({ - command: 'dropCommit', - error: await this.dataSource.dropCommit(msg.repo, msg.commitHash) - }); - break; - case 'dropStash': - this.sendMessage({ - command: 'dropStash', - error: await this.dataSource.dropStash(msg.repo, msg.selector) - }); - break; - case 'editRemote': - this.sendMessage({ - command: 'editRemote', - error: await this.dataSource.editRemote(msg.repo, msg.nameOld, msg.nameNew, msg.urlOld, msg.urlNew, msg.pushUrlOld, msg.pushUrlNew) - }); - break; - case 'editUserDetails': - errorInfos = [ - await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserName, msg.name, msg.location), - await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserEmail, msg.email, msg.location) - ]; - if (errorInfos[0] === null && errorInfos[1] === null) { - if (msg.deleteLocalName) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, GitConfigLocation.Local)); - } - if (msg.deleteLocalEmail) { - errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, GitConfigLocation.Local)); - } - } - this.sendMessage({ - command: 'editUserDetails', - errors: errorInfos - }); - break; - case 'endCodeReview': - this.extensionState.endCodeReview(msg.repo, msg.id); - break; - case 'exportRepoConfig': - this.sendMessage({ - command: 'exportRepoConfig', - error: await this.repoManager.exportRepoConfig(msg.repo) - }); - break; - case 'fetch': - this.sendMessage({ - command: 'fetch', - error: await this.dataSource.fetch(msg.repo, msg.name, msg.prune, msg.pruneTags) - }); - break; - case 'fetchAvatar': - this.avatarManager.fetchAvatarImage(msg.email, msg.repo, msg.remote, msg.commits); - break; - case 'fetchIntoLocalBranch': - this.sendMessage({ - command: 'fetchIntoLocalBranch', - error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch, msg.force) - }); - break; - case 'loadCommits': - this.loadCommitsRefreshId = msg.refreshId; - this.sendMessage({ - command: 'loadCommits', - refreshId: msg.refreshId, - onlyFollowFirstParent: msg.onlyFollowFirstParent, - ...await this.dataSource.getCommits(msg.repo, msg.branches, msg.authors, msg.maxCommits, msg.showTags, msg.showRemoteBranches, msg.includeCommitsMentionedByReflogs, msg.onlyFollowFirstParent, msg.commitOrdering, msg.remotes, msg.hideRemotes, msg.stashes, msg.simplifyByDecoration) - }); - break; - case 'loadConfig': - this.sendMessage({ - command: 'loadConfig', - repo: msg.repo, - ...await this.dataSource.getConfig(msg.repo, msg.remotes) - }); - break; - case 'loadRepoInfo': - this.loadRepoInfoRefreshId = msg.refreshId; - let repoInfo = await this.dataSource.getRepoInfo(msg.repo, msg.showRemoteBranches, msg.showStashes, msg.hideRemotes), isRepo = true; - if (repoInfo.error) { - // If an error occurred, check to make sure the repo still exists - isRepo = (await this.dataSource.repoRoot(msg.repo)) !== null; - if (!isRepo) repoInfo.error = null; // If the error is caused by the repo no longer existing, clear the error message - } - this.sendMessage({ - command: 'loadRepoInfo', - refreshId: msg.refreshId, - ...repoInfo, - isRepo: isRepo - }); - if (msg.repo !== this.currentRepo) { - this.currentRepo = msg.repo; - this.extensionState.setLastActiveRepo(msg.repo); - this.repoFileWatcher.start(msg.repo); - } - break; - case 'loadRepos': - if (!msg.check || !await this.repoManager.checkReposExist()) { - // If not required to check repos, or no changes were found when checking, respond with repos - this.respondLoadRepos(this.repoManager.getRepos(), null); - } - break; - case 'merge': - this.sendMessage({ - command: 'merge', - actionOn: msg.actionOn, - error: await this.dataSource.merge(msg.repo, msg.obj, msg.actionOn, msg.createNewCommit, msg.allowUnrelatedHistories, msg.squash, msg.noCommit) - }); - break; - case 'openExtensionSettings': - this.sendMessage({ - command: 'openExtensionSettings', - error: await openExtensionSettings() - }); - break; - case 'openExternalDirDiff': - this.sendMessage({ - command: 'openExternalDirDiff', - error: await this.dataSource.openExternalDirDiff(msg.repo, msg.fromHash, msg.toHash, msg.isGui) - }); - break; - case 'openExternalUrl': - this.sendMessage({ - command: 'openExternalUrl', - error: await openExternalUrl(msg.url) - }); - break; - case 'openFile': - this.sendMessage({ - command: 'openFile', - error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource) - }); - break; - case 'openTerminal': - this.sendMessage({ - command: 'openTerminal', - error: await this.dataSource.openGitTerminal(msg.repo, null, msg.name) - }); - break; - case 'popStash': - this.sendMessage({ - command: 'popStash', - error: await this.dataSource.popStash(msg.repo, msg.selector, msg.reinstateIndex) - }); - break; - case 'pruneRemote': - this.sendMessage({ - command: 'pruneRemote', - error: await this.dataSource.pruneRemote(msg.repo, msg.name) - }); - break; - case 'pullBranch': - this.sendMessage({ - command: 'pullBranch', - error: await this.dataSource.pullBranch(msg.repo, msg.branchName, msg.remote, msg.createNewCommit, msg.squash) - }); - break; - case 'pushBranch': - this.sendMessage({ - command: 'pushBranch', - willUpdateBranchConfig: msg.willUpdateBranchConfig, - errors: await this.dataSource.pushBranchToMultipleRemotes(msg.repo, msg.branchName, msg.remotes, msg.setUpstream, msg.mode) - }); - break; - case 'pushStash': - this.sendMessage({ - command: 'pushStash', - error: await this.dataSource.pushStash(msg.repo, msg.message, msg.includeUntracked) - }); - break; - case 'pushTag': - this.sendMessage({ - command: 'pushTag', - repo: msg.repo, - tagName: msg.tagName, - remotes: msg.remotes, - commitHash: msg.commitHash, - errors: await this.dataSource.pushTag(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.skipRemoteCheck) - }); - break; - case 'rebase': - this.sendMessage({ - command: 'rebase', - actionOn: msg.actionOn, - interactive: msg.interactive, - error: await this.dataSource.rebase(msg.repo, msg.obj, msg.actionOn, msg.ignoreDate, msg.interactive) - }); - break; - case 'renameBranch': - this.sendMessage({ - command: 'renameBranch', - error: await this.dataSource.renameBranch(msg.repo, msg.oldName, msg.newName) - }); - break; - case 'rescanForRepos': - if (!(await this.repoManager.searchWorkspaceForRepos())) { - showErrorMessage('No Git repositories were found in the current workspace.'); - } - break; - case 'resetFileToRevision': - this.sendMessage({ - command: 'resetFileToRevision', - error: await this.dataSource.resetFileToRevision(msg.repo, msg.commitHash, msg.filePath) - }); - break; - case 'resetToCommit': - this.sendMessage({ - command: 'resetToCommit', - error: await this.dataSource.resetToCommit(msg.repo, msg.commit, msg.resetMode) - }); - break; - case 'revertCommit': - this.sendMessage({ - command: 'revertCommit', - error: await this.dataSource.revertCommit(msg.repo, msg.commitHash, msg.parentIndex) - }); - break; - case 'setGlobalViewState': - this.sendMessage({ - command: 'setGlobalViewState', - error: await this.extensionState.setGlobalViewState(msg.state) - }); - break; - case 'setRepoState': - this.repoManager.setRepoState(msg.repo, msg.state); - break; - case 'setWorkspaceViewState': - this.sendMessage({ - command: 'setWorkspaceViewState', - error: await this.extensionState.setWorkspaceViewState(msg.state) - }); - break; - case 'showErrorMessage': - showErrorMessage(msg.message); - break; - case 'startCodeReview': - this.sendMessage({ - command: 'startCodeReview', - commitHash: msg.commitHash, - compareWithHash: msg.compareWithHash, - ...await this.extensionState.startCodeReview(msg.repo, msg.id, msg.files, msg.lastViewedFile) - }); - break; - case 'tagDetails': - this.sendMessage({ - command: 'tagDetails', - tagName: msg.tagName, - commitHash: msg.commitHash, - ...await this.dataSource.getTagDetails(msg.repo, msg.tagName) - }); - break; - case 'updateCodeReview': - this.sendMessage({ - command: 'updateCodeReview', - error: await this.extensionState.updateCodeReview(msg.repo, msg.id, msg.remainingFiles, msg.lastViewedFile) - }); - break; - case 'viewDiff': - this.sendMessage({ - command: 'viewDiff', - error: await viewDiff(msg.repo, msg.fromHash, msg.toHash, msg.oldFilePath, msg.newFilePath, msg.type) - }); - break; - case 'viewDiffWithWorkingFile': - this.sendMessage({ - command: 'viewDiffWithWorkingFile', - error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource) - }); - break; - case 'viewFileAtRevision': - this.sendMessage({ - command: 'viewFileAtRevision', - error: await viewFileAtRevision(msg.repo, msg.hash, msg.filePath) - }); - break; - case 'viewScm': - this.sendMessage({ - command: 'viewScm', - error: await viewScm() - }); - break; - } - - this.repoFileWatcher.unmute(); - } - - /** - * Send a message to the front-end. - * @param msg The message to be sent. - */ - private sendMessage(msg: ResponseMessage) { - if (this.isDisposed()) { - this.logger.log('The Git Graph View has already been disposed, ignored sending "' + msg.command + '" message.'); - } else { - this.panel.webview.postMessage(msg).then( - () => { }, - () => { - if (this.isDisposed()) { - this.logger.log('The Git Graph View was disposed while sending "' + msg.command + '" message.'); - } else { - this.logger.logError('Unable to send "' + msg.command + '" message to the Git Graph View.'); - } - } - ); - } - } - - /** - * Update the HTML document loaded in the Webview. - */ - private update() { - this.panel.webview.html = this.getHtmlForWebview(); - } - - /** - * Get the HTML document to be loaded in the Webview. - * @returns The HTML. - */ - private getHtmlForWebview() { - const config = getConfig(), nonce = getNonce(); - const initialState: GitGraphViewInitialState = { - config: { - commitDetailsView: config.commitDetailsView, - commitOrdering: config.commitOrder, - contextMenuActionsVisibility: config.contextMenuActionsVisibility, - customBranchGlobPatterns: config.customBranchGlobPatterns, - customEmojiShortcodeMappings: config.customEmojiShortcodeMappings, - customPullRequestProviders: config.customPullRequestProviders, - dateFormat: config.dateFormat, - defaultColumnVisibility: config.defaultColumnVisibility, - stickyHeader: config.stickyHeader, - dialogDefaults: config.dialogDefaults, - enhancedAccessibility: config.enhancedAccessibility, - fetchAndPrune: config.fetchAndPrune, - fetchAndPruneTags: config.fetchAndPruneTags, - fetchAvatars: config.fetchAvatars && this.extensionState.isAvatarStorageAvailable(), - graph: config.graph, - includeCommitsMentionedByReflogs: config.includeCommitsMentionedByReflogs, - initialLoadCommits: config.initialLoadCommits, - keybindings: config.keybindings, - loadMoreCommits: config.loadMoreCommits, - loadMoreCommitsAutomatically: config.loadMoreCommitsAutomatically, - markdown: config.markdown, - mute: config.muteCommits, - onlyFollowFirstParent: config.onlyFollowFirstParent, - onRepoLoad: config.onRepoLoad, - referenceLabels: config.referenceLabels, - repoDropdownOrder: config.repoDropdownOrder, - showRemoteBranches: config.showRemoteBranches, - simplifyByDecoration: config.simplifyByDecoration, - showStashes: config.showStashes, - showTags: config.showTags, - toolbarButtonVisibility: config.toolbarButtonVisibility - }, - lastActiveRepo: this.extensionState.getLastActiveRepo(), - loadViewTo: this.loadViewTo, - repos: this.repoManager.getRepos(), - loadRepoInfoRefreshId: this.loadRepoInfoRefreshId, - loadCommitsRefreshId: this.loadCommitsRefreshId - }; - const globalState = this.extensionState.getGlobalViewState(); - const workspaceState = this.extensionState.getWorkspaceViewState(); - - let body, numRepos = Object.keys(initialState.repos).length, colorVars = '', colorParams = ''; - for (let i = 0; i < initialState.config.graph.colours.length; i++) { - colorVars += '--git-graph-color' + i + ':' + initialState.config.graph.colours[i] + '; '; - colorParams += '[data-color="' + i + '"]{--git-graph-color:var(--git-graph-color' + i + ');} '; - } - - if (this.dataSource.isGitExecutableUnknown()) { - body = ` -

    Unable to load Git Graph

    -

    ${UNABLE_TO_FIND_GIT_MSG}

    - `; - } else if (numRepos > 0) { - const stickyClassAttr = initialState.config.stickyHeader ? ' class="sticky"' : ''; - let hideRemotes = '', hideSimplify = ''; - if (!config.toolbarButtonVisibility.remotes) { hideRemotes = 'style="display: none"'; } - if (!config.toolbarButtonVisibility.simplify) { hideSimplify = 'style="display: none"'; } - body = ` -
    -
    - Repo: - Branches: - Authors: - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - `; - } else { - body = ` -

    Unable to load Git Graph

    -

    No Git repositories were found in the current workspace when it was last scanned by Git Graph.

    -

    If your repositories are in subfolders of the open workspace folder(s), make sure you have set the Git Graph Setting "git-graph.maxDepthOfRepoSearch" appropriately (read the documentation for more information).

    -

    Re-scan the current workspace for repositories

    - - `; - } - this.isGraphViewLoaded = numRepos > 0; - this.loadViewTo = null; - - return ` - - - - - - - Git Graph - - - ${body} - `; - } - - - /* URI Manipulation Methods */ - - /** - * Get a WebviewUri for a media file included in the extension. - * @param file The file name in the `media` directory. - * @returns The WebviewUri. - */ - private getMediaUri(file: string) { - return this.panel.webview.asWebviewUri(this.getUri('media', file)); - } - - /** - * Get a File Uri for a resource file included in the extension. - * @param file The file name in the `resource` directory. - * @returns The Uri. - */ - private getResourcesUri(file: string) { - return this.getUri('resources', file); - } - - /** - * Get a File Uri for a file included in the extension. - * @param pathComps The path components relative to the root directory of the extension. - * @returns The File Uri. - */ - private getUri(...pathComps: string[]) { - return vscode.Uri.file(path.join(this.extensionPath, ...pathComps)); - } - - - /* Response Construction Methods */ - - /** - * Send the known repositories to the front-end. - * @param repos The set of known repositories. - * @param loadViewTo What to load the view to. - */ - private respondLoadRepos(repos: GitRepoSet, loadViewTo: LoadGitGraphViewTo) { - this.sendMessage({ - command: 'loadRepos', - repos: repos, - lastActiveRepo: this.extensionState.getLastActiveRepo(), - loadViewTo: loadViewTo - }); - } - - /** - * Call the command to scroll to the specified commit to the front-end. - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - * @param openDetails Open details of the specified commit. - * @param persistently Persistently find the commit even if it is not exists. - */ - public respondScrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { - this.sendMessage({ - command: 'scrollToCommit', - hash: hash, - alwaysCenterCommit: alwaysCenterCommit, - flash: flash, - openDetails: openDetails, - persistently: persistently - }); - } -} - -/** - * Standardise the CSP Source provided by Visual Studio Code for use with the Webview. It is idempotent unless called with http/https URI's, in which case it keeps only the authority portion of the http/https URI. This is necessary to be compatible with some web browser environments. - * @param cspSource The value provide by Visual Studio Code. - * @returns The standardised CSP Source. - */ -export function standardiseCspSource(cspSource: string) { - if (cspSource.startsWith('http://') || cspSource.startsWith('https://')) { - const pathIndex = cspSource.indexOf('/', 8), queryIndex = cspSource.indexOf('?', 8), fragmentIndex = cspSource.indexOf('#', 8); - let endOfAuthorityIndex = pathIndex; - if (queryIndex > -1 && (queryIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = queryIndex; - if (fragmentIndex > -1 && (fragmentIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = fragmentIndex; - return endOfAuthorityIndex > -1 ? cspSource.substring(0, endOfAuthorityIndex) : cspSource; - } else { - return cspSource; - } -} +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AvatarManager } from './avatarManager'; +import { getConfig } from './config'; +import { DataSource, GitCommitDetailsData, GitConfigKey } from './dataSource'; +import { ExtensionState } from './extensionState'; +import { Logger } from './logger'; +import { RepoFileWatcher } from './repoFileWatcher'; +import { RepoManager } from './repoManager'; +import { ErrorInfo, GitConfigLocation, GitGraphViewInitialState, GitPushBranchMode, GitRepoSet, LoadGitGraphViewTo, RequestMessage, ResponseMessage, TabIconColourTheme } from './types'; +import { UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, archive, copyFilePathToClipboard, copyToClipboard, createPullRequest, getNonce, openExtensionSettings, openExternalUrl, openFile, showErrorMessage, viewDiff, viewDiffWithWorkingFile, viewFileAtRevision, viewScm } from './utils'; +import { Disposable, toDisposable } from './utils/disposable'; + +/** + * Manages the Git Graph View. + */ +export class GitGraphView extends Disposable { + public static currentPanel: GitGraphView | undefined; + + private readonly panel: vscode.WebviewPanel; + private readonly extensionPath: string; + private readonly avatarManager: AvatarManager; + private readonly dataSource: DataSource; + private readonly extensionState: ExtensionState; + private readonly repoFileWatcher: RepoFileWatcher; + private readonly repoManager: RepoManager; + private readonly logger: Logger; + private isGraphViewLoaded: boolean = false; + public isPanelVisible: boolean = true; + private currentRepo: string | null = null; + private loadViewTo: LoadGitGraphViewTo = null; // Is used by the next call to getHtmlForWebview, and is then reset to null + + private loadRepoInfoRefreshId: number = 0; + private loadCommitsRefreshId: number = 0; + + /** + * If a Git Graph View already exists, show and update it. Otherwise, create a Git Graph View. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param avatarManger The Git Graph AvatarManager instance. + * @param repoManager The Git Graph RepoManager instance. + * @param logger The Git Graph Logger instance. + * @param loadViewTo What to load the view to. + */ + public static createOrShow(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo) { + const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; + + if (GitGraphView.currentPanel) { + // If Git Graph panel already exists + if (GitGraphView.currentPanel.isPanelVisible) { + // If the Git Graph panel is visible + if (loadViewTo !== null) { + GitGraphView.currentPanel.respondLoadRepos(repoManager.getRepos(), loadViewTo); + } + } else { + // If the Git Graph panel is not visible + GitGraphView.currentPanel.loadViewTo = loadViewTo; + } + GitGraphView.currentPanel.panel.reveal(column); + } else { + // If Git Graph panel doesn't already exist + GitGraphView.currentPanel = new GitGraphView(extensionPath, dataSource, extensionState, avatarManager, repoManager, logger, loadViewTo, column); + } + } + + /** + * Scroll the view to a commit (if it exists). + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public static scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + if (GitGraphView.currentPanel) { + GitGraphView.currentPanel.respondScrollToCommit(hash, alwaysCenterCommit, flash, openDetails, persistently); + } + } + + /** + * Creates a Git Graph View. + * @param extensionPath The absolute file path of the directory containing the extension. + * @param dataSource The Git Graph DataSource instance. + * @param extensionState The Git Graph ExtensionState instance. + * @param avatarManger The Git Graph AvatarManager instance. + * @param repoManager The Git Graph RepoManager instance. + * @param logger The Git Graph Logger instance. + * @param loadViewTo What to load the view to. + * @param column The column the view should be loaded in. + */ + private constructor(extensionPath: string, dataSource: DataSource, extensionState: ExtensionState, avatarManager: AvatarManager, repoManager: RepoManager, logger: Logger, loadViewTo: LoadGitGraphViewTo, column: vscode.ViewColumn | undefined) { + super(); + this.extensionPath = extensionPath; + this.avatarManager = avatarManager; + this.dataSource = dataSource; + this.extensionState = extensionState; + this.repoManager = repoManager; + this.logger = logger; + this.loadViewTo = loadViewTo; + + const config = getConfig(); + this.panel = vscode.window.createWebviewPanel('git-graph', 'Git Graph', column || vscode.ViewColumn.One, { + enableScripts: true, + localResourceRoots: [vscode.Uri.file(path.join(extensionPath, 'media'))], + retainContextWhenHidden: config.retainContextWhenHidden + }); + this.panel.iconPath = config.tabIconColourTheme === TabIconColourTheme.Colour + ? this.getResourcesUri('webview-icon.svg') + : { + light: this.getResourcesUri('webview-icon-light.svg'), + dark: this.getResourcesUri('webview-icon-dark.svg') + }; + + + this.registerDisposables( + // Dispose Git Graph View resources when disposed + toDisposable(() => { + GitGraphView.currentPanel = undefined; + this.repoFileWatcher.stop(); + }), + + // Dispose this Git Graph View when the Webview Panel is disposed + this.panel.onDidDispose(() => this.dispose()), + + // Register a callback that is called when the view is shown or hidden + this.panel.onDidChangeViewState(() => { + if (this.panel.visible !== this.isPanelVisible) { + if (this.panel.visible) { + this.update(); + } else { + this.currentRepo = null; + this.repoFileWatcher.stop(); + } + this.isPanelVisible = this.panel.visible; + } + }), + + // Subscribe to events triggered when a repository is added or deleted from Git Graph + repoManager.onDidChangeRepos((event) => { + if (!this.panel.visible) return; + const loadViewTo = event.loadRepo !== null ? { repo: event.loadRepo } : null; + if ((event.numRepos === 0 && this.isGraphViewLoaded) || (event.numRepos > 0 && !this.isGraphViewLoaded)) { + this.loadViewTo = loadViewTo; + this.update(); + } else { + this.respondLoadRepos(event.repos, loadViewTo); + } + }), + + // Subscribe to events triggered when an avatar is available + avatarManager.onAvatar((event) => { + this.sendMessage({ + command: 'fetchAvatar', + email: event.email, + image: event.image + }); + }), + + // Respond to messages sent from the Webview + this.panel.webview.onDidReceiveMessage((msg) => this.respondToMessage(msg)), + + // Dispose the Webview Panel when disposed + this.panel + ); + + // Instantiate a RepoFileWatcher that watches for file changes in the repository currently open in the Git Graph View + this.repoFileWatcher = new RepoFileWatcher(logger, () => { + if (this.panel.visible) { + this.sendMessage({ command: 'refresh' }); + } + }); + + // Render the content of the Webview + this.update(); + + this.logger.log('Created Git Graph View' + (loadViewTo !== null ? ' (active repo: ' + loadViewTo.repo + ')' : '')); + } + + /** + * Respond to a message sent from the front-end. + * @param msg The message that was received. + */ + private async respondToMessage(msg: RequestMessage) { + this.repoFileWatcher.mute(); + let errorInfos: ErrorInfo[]; + + switch (msg.command) { + case 'addRemote': + this.sendMessage({ + command: 'addRemote', + error: await this.dataSource.addRemote(msg.repo, msg.name, msg.url, msg.pushUrl, msg.fetch) + }); + break; + case 'addTag': + errorInfos = [await this.dataSource.addTag(msg.repo, msg.tagName, msg.commitHash, msg.type, msg.message, msg.force)]; + if (errorInfos[0] === null && msg.pushToRemote !== null) { + errorInfos.push(...await this.dataSource.pushTag(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.pushSkipRemoteCheck)); + } + this.sendMessage({ + command: 'addTag', + repo: msg.repo, + tagName: msg.tagName, + pushToRemote: msg.pushToRemote, + commitHash: msg.commitHash, + errors: errorInfos + }); + break; + case 'applyStash': + this.sendMessage({ + command: 'applyStash', + error: await this.dataSource.applyStash(msg.repo, msg.selector, msg.reinstateIndex) + }); + break; + case 'branchFromStash': + this.sendMessage({ + command: 'branchFromStash', + error: await this.dataSource.branchFromStash(msg.repo, msg.selector, msg.branchName) + }); + break; + case 'checkoutBranch': + errorInfos = [await this.dataSource.checkoutBranch(msg.repo, msg.branchName, msg.remoteBranch)]; + if (errorInfos[0] === null && msg.pullAfterwards !== null) { + errorInfos.push(await this.dataSource.pullBranch(msg.repo, msg.pullAfterwards.branchName, msg.pullAfterwards.remote, msg.pullAfterwards.createNewCommit, msg.pullAfterwards.squash)); + } + this.sendMessage({ + command: 'checkoutBranch', + pullAfterwards: msg.pullAfterwards, + errors: errorInfos + }); + break; + case 'checkoutCommit': + this.sendMessage({ + command: 'checkoutCommit', + error: await this.dataSource.checkoutCommit(msg.repo, msg.commitHash) + }); + break; + case 'cherrypickCommit': + errorInfos = [await this.dataSource.cherrypickCommit(msg.repo, msg.commitHash, msg.parentIndex, msg.recordOrigin, msg.noCommit)]; + if (errorInfos[0] === null && msg.noCommit) { + errorInfos.push(await viewScm()); + } + this.sendMessage({ command: 'cherrypickCommit', errors: errorInfos }); + break; + case 'cleanUntrackedFiles': + this.sendMessage({ + command: 'cleanUntrackedFiles', + error: await this.dataSource.cleanUntrackedFiles(msg.repo, msg.directories) + }); + break; + case 'commitDetails': + let data = await Promise.all([ + msg.commitHash === UNCOMMITTED + ? this.dataSource.getUncommittedDetails(msg.repo) + : msg.stash === null + ? this.dataSource.getCommitDetails(msg.repo, msg.commitHash, msg.hasParents) + : this.dataSource.getStashDetails(msg.repo, msg.commitHash, msg.stash), + msg.avatarEmail !== null ? this.avatarManager.getAvatarImage(msg.avatarEmail) : Promise.resolve(null) + ]); + this.sendMessage({ + command: 'commitDetails', + ...data[0], + avatar: data[1], + codeReview: msg.commitHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.commitHash) : null, + refresh: msg.refresh + }); + break; + case 'compareCommits': + this.sendMessage({ + command: 'compareCommits', + commitHash: msg.commitHash, + compareWithHash: msg.compareWithHash, + ...await this.dataSource.getCommitComparison(msg.repo, msg.fromHash, msg.toHash), + codeReview: msg.toHash !== UNCOMMITTED ? this.extensionState.getCodeReview(msg.repo, msg.fromHash + '-' + msg.toHash) : null, + refresh: msg.refresh + }); + break; + case 'copyFilePath': + this.sendMessage({ + command: 'copyFilePath', + error: await copyFilePathToClipboard(msg.repo, msg.filePath, msg.absolute) + }); + break; + case 'copyToClipboard': + this.sendMessage({ + command: 'copyToClipboard', + type: msg.type, + error: await copyToClipboard(msg.data) + }); + break; + case 'createArchive': + this.sendMessage({ + command: 'createArchive', + error: await archive(msg.repo, msg.ref, this.dataSource) + }); + break; + case 'createBranch': + this.sendMessage({ + command: 'createBranch', + errors: await this.dataSource.createBranch(msg.repo, msg.branchName, msg.commitHash, msg.checkout, msg.force) + }); + break; + case 'createPullRequest': + errorInfos = [msg.push ? await this.dataSource.pushBranch(msg.repo, msg.sourceBranch, msg.sourceRemote, true, GitPushBranchMode.Normal) : null]; + if (errorInfos[0] === null) { + errorInfos.push(await createPullRequest(msg.config, msg.sourceOwner, msg.sourceRepo, msg.sourceBranch)); + } + this.sendMessage({ + command: 'createPullRequest', + push: msg.push, + errors: errorInfos + }); + break; + case 'deleteBranch': + errorInfos = [await this.dataSource.deleteBranch(msg.repo, msg.branchName, msg.forceDelete)]; + if (errorInfos[0] === null) { + for (let i = 0; i < msg.deleteOnRemotes.length; i++) { + errorInfos.push(await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.deleteOnRemotes[i])); + } + } + this.sendMessage({ + command: 'deleteBranch', + repo: msg.repo, + branchName: msg.branchName, + deleteOnRemotes: msg.deleteOnRemotes, + errors: errorInfos + }); + break; + case 'deleteRemote': + this.sendMessage({ + command: 'deleteRemote', + error: await this.dataSource.deleteRemote(msg.repo, msg.name) + }); + break; + case 'deleteRemoteBranch': + this.sendMessage({ + command: 'deleteRemoteBranch', + error: await this.dataSource.deleteRemoteBranch(msg.repo, msg.branchName, msg.remote) + }); + break; + case 'deleteTag': + this.sendMessage({ + command: 'deleteTag', + error: await this.dataSource.deleteTag(msg.repo, msg.tagName, msg.deleteOnRemote) + }); + break; + case 'deleteUserDetails': + errorInfos = []; + if (msg.name) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, msg.location)); + } + if (msg.email) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, msg.location)); + } + this.sendMessage({ + command: 'deleteUserDetails', + errors: errorInfos + }); + break; + case 'dropCommit': + this.sendMessage({ + command: 'dropCommit', + error: await this.dataSource.dropCommit(msg.repo, msg.commitHash) + }); + break; + case 'dropStash': + this.sendMessage({ + command: 'dropStash', + error: await this.dataSource.dropStash(msg.repo, msg.selector) + }); + break; + case 'editRemote': + this.sendMessage({ + command: 'editRemote', + error: await this.dataSource.editRemote(msg.repo, msg.nameOld, msg.nameNew, msg.urlOld, msg.urlNew, msg.pushUrlOld, msg.pushUrlNew) + }); + break; + case 'editUserDetails': + errorInfos = [ + await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserName, msg.name, msg.location), + await this.dataSource.setConfigValue(msg.repo, GitConfigKey.UserEmail, msg.email, msg.location) + ]; + if (errorInfos[0] === null && errorInfos[1] === null) { + if (msg.deleteLocalName) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserName, GitConfigLocation.Local)); + } + if (msg.deleteLocalEmail) { + errorInfos.push(await this.dataSource.unsetConfigValue(msg.repo, GitConfigKey.UserEmail, GitConfigLocation.Local)); + } + } + this.sendMessage({ + command: 'editUserDetails', + errors: errorInfos + }); + break; + case 'endCodeReview': + this.extensionState.endCodeReview(msg.repo, msg.id); + break; + case 'exportRepoConfig': + this.sendMessage({ + command: 'exportRepoConfig', + error: await this.repoManager.exportRepoConfig(msg.repo) + }); + break; + case 'fetch': + this.sendMessage({ + command: 'fetch', + error: await this.dataSource.fetch(msg.repo, msg.name, msg.prune, msg.pruneTags) + }); + break; + case 'fetchAvatar': + this.avatarManager.fetchAvatarImage(msg.email, msg.repo, msg.remote, msg.commits); + break; + case 'fetchIntoLocalBranch': + this.sendMessage({ + command: 'fetchIntoLocalBranch', + error: await this.dataSource.fetchIntoLocalBranch(msg.repo, msg.remote, msg.remoteBranch, msg.localBranch, msg.force) + }); + break; + case 'loadCommits': + this.loadCommitsRefreshId = msg.refreshId; + this.sendMessage({ + command: 'loadCommits', + refreshId: msg.refreshId, + onlyFollowFirstParent: msg.onlyFollowFirstParent, + ...await this.dataSource.getCommits(msg.repo, msg.branches, msg.authors, msg.maxCommits, msg.showTags, msg.showRemoteBranches, msg.includeCommitsMentionedByReflogs, msg.onlyFollowFirstParent, msg.commitOrdering, msg.remotes, msg.hideRemotes, msg.stashes, msg.simplifyByDecoration) + }); + break; + case 'loadConfig': + this.sendMessage({ + command: 'loadConfig', + repo: msg.repo, + ...await this.dataSource.getConfig(msg.repo, msg.remotes) + }); + break; + case 'loadRepoInfo': + this.loadRepoInfoRefreshId = msg.refreshId; + let repoInfo = await this.dataSource.getRepoInfo(msg.repo, msg.showRemoteBranches, msg.showStashes, msg.hideRemotes), isRepo = true; + if (repoInfo.error) { + // If an error occurred, check to make sure the repo still exists + isRepo = (await this.dataSource.repoRoot(msg.repo)) !== null; + if (!isRepo) repoInfo.error = null; // If the error is caused by the repo no longer existing, clear the error message + } + this.sendMessage({ + command: 'loadRepoInfo', + refreshId: msg.refreshId, + ...repoInfo, + isRepo: isRepo + }); + if (msg.repo !== this.currentRepo) { + this.currentRepo = msg.repo; + this.extensionState.setLastActiveRepo(msg.repo); + this.repoFileWatcher.start(msg.repo); + } + break; + case 'loadRepos': + if (!msg.check || !await this.repoManager.checkReposExist()) { + // If not required to check repos, or no changes were found when checking, respond with repos + this.respondLoadRepos(this.repoManager.getRepos(), null); + } + break; + case 'merge': + this.sendMessage({ + command: 'merge', + actionOn: msg.actionOn, + error: await this.dataSource.merge(msg.repo, msg.obj, msg.actionOn, msg.createNewCommit, msg.allowUnrelatedHistories, msg.squash, msg.noCommit) + }); + break; + case 'openExtensionSettings': + this.sendMessage({ + command: 'openExtensionSettings', + error: await openExtensionSettings() + }); + break; + case 'openExternalDirDiff': + this.sendMessage({ + command: 'openExternalDirDiff', + error: await this.dataSource.openExternalDirDiff(msg.repo, msg.fromHash, msg.toHash, msg.isGui) + }); + break; + case 'openExternalUrl': + this.sendMessage({ + command: 'openExternalUrl', + error: await openExternalUrl(msg.url) + }); + break; + case 'openFile': + this.sendMessage({ + command: 'openFile', + error: await openFile(msg.repo, msg.filePath, msg.hash, this.dataSource) + }); + break; + case 'openTerminal': + this.sendMessage({ + command: 'openTerminal', + error: await this.dataSource.openGitTerminal(msg.repo, null, msg.name) + }); + break; + case 'popStash': + this.sendMessage({ + command: 'popStash', + error: await this.dataSource.popStash(msg.repo, msg.selector, msg.reinstateIndex) + }); + break; + case 'pruneRemote': + this.sendMessage({ + command: 'pruneRemote', + error: await this.dataSource.pruneRemote(msg.repo, msg.name) + }); + break; + case 'pullBranch': + this.sendMessage({ + command: 'pullBranch', + error: await this.dataSource.pullBranch(msg.repo, msg.branchName, msg.remote, msg.createNewCommit, msg.squash) + }); + break; + case 'pushBranch': + this.sendMessage({ + command: 'pushBranch', + willUpdateBranchConfig: msg.willUpdateBranchConfig, + errors: await this.dataSource.pushBranchToMultipleRemotes(msg.repo, msg.branchName, msg.remotes, msg.setUpstream, msg.mode) + }); + break; + case 'pushStash': + this.sendMessage({ + command: 'pushStash', + error: await this.dataSource.pushStash(msg.repo, msg.message, msg.includeUntracked) + }); + break; + case 'pushTag': + this.sendMessage({ + command: 'pushTag', + repo: msg.repo, + tagName: msg.tagName, + remotes: msg.remotes, + commitHash: msg.commitHash, + errors: await this.dataSource.pushTag(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.skipRemoteCheck) + }); + break; + case 'rebase': + this.sendMessage({ + command: 'rebase', + actionOn: msg.actionOn, + interactive: msg.interactive, + error: await this.dataSource.rebase(msg.repo, msg.obj, msg.actionOn, msg.ignoreDate, msg.interactive) + }); + break; + case 'renameBranch': + this.sendMessage({ + command: 'renameBranch', + error: await this.dataSource.renameBranch(msg.repo, msg.oldName, msg.newName) + }); + break; + case 'rescanForRepos': + if (!(await this.repoManager.searchWorkspaceForRepos())) { + showErrorMessage('No Git repositories were found in the current workspace.'); + } + break; + case 'resetFileToRevision': + this.sendMessage({ + command: 'resetFileToRevision', + error: await this.dataSource.resetFileToRevision(msg.repo, msg.commitHash, msg.filePath) + }); + break; + case 'resetToCommit': + this.sendMessage({ + command: 'resetToCommit', + error: await this.dataSource.resetToCommit(msg.repo, msg.commit, msg.resetMode) + }); + break; + case 'revertCommit': + this.sendMessage({ + command: 'revertCommit', + error: await this.dataSource.revertCommit(msg.repo, msg.commitHash, msg.parentIndex) + }); + break; + case 'setGlobalViewState': + this.sendMessage({ + command: 'setGlobalViewState', + error: await this.extensionState.setGlobalViewState(msg.state) + }); + break; + case 'setRepoState': + this.repoManager.setRepoState(msg.repo, msg.state); + break; + case 'setWorkspaceViewState': + this.sendMessage({ + command: 'setWorkspaceViewState', + error: await this.extensionState.setWorkspaceViewState(msg.state) + }); + break; + case 'showErrorMessage': + showErrorMessage(msg.message); + break; + case 'startCodeReview': + this.sendMessage({ + command: 'startCodeReview', + commitHash: msg.commitHash, + compareWithHash: msg.compareWithHash, + ...await this.extensionState.startCodeReview(msg.repo, msg.id, msg.files, msg.lastViewedFile) + }); + break; + case 'tagDetails': + this.sendMessage({ + command: 'tagDetails', + tagName: msg.tagName, + commitHash: msg.commitHash, + ...await this.dataSource.getTagDetails(msg.repo, msg.tagName) + }); + break; + case 'updateCodeReview': + this.sendMessage({ + command: 'updateCodeReview', + error: await this.extensionState.updateCodeReview(msg.repo, msg.id, msg.remainingFiles, msg.lastViewedFile) + }); + break; + case 'viewDiff': + this.sendMessage({ + command: 'viewDiff', + error: await viewDiff(msg.repo, msg.fromHash, msg.toHash, msg.oldFilePath, msg.newFilePath, msg.type) + }); + break; + case 'viewDiffWithWorkingFile': + this.sendMessage({ + command: 'viewDiffWithWorkingFile', + error: await viewDiffWithWorkingFile(msg.repo, msg.hash, msg.filePath, this.dataSource) + }); + break; + case 'viewFileAtRevision': + this.sendMessage({ + command: 'viewFileAtRevision', + error: await viewFileAtRevision(msg.repo, msg.hash, msg.filePath) + }); + break; + case 'viewScm': + this.sendMessage({ + command: 'viewScm', + error: await viewScm() + }); + break; + } + + this.repoFileWatcher.unmute(); + } + + /** + * Send a message to the front-end. + * @param msg The message to be sent. + */ + private sendMessage(msg: ResponseMessage) { + if (this.isDisposed()) { + this.logger.log('The Git Graph View has already been disposed, ignored sending "' + msg.command + '" message.'); + } else { + this.panel.webview.postMessage(msg).then( + () => { }, + () => { + if (this.isDisposed()) { + this.logger.log('The Git Graph View was disposed while sending "' + msg.command + '" message.'); + } else { + this.logger.logError('Unable to send "' + msg.command + '" message to the Git Graph View.'); + } + } + ); + } + } + + /** + * Update the HTML document loaded in the Webview. + */ + private update() { + this.panel.webview.html = this.getHtmlForWebview(); + } + + /** + * Get the HTML document to be loaded in the Webview. + * @returns The HTML. + */ + private getHtmlForWebview() { + const config = getConfig(), nonce = getNonce(); + const initialState: GitGraphViewInitialState = { + config: { + commitDetailsView: config.commitDetailsView, + commitOrdering: config.commitOrder, + contextMenuActionsVisibility: config.contextMenuActionsVisibility, + customBranchGlobPatterns: config.customBranchGlobPatterns, + customEmojiShortcodeMappings: config.customEmojiShortcodeMappings, + customPullRequestProviders: config.customPullRequestProviders, + dateFormat: config.dateFormat, + defaultColumnVisibility: config.defaultColumnVisibility, + stickyHeader: config.stickyHeader, + dialogDefaults: config.dialogDefaults, + enhancedAccessibility: config.enhancedAccessibility, + fetchAndPrune: config.fetchAndPrune, + fetchAndPruneTags: config.fetchAndPruneTags, + fetchAvatars: config.fetchAvatars && this.extensionState.isAvatarStorageAvailable(), + graph: config.graph, + includeCommitsMentionedByReflogs: config.includeCommitsMentionedByReflogs, + initialLoadCommits: config.initialLoadCommits, + keybindings: config.keybindings, + loadMoreCommits: config.loadMoreCommits, + loadMoreCommitsAutomatically: config.loadMoreCommitsAutomatically, + markdown: config.markdown, + mute: config.muteCommits, + onlyFollowFirstParent: config.onlyFollowFirstParent, + onRepoLoad: config.onRepoLoad, + referenceLabels: config.referenceLabels, + repoDropdownOrder: config.repoDropdownOrder, + showRemoteBranches: config.showRemoteBranches, + simplifyByDecoration: config.simplifyByDecoration, + showStashes: config.showStashes, + showTags: config.showTags, + toolbarButtonVisibility: config.toolbarButtonVisibility + }, + lastActiveRepo: this.extensionState.getLastActiveRepo(), + loadViewTo: this.loadViewTo, + repos: this.repoManager.getRepos(), + loadRepoInfoRefreshId: this.loadRepoInfoRefreshId, + loadCommitsRefreshId: this.loadCommitsRefreshId + }; + const globalState = this.extensionState.getGlobalViewState(); + const workspaceState = this.extensionState.getWorkspaceViewState(); + + let body, numRepos = Object.keys(initialState.repos).length, colorVars = '', colorParams = ''; + for (let i = 0; i < initialState.config.graph.colours.length; i++) { + colorVars += '--git-graph-color' + i + ':' + initialState.config.graph.colours[i] + '; '; + colorParams += '[data-color="' + i + '"]{--git-graph-color:var(--git-graph-color' + i + ');} '; + } + + if (this.dataSource.isGitExecutableUnknown()) { + body = ` +

    Unable to load Git Graph

    +

    ${UNABLE_TO_FIND_GIT_MSG}

    + `; + } else if (numRepos > 0) { + const stickyClassAttr = initialState.config.stickyHeader ? ' class="sticky"' : ''; + let hideRemotes = '', hideSimplify = ''; + if (!config.toolbarButtonVisibility.remotes) { hideRemotes = 'style="display: none"'; } + if (!config.toolbarButtonVisibility.simplify) { hideSimplify = 'style="display: none"'; } + body = ` +
    +
    + Repo: + Branches: + Authors: + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + `; + } else { + body = ` +

    Unable to load Git Graph

    +

    No Git repositories were found in the current workspace when it was last scanned by Git Graph.

    +

    If your repositories are in subfolders of the open workspace folder(s), make sure you have set the Git Graph Setting "git-graph.maxDepthOfRepoSearch" appropriately (read the documentation for more information).

    +

    Re-scan the current workspace for repositories

    + + `; + } + this.isGraphViewLoaded = numRepos > 0; + this.loadViewTo = null; + + return ` + + + + + + + Git Graph + + + ${body} + `; + } + + + /* URI Manipulation Methods */ + + /** + * Get a WebviewUri for a media file included in the extension. + * @param file The file name in the `media` directory. + * @returns The WebviewUri. + */ + private getMediaUri(file: string) { + return this.panel.webview.asWebviewUri(this.getUri('media', file)); + } + + /** + * Get a File Uri for a resource file included in the extension. + * @param file The file name in the `resource` directory. + * @returns The Uri. + */ + private getResourcesUri(file: string) { + return this.getUri('resources', file); + } + + /** + * Get a File Uri for a file included in the extension. + * @param pathComps The path components relative to the root directory of the extension. + * @returns The File Uri. + */ + private getUri(...pathComps: string[]) { + return vscode.Uri.file(path.join(this.extensionPath, ...pathComps)); + } + + + /* Response Construction Methods */ + + /** + * Send the known repositories to the front-end. + * @param repos The set of known repositories. + * @param loadViewTo What to load the view to. + */ + private respondLoadRepos(repos: GitRepoSet, loadViewTo: LoadGitGraphViewTo) { + this.sendMessage({ + command: 'loadRepos', + repos: repos, + lastActiveRepo: this.extensionState.getLastActiveRepo(), + loadViewTo: loadViewTo + }); + } + + /** + * Call the command to scroll to the specified commit to the front-end. + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public respondScrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + this.sendMessage({ + command: 'scrollToCommit', + hash: hash, + alwaysCenterCommit: alwaysCenterCommit, + flash: flash, + openDetails: openDetails, + persistently: persistently + }); + } +} + +/** + * Standardise the CSP Source provided by Visual Studio Code for use with the Webview. It is idempotent unless called with http/https URI's, in which case it keeps only the authority portion of the http/https URI. This is necessary to be compatible with some web browser environments. + * @param cspSource The value provide by Visual Studio Code. + * @returns The standardised CSP Source. + */ +export function standardiseCspSource(cspSource: string) { + if (cspSource.startsWith('http://') || cspSource.startsWith('https://')) { + const pathIndex = cspSource.indexOf('/', 8), queryIndex = cspSource.indexOf('?', 8), fragmentIndex = cspSource.indexOf('#', 8); + let endOfAuthorityIndex = pathIndex; + if (queryIndex > -1 && (queryIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = queryIndex; + if (fragmentIndex > -1 && (fragmentIndex < endOfAuthorityIndex || endOfAuthorityIndex === -1)) endOfAuthorityIndex = fragmentIndex; + return endOfAuthorityIndex > -1 ? cspSource.substring(0, endOfAuthorityIndex) : cspSource; + } else { + return cspSource; + } +} diff --git a/web/main.ts b/web/main.ts index b5618e67..8a8b0a44 100644 --- a/web/main.ts +++ b/web/main.ts @@ -1,4183 +1,4183 @@ -class GitGraphView { - private gitRepos: GG.GitRepoSet; - private gitBranches: ReadonlyArray = []; - private gitBranchHead: string | null = null; - private gitConfig: GG.GitRepoConfig | null = null; - private gitRemotes: ReadonlyArray = []; - private gitStashes: ReadonlyArray = []; - private gitTags: ReadonlyArray = []; - private commits: GG.GitCommit[] = []; - private commitHead: string | null = null; - private commitLookup: { [hash: string]: number } = {}; - private onlyFollowFirstParent: boolean = false; - private avatars: AvatarImageCollection = {}; - private currentBranches: string[] | null = null; - private currentAuthors: string[] | null = null; - - private currentRepo!: string; - private currentRepoLoading: boolean = true; - private currentRepoRefreshState: { - inProgress: boolean; - hard: boolean; - loadRepoInfoRefreshId: number; - loadCommitsRefreshId: number; - repoInfoChanges: boolean; - configChanges: boolean; - requestingRepoInfo: boolean; - requestingConfig: boolean; - }; - private loadViewTo: GG.LoadGitGraphViewTo = null; - - public scrollToCommitArgs: { - hash: string, - alwaysCenterCommit: boolean, - flash: boolean, - openDetails: boolean, - persistently: boolean - }; - - private readonly graph: Graph; - private readonly config: Config; - - private moreCommitsAvailable: boolean = false; - private expandedCommit: ExpandedCommit | null = null; - private maxCommits: number; - private scrollTop = 0; - private renderedGitBranchHead: string | null = null; - - private lastScrollToStash: { - time: number, - hash: string | null - } = { time: 0, hash: null }; - - private readonly findWidget: FindWidget; - private readonly settingsWidget: SettingsWidget; - private readonly repoDropdown: Dropdown; - private readonly branchDropdown: Dropdown; - private readonly authorDropdown: Dropdown; - - private readonly viewElem: HTMLElement; - private readonly controlsElem: HTMLElement; - private readonly tableElem: HTMLElement; - private tableColHeadersElem: HTMLElement | null; - private readonly footerElem: HTMLElement; - private readonly showRemoteBranchesElem: HTMLInputElement; - private readonly simplifyByDecorationElem: HTMLInputElement; - private readonly refreshBtnElem: HTMLElement; - - constructor(viewElem: HTMLElement, prevState: WebViewState | null) { - this.gitRepos = initialState.repos; - this.config = initialState.config; - this.maxCommits = this.config.initialLoadCommits; - this.viewElem = viewElem; - this.currentRepoRefreshState = { - inProgress: false, - hard: true, - loadRepoInfoRefreshId: initialState.loadRepoInfoRefreshId, - loadCommitsRefreshId: initialState.loadCommitsRefreshId, - repoInfoChanges: false, - configChanges: false, - requestingRepoInfo: false, - requestingConfig: false - }; - - this.scrollToCommitArgs = { - hash: '', - alwaysCenterCommit: false, - flash: false, - openDetails: false, - persistently: false - }; - - this.controlsElem = document.getElementById('controls')!; - this.tableElem = document.getElementById('commitTable')!; - this.tableColHeadersElem = document.getElementById('tableColHeaders')!; - this.footerElem = document.getElementById('footer')!; - - viewElem.focus(); - - this.graph = new Graph('commitGraph', viewElem, this.config.graph, this.config.mute); - - this.repoDropdown = new Dropdown('repoDropdown', true, false, 'Repos', (values) => { - this.loadRepo(values[0]); - }); - - this.branchDropdown = new Dropdown('branchDropdown', false, true, 'Branches', (values) => { - this.currentBranches = values; - this.maxCommits = this.config.initialLoadCommits; - this.saveState(); - this.clearCommits(); - this.requestLoadRepoInfoAndCommits(true, true); - }); - this.authorDropdown = new Dropdown('authorDropdown', false, true, 'Authors', (values) => { - this.currentAuthors = values; - this.maxCommits = this.config.initialLoadCommits; - this.saveState(); - this.clearCommits(); - this.requestLoadRepoInfoAndCommits(true, true); - }); - this.showRemoteBranchesElem = document.getElementById('showRemoteBranchesCheckbox')!; - this.showRemoteBranchesElem.addEventListener('change', () => { - this.saveRepoStateValue(this.currentRepo, 'showRemoteBranchesV2', this.showRemoteBranchesElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); - this.refresh(true); - }); - this.simplifyByDecorationElem = document.getElementById('simplifyByDecorationCheckbox')!; - this.simplifyByDecorationElem.addEventListener('change', () => { - this.saveRepoStateValue(this.currentRepo, 'simplifyByDecoration', this.simplifyByDecorationElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); - this.refresh(true); - }); - - this.refreshBtnElem = document.getElementById('refreshBtn')!; - this.refreshBtnElem.addEventListener('click', () => { - if (!this.refreshBtnElem.classList.contains(CLASS_REFRESHING)) { - this.refresh(true, true); - } - }); - this.renderRefreshButton(); - - this.findWidget = new FindWidget(this); - this.settingsWidget = new SettingsWidget(this); - - alterClass(document.body, CLASS_BRANCH_LABELS_ALIGNED_TO_GRAPH, this.config.referenceLabels.branchLabelsAlignedToGraph); - alterClass(document.body, CLASS_TAG_LABELS_RIGHT_ALIGNED, this.config.referenceLabels.tagLabelsOnRight); - - this.observeWindowSizeChanges(); - this.observeWebviewStyleChanges(); - this.observeViewScroll(); - this.observeKeyboardEvents(); - this.observeUrls(); - this.observeTableEvents(); - - if (prevState && !prevState.currentRepoLoading && typeof this.gitRepos[prevState.currentRepo] !== 'undefined') { - this.currentRepo = prevState.currentRepo; - this.currentBranches = prevState.currentBranches; - this.currentAuthors = prevState.currentAuthors; - this.maxCommits = prevState.maxCommits; - this.expandedCommit = prevState.expandedCommit; - this.avatars = prevState.avatars; - this.gitConfig = prevState.gitConfig; - this.loadRepoInfo(prevState.gitBranches, prevState.gitBranchHead, prevState.gitRemotes, prevState.gitStashes, true); - this.loadCommits(prevState.commits, prevState.commitHead, prevState.gitTags, prevState.moreCommitsAvailable, prevState.onlyFollowFirstParent); - this.findWidget.restoreState(prevState.findWidget); - this.settingsWidget.restoreState(prevState.settingsWidget); - this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[prevState.currentRepo].showRemoteBranchesV2); - this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[prevState.currentRepo].simplifyByDecoration); - } - - let loadViewTo = initialState.loadViewTo; - if (loadViewTo === null && prevState && prevState.currentRepoLoading && typeof prevState.currentRepo !== 'undefined') { - loadViewTo = { repo: prevState.currentRepo }; - } - - if (!this.loadRepos(this.gitRepos, initialState.lastActiveRepo, loadViewTo)) { - if (prevState) { - this.scrollTop = prevState.scrollTop; - this.viewElem.scroll(0, this.scrollTop); - } - this.requestLoadRepoInfoAndCommits(false, false); - } - - const currentBtn = document.getElementById('currentBtn')!, fetchBtn = document.getElementById('fetchBtn')!, findBtn = document.getElementById('findBtn')!, settingsBtn = document.getElementById('settingsBtn')!, terminalBtn = document.getElementById('terminalBtn')!; - currentBtn.innerHTML = SVG_ICONS.current; - currentBtn.addEventListener('click', () => { - if (this.commitHead) { - this.scrollToCommit(this.commitHead, true, true, false, true); - } - }); - fetchBtn.title = 'Fetch' + (this.config.fetchAndPrune ? ' & Prune' : '') + ' from Remote(s)'; - fetchBtn.innerHTML = SVG_ICONS.download; - fetchBtn.addEventListener('click', () => this.fetchFromRemotesAction()); - findBtn.innerHTML = SVG_ICONS.search; - findBtn.addEventListener('click', () => this.findWidget.show(true)); - settingsBtn.innerHTML = SVG_ICONS.gear; - settingsBtn.addEventListener('click', () => this.settingsWidget.show(this.currentRepo)); - terminalBtn.innerHTML = SVG_ICONS.terminal; - terminalBtn.addEventListener('click', () => { - runAction({ - command: 'openTerminal', - repo: this.currentRepo, - name: this.gitRepos[this.currentRepo].name || getRepoName(this.currentRepo) - }, 'Opening Terminal'); - }); - } - - - /* Loading Data */ - - public loadRepos(repos: GG.GitRepoSet, lastActiveRepo: string | null, loadViewTo: GG.LoadGitGraphViewTo) { - this.gitRepos = repos; - this.saveState(); - - let newRepo: string; - if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { - newRepo = loadViewTo.repo; - } else if (typeof repos[this.currentRepo] === 'undefined') { - newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' - ? lastActiveRepo - : getSortedRepositoryPaths(repos, this.config.repoDropdownOrder)[0]; - } else { - newRepo = this.currentRepo; - } - - alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); - this.renderRepoDropdownOptions(newRepo); - - if (loadViewTo !== null) { - if (loadViewTo.repo === newRepo) { - this.loadViewTo = loadViewTo; - } else { - this.loadViewTo = null; - showErrorMessage('Unable to load the Git Graph View for the repository "' + loadViewTo.repo + '". It is not currently included in Git Graph.'); - } - } else { - this.loadViewTo = null; - } - - if (this.currentRepo !== newRepo) { - this.loadRepo(newRepo); - return true; - } else { - this.finaliseRepoLoad(false); - return false; - } - } - - private loadRepo(repo: string) { - this.currentRepo = repo; - this.currentRepoLoading = true; - this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[this.currentRepo].showRemoteBranchesV2); - this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[this.currentRepo].simplifyByDecoration); - this.maxCommits = this.config.initialLoadCommits; - this.gitConfig = null; - this.gitRemotes = []; - this.gitStashes = []; - this.gitTags = []; - this.currentBranches = null; - this.currentAuthors = null; - this.renderFetchButton(); - this.closeCommitDetails(false); - this.settingsWidget.close(); - this.saveState(); - this.refresh(true); - } - - private loadRepoInfo(branchOptions: ReadonlyArray, branchHead: string | null, remotes: ReadonlyArray, stashes: ReadonlyArray, isRepo: boolean) { - // Changes to this.gitStashes are reflected as changes to the commits when loadCommits is run - this.gitStashes = stashes; - - if (!isRepo || (!this.currentRepoRefreshState.hard && arraysStrictlyEqual(this.gitBranches, branchOptions) && this.gitBranchHead === branchHead && arraysStrictlyEqual(this.gitRemotes, remotes))) { - this.saveState(); - this.finaliseLoadRepoInfo(false, isRepo); - return; - } - - // Changes to these properties must be indicated as a repository info change - this.gitBranches = branchOptions; - this.gitBranchHead = branchHead; - this.gitRemotes = remotes; - - // Update the state of the fetch button - this.renderFetchButton(); - - const filterCurrentBranches = () => { - // Configure current branches - if (this.currentBranches !== null && !(this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES)) { - // Filter any branches that are currently selected, but no longer exist - const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); - this.currentBranches = this.currentBranches.filter((branch) => - this.gitBranches.includes(branch) || globPatterns.includes(branch) || branch === 'HEAD' - ); - } - }; - - filterCurrentBranches(); - if (this.currentBranches === null || this.currentBranches.length === 0) { - // No branches are currently selected - const onRepoLoadShowCheckedOutBranch = getOnRepoLoadShowCheckedOutBranch(this.gitRepos[this.currentRepo].onRepoLoadShowCheckedOutBranch); - const onRepoLoadShowSpecificBranches = getOnRepoLoadShowSpecificBranches(this.gitRepos[this.currentRepo].onRepoLoadShowSpecificBranches); - this.currentBranches = []; - if (onRepoLoadShowSpecificBranches.length > 0) { - // Show specific branches if they exist in the repository - const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); - this.currentBranches.push(...onRepoLoadShowSpecificBranches.filter((branch) => - this.gitBranches.includes(branch) || globPatterns.includes(branch) - )); - } - if (onRepoLoadShowCheckedOutBranch && this.gitBranchHead !== null && !this.currentBranches.includes(this.gitBranchHead)) { - // Show the checked-out branch, and it hasn't already been added as a specific branch - this.currentBranches.push(this.gitBranchHead); - } - if (this.currentBranches.length === 0) { - this.currentBranches.push(SHOW_ALL_BRANCHES); - } - } - filterCurrentBranches(); - - this.saveState(); - - // Set up branch dropdown options - this.branchDropdown.setOptions(this.getBranchOptions(true), this.currentBranches); - this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); - - // Remove hidden remotes that no longer exist - let hiddenRemotes = this.gitRepos[this.currentRepo].hideRemotes; - let hideRemotes = hiddenRemotes.filter((hiddenRemote) => remotes.includes(hiddenRemote)); - if (hiddenRemotes.length !== hideRemotes.length) { - this.saveRepoStateValue(this.currentRepo, 'hideRemotes', hideRemotes); - } - - this.finaliseLoadRepoInfo(true, isRepo); - } - - private finaliseLoadRepoInfo(repoInfoChanges: boolean, isRepo: boolean) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - if (isRepo) { - refreshState.repoInfoChanges = refreshState.repoInfoChanges || repoInfoChanges; - refreshState.requestingRepoInfo = false; - this.requestLoadCommits(); - } else { - dialog.closeActionRunning(); - refreshState.inProgress = false; - this.loadViewTo = null; - this.renderRefreshButton(); - sendMessage({ command: 'loadRepos', check: true }); - } - } - } - - private loadCommits(commits: GG.GitCommit[], commitHead: string | null, tags: ReadonlyArray, moreAvailable: boolean, onlyFollowFirstParent: boolean) { - // This list of tags is just used to provide additional information in the dialogs. Tag information included in commits is used for all other purposes (e.g. rendering, context menus) - const tagsChanged = !arraysStrictlyEqual(this.gitTags, tags); - this.gitTags = tags; - - if (!this.currentRepoLoading && !this.currentRepoRefreshState.hard && this.moreCommitsAvailable === moreAvailable && this.onlyFollowFirstParent === onlyFollowFirstParent && this.commitHead === commitHead && commits.length > 0 && arraysEqual(this.commits, commits, (a, b) => - a.hash === b.hash && - arraysStrictlyEqual(a.heads, b.heads) && - arraysEqual(a.tags, b.tags, (a, b) => a.name === b.name && a.annotated === b.annotated) && - arraysEqual(a.remotes, b.remotes, (a, b) => a.name === b.name && a.remote === b.remote) && - arraysStrictlyEqual(a.parents, b.parents) && - ((a.stash === null && b.stash === null) || (a.stash !== null && b.stash !== null && a.stash.selector === b.stash.selector)) - ) && this.renderedGitBranchHead === this.gitBranchHead) { - - if (this.commits[0].hash === UNCOMMITTED) { - this.commits[0] = commits[0]; - this.saveState(); - this.renderUncommittedChanges(); - if (this.expandedCommit !== null && this.expandedCommit.commitElem !== null) { - if (this.expandedCommit.compareWithHash === null) { - // Commit Details View is open - if (this.expandedCommit.commitHash === UNCOMMITTED) { - this.requestCommitDetails(this.expandedCommit.commitHash, true); - } - } else { - // Commit Comparison is open - if (this.expandedCommit.compareWithElem !== null && (this.expandedCommit.commitHash === UNCOMMITTED || this.expandedCommit.compareWithHash === UNCOMMITTED)) { - this.requestCommitComparison(this.expandedCommit.commitHash, this.expandedCommit.compareWithHash, true); - } - } - } - } else if (tagsChanged) { - this.saveState(); - } - this.finaliseLoadCommits(); - return; - } - - const currentRepoLoading = this.currentRepoLoading; - this.currentRepoLoading = false; - this.moreCommitsAvailable = moreAvailable; - this.onlyFollowFirstParent = onlyFollowFirstParent; - this.commits = commits; - this.commitHead = commitHead; - this.commitLookup = {}; - - let i: number, expandedCommitVisible = false, expandedCompareWithCommitVisible = false, avatarsNeeded: { [email: string]: string[] } = {}, commit; - for (i = 0; i < this.commits.length; i++) { - commit = this.commits[i]; - this.commitLookup[commit.hash] = i; - if (this.expandedCommit !== null) { - if (this.expandedCommit.commitHash === commit.hash) { - expandedCommitVisible = true; - } else if (this.expandedCommit.compareWithHash === commit.hash) { - expandedCompareWithCommitVisible = true; - } - } - if (this.config.fetchAvatars && typeof this.avatars[commit.email] !== 'string' && commit.email !== '') { - if (typeof avatarsNeeded[commit.email] === 'undefined') { - avatarsNeeded[commit.email] = [commit.hash]; - } else { - avatarsNeeded[commit.email].push(commit.hash); - } - } - } - - if (this.expandedCommit !== null && (!expandedCommitVisible || (this.expandedCommit.compareWithHash !== null && !expandedCompareWithCommitVisible))) { - this.closeCommitDetails(false); - } - - this.saveState(); - - this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); - this.render(); - - if (currentRepoLoading && this.config.onRepoLoad.scrollToHead && this.commitHead !== null) { - this.scrollToCommit(this.commitHead, true); - } - - this.finaliseLoadCommits(); - this.requestAvatars(avatarsNeeded); - } - - private finaliseLoadCommits() { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - dialog.closeActionRunning(); - - if (dialog.isTargetDynamicSource()) { - if (refreshState.repoInfoChanges) { - dialog.close(); - } else { - dialog.refresh(this.getCommits()); - } - } - - if (contextMenu.isTargetDynamicSource()) { - if (refreshState.repoInfoChanges) { - contextMenu.close(); - } else { - contextMenu.refresh(this.getCommits()); - } - } - - refreshState.inProgress = false; - this.renderRefreshButton(); - } - - this.finaliseRepoLoad(true); - - if (this.scrollToCommitArgs.persistently) { - this.scrollToCommit(this.scrollToCommitArgs.hash, this.scrollToCommitArgs.alwaysCenterCommit, this.scrollToCommitArgs.flash, this.scrollToCommitArgs.openDetails, this.scrollToCommitArgs.persistently); - } - } - - private finaliseRepoLoad(didLoadRepoData: boolean) { - if (this.loadViewTo !== null && this.currentRepo === this.loadViewTo.repo) { - if (this.loadViewTo.commitDetails && (this.expandedCommit === null || this.expandedCommit.commitHash !== this.loadViewTo.commitDetails.commitHash || this.expandedCommit.compareWithHash !== this.loadViewTo.commitDetails.compareWithHash)) { - const commitIndex = this.getCommitId(this.loadViewTo.commitDetails.commitHash); - const compareWithIndex = this.loadViewTo.commitDetails.compareWithHash !== null ? this.getCommitId(this.loadViewTo.commitDetails.compareWithHash) : null; - const commitElems = getCommitElems(); - const commitElem = findCommitElemWithId(commitElems, commitIndex); - const compareWithElem = findCommitElemWithId(commitElems, compareWithIndex); - - if (commitElem !== null && (this.loadViewTo.commitDetails.compareWithHash === null || compareWithElem !== null)) { - if (compareWithElem !== null) { - this.loadCommitComparison(commitElem, compareWithElem); - } else { - this.loadCommitDetails(commitElem); - } - } else { - showErrorMessage('Unable to resume Code Review, it could not be found in the latest ' + this.maxCommits + ' commits that were loaded in this repository.'); - } - } else if (this.loadViewTo.runCommandOnLoad) { - switch (this.loadViewTo.runCommandOnLoad) { - case 'fetch': - this.fetchFromRemotesAction(); - break; - } - } - } - this.loadViewTo = null; - - if (this.gitConfig === null || (didLoadRepoData && this.currentRepoRefreshState.configChanges)) { - this.requestLoadConfig(); - } - } - - private clearCommits() { - closeDialogAndContextMenu(); - this.moreCommitsAvailable = false; - this.commits = []; - this.commitHead = null; - this.commitLookup = {}; - this.renderedGitBranchHead = null; - this.closeCommitDetails(false); - this.saveState(); - this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); - this.tableElem.innerHTML = ''; - this.footerElem.innerHTML = ''; - this.renderGraph(); - this.findWidget.refresh(); - } - - public processLoadRepoInfoResponse(msg: GG.ResponseLoadRepoInfo) { - if (msg.error === null) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress && refreshState.loadRepoInfoRefreshId === msg.refreshId) { - this.loadRepoInfo(msg.branches, msg.head, msg.remotes, msg.stashes, msg.isRepo); - } - } else { - this.displayLoadDataError('Unable to load Repository Info', msg.error); - } - } - - public processLoadCommitsResponse(msg: GG.ResponseLoadCommits) { - if (msg.error === null) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress && refreshState.loadCommitsRefreshId === msg.refreshId) { - this.loadCommits(msg.commits, msg.head, msg.tags, msg.moreCommitsAvailable, msg.onlyFollowFirstParent); - } - } else { - const error = this.gitBranches.length === 0 && msg.error.indexOf('bad revision \'HEAD\'') > -1 - ? 'There are no commits in this repository.' - : msg.error; - this.displayLoadDataError('Unable to load Commits', error); - } - } - - public processLoadConfig(msg: GG.ResponseLoadConfig) { - this.currentRepoRefreshState.requestingConfig = false; - if (msg.config !== null && this.currentRepo === msg.repo) { - this.gitConfig = msg.config; - this.saveState(); - - this.renderCdvExternalDiffBtn(); - } - this.settingsWidget.refresh(); - this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); - } - - private displayLoadDataError(message: string, reason: string) { - this.clearCommits(); - this.currentRepoRefreshState.inProgress = false; - this.loadViewTo = null; - this.renderRefreshButton(); - dialog.showError(message, reason, 'Retry', () => { - this.refresh(true); - }); - } - - public loadAvatar(email: string, image: string) { - this.avatars[email] = image; - this.saveState(); - let avatarsElems = >document.getElementsByClassName('avatar'), escapedEmail = escapeHtml(email); - for (let i = 0; i < avatarsElems.length; i++) { - if (avatarsElems[i].dataset.email === escapedEmail) { - avatarsElems[i].innerHTML = ''; - } - } - } - - - /* Getters */ - - public getBranches(): ReadonlyArray { - return this.gitBranches; - } - - public getBranchOptions(includeShowAll?: boolean): ReadonlyArray { - const options: DialogSelectInputOption[] = []; - if (includeShowAll) { - options.push({ name: 'Show All', value: SHOW_ALL_BRANCHES }); - } - options.push({ name: 'HEAD', value: 'HEAD' }); - for (let i = 0; i < this.config.customBranchGlobPatterns.length; i++) { - options.push({ name: 'Glob: ' + this.config.customBranchGlobPatterns[i].name, value: this.config.customBranchGlobPatterns[i].glob }); - } - for (let i = 0; i < this.gitBranches.length; i++) { - options.push({ name: this.gitBranches[i].indexOf('remotes/') === 0 ? this.gitBranches[i].substring(8) : this.gitBranches[i], value: this.gitBranches[i] }); - } - return options; - } - public getAuthorOptions(): ReadonlyArray { - const options: DialogSelectInputOption[] = []; - options.push({ name: 'All', value: SHOW_ALL_BRANCHES }); - if (this.gitConfig && this.gitConfig.authors) { - for (let i = 0; i < this!.gitConfig!.authors.length; i++) { - const author = this!.gitConfig!.authors[i]; - options.push({ name: author.name, value: author.name }); - } - } - return options; - } - public getCommitId(hash: string) { - if (typeof this.commitLookup[hash] === 'number') { - return this.commitLookup[hash]; - } - // If a full match isn't found, try to find a matching partial hash - for (const key in this.commitLookup) { - if (key.startsWith(hash)) { - return this.commitLookup[key]; - } - } - return null; - } - - private getCommitOfElem(elem: HTMLElement) { - let id = parseInt(elem.dataset.id!); - return id < this.commits.length ? this.commits[id] : null; - } - - public getCommits(): ReadonlyArray { - return this.commits; - } - - private getPushRemote(branch: string | null = null) { - const possibleRemotes = []; - if (this.gitConfig !== null) { - if (branch !== null && typeof this.gitConfig.branches[branch] !== 'undefined') { - possibleRemotes.push(this.gitConfig.branches[branch].pushRemote, this.gitConfig.branches[branch].remote); - } - possibleRemotes.push(this.gitConfig.pushDefault); - } - possibleRemotes.push('origin'); - return possibleRemotes.find((remote) => remote !== null && this.gitRemotes.includes(remote)) || this.gitRemotes[0]; - } - - public getRepoConfig(): Readonly | null { - return this.gitConfig; - } - - public getRepoState(repo: string): Readonly | null { - return typeof this.gitRepos[repo] !== 'undefined' - ? this.gitRepos[repo] - : null; - } - - public isConfigLoading(): boolean { - return this.currentRepoRefreshState.requestingConfig; - } - - - /* Refresh */ - - public refresh(hard: boolean, configChanges: boolean = false) { - if (hard) { - this.clearCommits(); - } - this.requestLoadRepoInfoAndCommits(hard, false, configChanges); - } - - - /* Requests */ - - private requestLoadRepoInfo() { - const repoState = this.gitRepos[this.currentRepo]; - sendMessage({ - command: 'loadRepoInfo', - repo: this.currentRepo, - refreshId: ++this.currentRepoRefreshState.loadRepoInfoRefreshId, - showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), - simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), - showStashes: getShowStashes(repoState.showStashes), - hideRemotes: repoState.hideRemotes - }); - } - - private requestLoadCommits() { - const repoState = this.gitRepos[this.currentRepo]; - sendMessage({ - command: 'loadCommits', - repo: this.currentRepo, - refreshId: ++this.currentRepoRefreshState.loadCommitsRefreshId, - branches: this.currentBranches === null || (this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES) ? null : this.currentBranches, - authors: this.currentAuthors === null || (this.currentAuthors.length === 1 && this.currentAuthors[0] === SHOW_ALL_BRANCHES) ? null : this.currentAuthors, - maxCommits: this.maxCommits, - showTags: getShowTags(repoState.showTags), - showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), - simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), - includeCommitsMentionedByReflogs: getIncludeCommitsMentionedByReflogs(repoState.includeCommitsMentionedByReflogs), - onlyFollowFirstParent: getOnlyFollowFirstParent(repoState.onlyFollowFirstParent), - commitOrdering: getCommitOrdering(repoState.commitOrdering), - remotes: this.gitRemotes, - hideRemotes: repoState.hideRemotes, - stashes: this.gitStashes - }); - } - - private requestLoadRepoInfoAndCommits(hard: boolean, skipRepoInfo: boolean, configChanges: boolean = false) { - const refreshState = this.currentRepoRefreshState; - if (refreshState.inProgress) { - refreshState.hard = refreshState.hard || hard; - refreshState.configChanges = refreshState.configChanges || configChanges; - if (!skipRepoInfo) { - // This request will trigger a loadCommit request after the loadRepoInfo request has completed. - // Invalidate any previous commit requests in progress. - refreshState.loadCommitsRefreshId++; - } - } else { - refreshState.hard = hard; - refreshState.inProgress = true; - refreshState.repoInfoChanges = false; - refreshState.configChanges = configChanges; - refreshState.requestingRepoInfo = false; - } - - this.renderRefreshButton(); - if (this.commits.length === 0) { - this.tableElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; - } - - if (skipRepoInfo) { - if (!refreshState.requestingRepoInfo) { - this.requestLoadCommits(); - } - } else { - refreshState.requestingRepoInfo = true; - this.requestLoadRepoInfo(); - } - } - - public requestLoadConfig() { - this.currentRepoRefreshState.requestingConfig = true; - sendMessage({ command: 'loadConfig', repo: this.currentRepo, remotes: this.gitRemotes }); - this.settingsWidget.refresh(); - } - - public requestCommitDetails(hash: string, refresh: boolean) { - let commit = this.commits[this.commitLookup[hash]]; - sendMessage({ - command: 'commitDetails', - repo: this.currentRepo, - commitHash: hash, - hasParents: commit.parents.length > 0, - stash: commit.stash, - avatarEmail: this.config.fetchAvatars && hash !== UNCOMMITTED ? commit.email : null, - refresh: refresh - }); - } - - public requestCommitComparison(hash: string, compareWithHash: string, refresh: boolean) { - let commitOrder = this.getCommitOrder(hash, compareWithHash); - sendMessage({ - command: 'compareCommits', - repo: this.currentRepo, - commitHash: hash, compareWithHash: compareWithHash, - fromHash: commitOrder.from, toHash: commitOrder.to, - refresh: refresh - }); - } - - private requestAvatars(avatars: { [email: string]: string[] }) { - let emails = Object.keys(avatars), remote = this.gitRemotes.length > 0 ? this.gitRemotes.includes('origin') ? 'origin' : this.gitRemotes[0] : null; - for (let i = 0; i < emails.length; i++) { - sendMessage({ command: 'fetchAvatar', repo: this.currentRepo, remote: remote, email: emails[i], commits: avatars[emails[i]] }); - } - } - - - /* State */ - - public saveState() { - let expandedCommit; - if (this.expandedCommit !== null) { - expandedCommit = Object.assign({}, this.expandedCommit); - expandedCommit.commitElem = null; - expandedCommit.compareWithElem = null; - expandedCommit.contextMenuOpen = { - summary: false, - fileView: -1 - }; - } else { - expandedCommit = null; - } - - VSCODE_API.setState({ - currentRepo: this.currentRepo, - currentRepoLoading: this.currentRepoLoading, - gitRepos: this.gitRepos, - gitBranches: this.gitBranches, - gitBranchHead: this.gitBranchHead, - gitConfig: this.gitConfig, - gitRemotes: this.gitRemotes, - gitStashes: this.gitStashes, - gitTags: this.gitTags, - commits: this.commits, - commitHead: this.commitHead, - avatars: this.avatars, - currentBranches: this.currentBranches, - currentAuthors: this.currentAuthors, - moreCommitsAvailable: this.moreCommitsAvailable, - maxCommits: this.maxCommits, - onlyFollowFirstParent: this.onlyFollowFirstParent, - expandedCommit: expandedCommit, - scrollTop: this.scrollTop, - findWidget: this.findWidget.getState(), - settingsWidget: this.settingsWidget.getState() - }); - } - - public saveRepoState() { - sendMessage({ command: 'setRepoState', repo: this.currentRepo, state: this.gitRepos[this.currentRepo] }); - } - - private saveColumnWidths(columnWidths: GG.ColumnWidth[]) { - this.gitRepos[this.currentRepo].columnWidths = [columnWidths[0], columnWidths[2], columnWidths[3], columnWidths[4]]; - this.saveRepoState(); - } - - private saveExpandedCommitLoading(index: number, commitHash: string, commitElem: HTMLElement, compareWithHash: string | null, compareWithElem: HTMLElement | null) { - this.expandedCommit = { - index: index, - commitHash: commitHash, - commitElem: commitElem, - compareWithHash: compareWithHash, - compareWithElem: compareWithElem, - commitDetails: null, - fileChanges: null, - fileTree: null, - avatar: null, - codeReview: null, - lastViewedFile: null, - loading: true, - scrollTop: { - summary: 0, - fileView: 0 - }, - contextMenuOpen: { - summary: false, - fileView: -1 - } - }; - this.saveState(); - } - - public saveRepoStateValue(repo: string, key: K, value: GG.GitRepoState[K]) { - if (repo === this.currentRepo) { - this.gitRepos[this.currentRepo][key] = value; - this.saveRepoState(); - } - } - - - /* Renderers */ - - private render() { - this.renderTable(); - this.renderGraph(); - } - - private renderGraph() { - if (typeof this.currentRepo === 'undefined') { - // Only render the graph if a repo is loaded (or a repo is currently being loaded) - return; - } - - const colHeadersElem = document.getElementById('tableColHeaders'); - const cdvHeight = this.gitRepos[this.currentRepo].isCdvSummaryHidden ? 0 : this.gitRepos[this.currentRepo].cdvHeight; - const headerHeight = colHeadersElem !== null ? colHeadersElem.clientHeight + 1 : 0; - const expandedCommit = this.isCdvDocked() ? null : this.expandedCommit; - const expandedCommitElem = expandedCommit !== null ? document.getElementById('cdv') : null; - - // Update the graphs grid dimensions - this.config.graph.grid.expandY = expandedCommitElem !== null - ? expandedCommitElem.getBoundingClientRect().height - : cdvHeight; - this.config.graph.grid.y = this.commits.length > 0 && this.tableElem.children.length > 0 - ? (this.tableElem.children[0].clientHeight - headerHeight - (expandedCommit !== null ? cdvHeight : 0)) / this.commits.length - : this.config.graph.grid.y; - this.config.graph.grid.offsetY = headerHeight + this.config.graph.grid.y / 2; - - this.graph.render(expandedCommit); - } - - private renderTable() { - const colVisibility = this.getColumnVisibility(); - const currentHash = this.commits.length > 0 && this.commits[0].hash === UNCOMMITTED ? UNCOMMITTED : this.commitHead; - const vertexColours = this.graph.getVertexColours(); - const widthsAtVertices = this.config.referenceLabels.branchLabelsAlignedToGraph ? this.graph.getWidthsAtVertices() : []; - const mutedCommits = this.graph.getMutedCommits(currentHash); - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - emoji: true, - issueLinking: true, - markdown: this.config.markdown - }); - - let html = 'GraphDescription' + - (colVisibility.date ? 'Date' : '') + - (colVisibility.author ? 'Author' : '') + - (colVisibility.commit ? 'Commit' : '') + - ''; - - for (let i = 0; i < this.commits.length; i++) { - let commit = this.commits[i]; - let message = '' + textFormatter.format(commit.message) + ''; - let date = formatShortDate(commit.date); - let branchLabels = getBranchLabels(commit.heads, commit.remotes); - let refBranches = '', refTags = '', j, k, refName, remoteName, refActive, refHtml, branchCheckedOutAtCommit: string | null = null; - - for (j = 0; j < branchLabels.heads.length; j++) { - refName = escapeHtml(branchLabels.heads[j].name); - refActive = branchLabels.heads[j].name === this.gitBranchHead; - refHtml = '' + SVG_ICONS.branch + '' + refName + ''; - for (k = 0; k < branchLabels.heads[j].remotes.length; k++) { - remoteName = escapeHtml(branchLabels.heads[j].remotes[k]); - refHtml += '' + remoteName + ''; - } - refHtml += ''; - refBranches = refActive ? refHtml + refBranches : refBranches + refHtml; - if (refActive) branchCheckedOutAtCommit = this.gitBranchHead; - } - for (j = 0; j < branchLabels.remotes.length; j++) { - refName = escapeHtml(branchLabels.remotes[j].name); - refBranches += '' + SVG_ICONS.branch + '' + refName + ''; - } - - for (j = 0; j < commit.tags.length; j++) { - refName = escapeHtml(commit.tags[j].name); - refTags += '' + SVG_ICONS.tag + '' + refName + ''; - } - - if (commit.stash !== null) { - refName = escapeHtml(commit.stash.selector); - refBranches = '' + SVG_ICONS.stash + '' + escapeHtml(commit.stash.selector.substring(5)) + '' + refBranches; - } - - const commitDot = commit.hash === this.commitHead - ? '' - : ''; - - html += '' + - (this.config.referenceLabels.branchLabelsAlignedToGraph ? '' + getResizeColHtml(0) + (refBranches !== '' ? '' + getResizeColHtml(1) + '' + commitDot : '' + getResizeColHtml(0) + '' + getResizeColHtml(1) + '' + commitDot + refBranches) + (this.config.referenceLabels.tagLabelsOnRight ? message + refTags : refTags + message) + '' + - (colVisibility.date ? '' + getResizeColHtml(2) + date.formatted + '' : '') + - (colVisibility.author ? '' + getResizeColHtml(3) + (this.config.fetchAvatars ? '' + (typeof this.avatars[commit.email] === 'string' ? '' : '') + '' : '') + escapeHtml(commit.author) + '' : '') + - (colVisibility.commit ? '' + getResizeColHtml(4) + abbrevCommit(commit.hash) + '' : '') + - ''; - - - } - function getResizeColHtml(col: number) { - return (col > 0 ? '' : '') + (col < 4 ? '' : ''); - } - this.tableElem.innerHTML = '' + html + '
    '; - this.footerElem.innerHTML = this.moreCommitsAvailable ? '
    Load More Commits
    ' : ''; - this.makeTableResizable(); - this.findWidget.refresh(); - this.renderedGitBranchHead = this.gitBranchHead; - - if (this.moreCommitsAvailable) { - document.getElementById('loadMoreCommitsBtn')!.addEventListener('click', () => { - this.loadMoreCommits(); - }); - } - - if (this.expandedCommit !== null) { - const expandedCommit = this.expandedCommit, elems = getCommitElems(); - const commitElem = findCommitElemWithId(elems, this.getCommitId(expandedCommit.commitHash)); - const compareWithElem = expandedCommit.compareWithHash !== null ? findCommitElemWithId(elems, this.getCommitId(expandedCommit.compareWithHash)) : null; - - if (commitElem === null || (expandedCommit.compareWithHash !== null && compareWithElem === null)) { - this.closeCommitDetails(false); - this.saveState(); - } else { - expandedCommit.index = parseInt(commitElem.dataset.id!); - expandedCommit.commitElem = commitElem; - expandedCommit.compareWithElem = compareWithElem; - this.saveState(); - if (expandedCommit.compareWithHash === null) { - // Commit Details View is open - if (!expandedCommit.loading && expandedCommit.commitDetails !== null && expandedCommit.fileTree !== null) { - this.showCommitDetails(expandedCommit.commitDetails, expandedCommit.fileTree, expandedCommit.avatar, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); - if (expandedCommit.commitHash === UNCOMMITTED) { - this.requestCommitDetails(expandedCommit.commitHash, true); - } - } else { - this.loadCommitDetails(commitElem); - } - } else { - // Commit Comparison is open - if (!expandedCommit.loading && expandedCommit.fileChanges !== null && expandedCommit.fileTree !== null) { - this.showCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, expandedCommit.fileChanges, expandedCommit.fileTree, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); - if (expandedCommit.commitHash === UNCOMMITTED || expandedCommit.compareWithHash === UNCOMMITTED) { - this.requestCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, true); - } - } else { - this.loadCommitComparison(commitElem, compareWithElem!); - } - } - } - } - - if (this.config.stickyHeader) { - this.tableColHeadersElem = document.getElementById('tableColHeaders'); - this.alignTableHeaderToControls(); - } - } - - private renderUncommittedChanges() { - const colVisibility = this.getColumnVisibility(), date = formatShortDate(this.commits[0].date); - document.getElementById('uncommittedChanges')!.innerHTML = '' + escapeHtml(this.commits[0].message) + '' + - (colVisibility.date ? '' + date.formatted + '' : '') + - (colVisibility.author ? '*' : '') + - (colVisibility.commit ? '*' : ''); - } - - private renderFetchButton() { - alterClass(this.controlsElem, CLASS_FETCH_SUPPORTED, this.gitRemotes.length > 0); - } - - public renderRefreshButton() { - const enabled = !this.currentRepoRefreshState.inProgress; - this.refreshBtnElem.title = enabled ? 'Refresh' : 'Refreshing'; - this.refreshBtnElem.innerHTML = enabled ? SVG_ICONS.refresh : SVG_ICONS.loading; - alterClass(this.refreshBtnElem, CLASS_REFRESHING, !enabled); - } - - public renderTagDetails(tagName: string, commitHash: string, details: GG.GitTagDetails) { - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - commits: true, - emoji: true, - issueLinking: true, - markdown: this.config.markdown, - multiline: true, - urls: true - }); - dialog.showMessage( - 'Tag ' + escapeHtml(tagName) + '
    ' + - 'Object: ' + escapeHtml(details.hash) + '
    ' + - 'Commit: ' + escapeHtml(commitHash) + '
    ' + - 'Tagger: ' + escapeHtml(details.taggerName) + ' <' + escapeHtml(details.taggerEmail) + '>' + (details.signature !== null ? generateSignatureHtml(details.signature) : '') + '
    ' + - 'Date: ' + formatLongDate(details.taggerDate) + '

    ' + - textFormatter.format(details.message) + - '
    ' - ); - } - - public renderRepoDropdownOptions(repo?: string) { - this.repoDropdown.setOptions(getRepoDropdownOptions(this.gitRepos), [repo || this.currentRepo]); - } - - - /* Context Menu Generation */ - - private getBranchContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { - const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.branch; - const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(refName); - - return [[ - { - title: 'Checkout Branch', - visible: visibility.checkout && this.gitBranchHead !== refName, - onClick: () => this.checkoutBranchAction(refName, null, null, target) - }, { - title: 'Rename Branch' + ELLIPSIS, - visible: visibility.rename, - onClick: () => { - dialog.showRefInput('Enter the new name for branch ' + escapeHtml(refName) + ':', refName, 'Rename Branch', (newName) => { - runAction({ command: 'renameBranch', repo: this.currentRepo, oldName: refName, newName: newName }, 'Renaming Branch'); - }, target); - } - }, { - title: 'Delete Branch' + ELLIPSIS, - visible: visibility.delete && this.gitBranchHead !== refName, - onClick: () => { - let remotesWithBranch = this.gitRemotes.filter(remote => this.gitBranches.includes('remotes/' + remote + '/' + refName)); - let inputs: DialogInput[] = [{ type: DialogInputType.Checkbox, name: 'Force Delete', value: this.config.dialogDefaults.deleteBranch.forceDelete }]; - if (remotesWithBranch.length > 0) { - inputs.push({ - type: DialogInputType.Checkbox, - name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : ''), - value: false, - info: 'This branch is on the remote' + (remotesWithBranch.length > 1 ? 's: ' : ' ') + formatCommaSeparatedList(remotesWithBranch.map((remote) => '"' + remote + '"')) - }); - } - dialog.showForm('Are you sure you want to delete the branch ' + escapeHtml(refName) + '?', inputs, 'Yes, delete', (values) => { - runAction({ command: 'deleteBranch', repo: this.currentRepo, branchName: refName, forceDelete: values[0], deleteOnRemotes: remotesWithBranch.length > 0 && values[1] ? remotesWithBranch : [] }, 'Deleting Branch'); - }, target); - } - }, { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge && this.gitBranchHead !== refName, - onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.Branch, target) - }, { - title: 'Rebase current Branch on Branch' + ELLIPSIS, - visible: visibility.rebase && this.gitBranchHead !== refName, - onClick: () => this.rebaseAction(refName, refName, GG.RebaseActionOn.Branch, target) - }, { - title: 'Push Branch' + ELLIPSIS, - visible: visibility.push && this.gitRemotes.length > 0, - onClick: () => { - const multipleRemotes = this.gitRemotes.length > 1; - const inputs: DialogInput[] = [ - { type: DialogInputType.Checkbox, name: 'Set Upstream', value: true }, - { - type: DialogInputType.Radio, - name: 'Push Mode', - options: [ - { name: 'Normal', value: GG.GitPushBranchMode.Normal }, - { name: 'Force With Lease', value: GG.GitPushBranchMode.ForceWithLease }, - { name: 'Force', value: GG.GitPushBranchMode.Force } - ], - default: GG.GitPushBranchMode.Normal - } - ]; - - if (multipleRemotes) { - inputs.unshift({ - type: DialogInputType.Select, - name: 'Push to Remote(s)', - defaults: [this.getPushRemote(refName)], - options: this.gitRemotes.map((remote) => ({ name: remote, value: remote })), - multiple: true - }); - } - - dialog.showForm('Are you sure you want to push the branch ' + escapeHtml(refName) + '' + (multipleRemotes ? '' : ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '') + '?', inputs, 'Yes, push', (values) => { - const remotes = multipleRemotes ? values.shift() : [this.gitRemotes[0]]; - const setUpstream = values[0]; - runAction({ - command: 'pushBranch', - repo: this.currentRepo, - branchName: refName, - remotes: remotes, - setUpstream: setUpstream, - mode: values[1], - willUpdateBranchConfig: setUpstream && remotes.length > 0 && (this.gitConfig === null || typeof this.gitConfig.branches[refName] === 'undefined' || this.gitConfig.branches[refName].remote !== remotes[remotes.length - 1]) - }, 'Pushing Branch'); - }, target); - } - } - ], [ - this.getViewIssueAction(refName, visibility.viewIssue, target), - { - title: 'Create Pull Request' + ELLIPSIS, - visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null, - onClick: () => { - const config = this.gitRepos[this.currentRepo].pullRequestConfig; - if (config === null) return; - dialog.showCheckbox('Are you sure you want to create a Pull Request for branch ' + escapeHtml(refName) + '?', 'Push branch before creating the Pull Request', true, 'Yes, create Pull Request', (push) => { - runAction({ command: 'createPullRequest', repo: this.currentRepo, config: config, sourceRemote: config.sourceRemote, sourceOwner: config.sourceOwner, sourceRepo: config.sourceRepo, sourceBranch: refName, push: push }, 'Creating Pull Request'); - }, target); - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); - } - }, - { - title: 'Select in Branches Dropdown', - visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.selectOption(refName) - }, - { - title: 'Unselect in Branches Dropdown', - visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.unselectOption(refName) - } - ], [ - { - title: 'Copy Branch Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); - } - } - ]]; - } - - private getCommitContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { - const hash = target.hash, visibility = this.config.contextMenuActionsVisibility.commit; - const commit = this.commits[this.commitLookup[hash]]; - return [[ - { - title: 'Add Tag' + ELLIPSIS, - visible: visibility.addTag, - onClick: () => this.addTagAction(hash, '', this.config.dialogDefaults.addTag.type, '', null, target) - }, { - title: 'Create Branch' + ELLIPSIS, - visible: visibility.createBranch, - onClick: () => this.createBranchAction(hash, '', this.config.dialogDefaults.createBranch.checkout, target) - } - ], [ - { - title: 'Checkout' + (globalState.alwaysAcceptCheckoutCommit ? '' : ELLIPSIS), - visible: visibility.checkout, - onClick: () => { - const checkoutCommit = () => runAction({ command: 'checkoutCommit', repo: this.currentRepo, commitHash: hash }, 'Checking out Commit'); - if (globalState.alwaysAcceptCheckoutCommit) { - checkoutCommit(); - } else { - dialog.showCheckbox('Are you sure you want to checkout commit ' + abbrevCommit(hash) + '? This will result in a \'detached HEAD\' state.', 'Always Accept', false, 'Yes, checkout', (alwaysAccept) => { - if (alwaysAccept) { - updateGlobalViewState('alwaysAcceptCheckoutCommit', true); - } - checkoutCommit(); - }, target); - } - } - }, { - title: 'Cherry Pick' + ELLIPSIS, - visible: visibility.cherrypick, - onClick: () => { - const isMerge = commit.parents.length > 1; - let inputs: DialogInput[] = []; - if (isMerge) { - let options = commit.parents.map((hash, index) => ({ - name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), - value: (index + 1).toString() - })); - inputs.push({ - type: DialogInputType.Select, - name: 'Parent Hash', - options: options, - default: '1', - info: 'Choose the parent hash on the main branch, to cherry pick the commit relative to.' - }); - } - inputs.push({ - type: DialogInputType.Checkbox, - name: 'Record Origin', - value: this.config.dialogDefaults.cherryPick.recordOrigin, - info: 'Record that this commit was the origin of the cherry pick by appending a line to the original commit message that states "(cherry picked from commit ...​)".' - }, { - type: DialogInputType.Checkbox, - name: 'No Commit', - value: this.config.dialogDefaults.cherryPick.noCommit, - info: 'Cherry picked changes will be staged but not committed, so that you can select and commit specific parts of this commit.' - }); - - dialog.showForm('Are you sure you want to cherry pick commit ' + abbrevCommit(hash) + '?', inputs, 'Yes, cherry pick', (values) => { - let parentIndex = isMerge ? parseInt(values.shift()) : 0; - runAction({ - command: 'cherrypickCommit', - repo: this.currentRepo, - commitHash: hash, - parentIndex: parentIndex, - recordOrigin: values[0], - noCommit: values[1] - }, 'Cherry picking Commit'); - }, target); - } - }, { - title: 'Revert' + ELLIPSIS, - visible: visibility.revert, - onClick: () => { - if (commit.parents.length > 1) { - let options = commit.parents.map((hash, index) => ({ - name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), - value: (index + 1).toString() - })); - dialog.showSelect('Are you sure you want to revert merge commit ' + abbrevCommit(hash) + '? Choose the parent hash on the main branch, to revert the commit relative to:', '1', options, 'Yes, revert', (parentIndex) => { - runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: parseInt(parentIndex) }, 'Reverting Commit'); - }, target); - } else { - dialog.showConfirmation('Are you sure you want to revert commit ' + abbrevCommit(hash) + '?', 'Yes, revert', () => { - runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: 0 }, 'Reverting Commit'); - }, target); - } - } - }, { - title: 'Drop' + ELLIPSIS, - visible: visibility.drop && this.graph.dropCommitPossible(this.commitLookup[hash]), - onClick: () => { - dialog.showConfirmation('Are you sure you want to permanently drop commit ' + abbrevCommit(hash) + '?' + (this.onlyFollowFirstParent ? '
    Note: By enabling "Only follow the first parent of commits", some commits may have been hidden from the Git Graph View that could affect the outcome of performing this action.' : ''), 'Yes, drop', () => { - runAction({ command: 'dropCommit', repo: this.currentRepo, commitHash: hash }, 'Dropping Commit'); - }, target); - } - } - ], [ - { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge, - onClick: () => this.mergeAction(hash, abbrevCommit(hash), GG.MergeActionOn.Commit, target) - }, { - title: 'Rebase current Branch on this Commit' + ELLIPSIS, - visible: visibility.rebase, - onClick: () => this.rebaseAction(hash, abbrevCommit(hash), GG.RebaseActionOn.Commit, target) - }, { - title: 'Reset current branch to this Commit' + ELLIPSIS, - visible: visibility.reset, - onClick: () => { - dialog.showSelect('Are you sure you want to reset ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' to commit ' + abbrevCommit(hash) + '?', this.config.dialogDefaults.resetCommit.mode, [ - { name: 'Soft - Keep all changes, but reset head', value: GG.GitResetMode.Soft }, - { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, - { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } - ], 'Yes, reset', (mode) => { - runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: hash, resetMode: mode }, 'Resetting to Commit'); - }, target); - } - } - ], [ - { - title: 'Copy Commit Hash to Clipboard', - visible: visibility.copyHash, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Commit Hash', data: hash }); - } - }, - { - title: 'Copy Commit Subject to Clipboard', - visible: visibility.copySubject, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Commit Subject', data: commit.message }); - } - } - ]]; - } - - private getRemoteBranchContextMenuActions(remote: string, target: DialogTarget & RefTarget): ContextMenuActions { - const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.remoteBranch; - const branchName = remote !== '' ? refName.substring(remote.length + 1) : ''; - const prefixedRefName = 'remotes/' + refName; - const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(prefixedRefName); - return [[ - { - title: 'Checkout Branch' + ELLIPSIS, - visible: visibility.checkout, - onClick: () => this.checkoutBranchAction(refName, remote, null, target) - }, { - title: 'Delete Remote Branch' + ELLIPSIS, - visible: visibility.delete && remote !== '', - onClick: () => { - dialog.showConfirmation('Are you sure you want to delete the remote branch ' + escapeHtml(refName) + '?', 'Yes, delete', () => { - runAction({ command: 'deleteRemoteBranch', repo: this.currentRepo, branchName: branchName, remote: remote }, 'Deleting Remote Branch'); - }, target); - } - }, { - title: 'Fetch into local branch' + ELLIPSIS, - visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, - onClick: () => { - dialog.showForm('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Force Fetch', - value: this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, - info: 'Force the local branch to be reset to this remote branch.' - }], 'Yes, fetch', (values) => { - runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: values[0] }, 'Fetching Branch'); - }, target); - } - }, { - title: 'Merge into current branch' + ELLIPSIS, - visible: visibility.merge, - onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.RemoteTrackingBranch, target) - }, { - title: 'Pull into current branch' + ELLIPSIS, - visible: visibility.pull && remote !== '', - onClick: () => { - dialog.showForm('Are you sure you want to pull the remote branch ' + escapeHtml(refName) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '? If a merge is required:', [ - { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.pullBranch.noFastForward }, - { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.pullBranch.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this remote branch.' } - ], 'Yes, pull', (values) => { - runAction({ command: 'pullBranch', repo: this.currentRepo, branchName: branchName, remote: remote, createNewCommit: values[0], squash: values[1] }, 'Pulling Branch'); - }, target); - } - } - ], [ - this.getViewIssueAction(refName, visibility.viewIssue, target), - { - title: 'Create Pull Request', - visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' && - (this.gitRepos[this.currentRepo].pullRequestConfig!.sourceRemote === remote || this.gitRepos[this.currentRepo].pullRequestConfig!.destRemote === remote), - onClick: () => { - const config = this.gitRepos[this.currentRepo].pullRequestConfig; - if (config === null) return; - const isDestRemote = config.destRemote === remote; - runAction({ - command: 'createPullRequest', - repo: this.currentRepo, - config: config, - sourceRemote: isDestRemote ? config.destRemote! : config.sourceRemote, - sourceOwner: isDestRemote ? config.destOwner : config.sourceOwner, - sourceRepo: isDestRemote ? config.destRepo : config.sourceRepo, - sourceBranch: branchName, - push: false - }, 'Creating Pull Request'); - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); - } - }, - { - title: 'Select in Branches Dropdown', - visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.selectOption(prefixedRefName) - }, - { - title: 'Unselect in Branches Dropdown', - visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, - onClick: () => this.branchDropdown.unselectOption(prefixedRefName) - } - ], [ - { - title: 'Copy Branch Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); - } - } - ]]; - } - - private getStashContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { - const hash = target.hash, selector = target.ref, visibility = this.config.contextMenuActionsVisibility.stash; - return [[ - { - title: 'Apply Stash' + ELLIPSIS, - visible: visibility.apply, - onClick: () => { - dialog.showForm('Are you sure you want to apply the stash ' + escapeHtml(selector.substring(5)) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Reinstate Index', - value: this.config.dialogDefaults.applyStash.reinstateIndex, - info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' - }], 'Yes, apply stash', (values) => { - runAction({ command: 'applyStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Applying Stash'); - }, target); - } - }, { - title: 'Create Branch from Stash' + ELLIPSIS, - visible: visibility.createBranch, - onClick: () => { - dialog.showRefInput('Create a branch from stash ' + escapeHtml(selector.substring(5)) + ' with the name:', '', 'Create Branch', (branchName) => { - runAction({ command: 'branchFromStash', repo: this.currentRepo, selector: selector, branchName: branchName }, 'Creating Branch'); - }, target); - } - }, { - title: 'Pop Stash' + ELLIPSIS, - visible: visibility.pop, - onClick: () => { - dialog.showForm('Are you sure you want to pop the stash ' + escapeHtml(selector.substring(5)) + '?', [{ - type: DialogInputType.Checkbox, - name: 'Reinstate Index', - value: this.config.dialogDefaults.popStash.reinstateIndex, - info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' - }], 'Yes, pop stash', (values) => { - runAction({ command: 'popStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Popping Stash'); - }, target); - } - }, { - title: 'Drop Stash' + ELLIPSIS, - visible: visibility.drop, - onClick: () => { - dialog.showConfirmation('Are you sure you want to drop the stash ' + escapeHtml(selector.substring(5)) + '?', 'Yes, drop', () => { - runAction({ command: 'dropStash', repo: this.currentRepo, selector: selector }, 'Dropping Stash'); - }, target); - } - } - ], [ - { - title: 'Copy Stash Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Stash Name', data: selector }); - } - }, { - title: 'Copy Stash Hash to Clipboard', - visible: visibility.copyHash, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Stash Hash', data: hash }); - } - } - ]]; - } - - private getTagContextMenuActions(isAnnotated: boolean, target: DialogTarget & RefTarget): ContextMenuActions { - const hash = target.hash, tagName = target.ref, visibility = this.config.contextMenuActionsVisibility.tag; - return [[ - { - title: 'View Details', - visible: visibility.viewDetails && isAnnotated, - onClick: () => { - runAction({ command: 'tagDetails', repo: this.currentRepo, tagName: tagName, commitHash: hash }, 'Retrieving Tag Details'); - } - }, { - title: 'Delete Tag' + ELLIPSIS, - visible: visibility.delete, - onClick: () => { - let message = 'Are you sure you want to delete the tag ' + escapeHtml(tagName) + '?'; - if (this.gitRemotes.length > 1) { - let options = [{ name: 'Don\'t delete on any remote', value: '-1' }]; - this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); - dialog.showSelect(message + '
    Do you also want to delete the tag on a remote:', '-1', options, 'Yes, delete', remoteIndex => { - this.deleteTagAction(tagName, remoteIndex !== '-1' ? this.gitRemotes[parseInt(remoteIndex)] : null); - }, target); - } else if (this.gitRemotes.length === 1) { - dialog.showCheckbox(message, 'Also delete on remote', false, 'Yes, delete', deleteOnRemote => { - this.deleteTagAction(tagName, deleteOnRemote ? this.gitRemotes[0] : null); - }, target); - } else { - dialog.showConfirmation(message, 'Yes, delete', () => { - this.deleteTagAction(tagName, null); - }, target); - } - } - }, { - title: 'Push Tag' + ELLIPSIS, - visible: visibility.push && this.gitRemotes.length > 0, - onClick: () => { - const runPushTagAction = (remotes: string[]) => { - runAction({ - command: 'pushTag', - repo: this.currentRepo, - tagName: tagName, - remotes: remotes, - commitHash: hash, - skipRemoteCheck: globalState.pushTagSkipRemoteCheck - }, 'Pushing Tag'); - }; - - if (this.gitRemotes.length === 1) { - dialog.showConfirmation('Are you sure you want to push the tag ' + escapeHtml(tagName) + ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '?', 'Yes, push', () => { - runPushTagAction([this.gitRemotes[0]]); - }, target); - } else if (this.gitRemotes.length > 1) { - const defaults = [this.getPushRemote()]; - const options = this.gitRemotes.map((remote) => ({ name: remote, value: remote })); - dialog.showMultiSelect('Are you sure you want to push the tag ' + escapeHtml(tagName) + '? Select the remote(s) to push the tag to:', defaults, options, 'Yes, push', (remotes) => { - runPushTagAction(remotes); - }, target); - } - } - } - ], [ - { - title: 'Create Archive', - visible: visibility.createArchive, - onClick: () => { - runAction({ command: 'createArchive', repo: this.currentRepo, ref: tagName }, 'Creating Archive'); - } - }, - { - title: 'Copy Tag Name to Clipboard', - visible: visibility.copyName, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'Tag Name', data: tagName }); - } - } - ]]; - } - - private getUncommittedChangesContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { - let visibility = this.config.contextMenuActionsVisibility.uncommittedChanges; - return [[ - { - title: 'Stash uncommitted changes' + ELLIPSIS, - visible: visibility.stash, - onClick: () => { - dialog.showForm('Are you sure you want to stash the uncommitted changes?', [ - { type: DialogInputType.Text, name: 'Message', default: '', placeholder: 'Optional' }, - { type: DialogInputType.Checkbox, name: 'Include Untracked', value: this.config.dialogDefaults.stashUncommittedChanges.includeUntracked, info: 'Include all untracked files in the stash, and then clean them from the working directory.' } - ], 'Yes, stash', (values) => { - runAction({ command: 'pushStash', repo: this.currentRepo, message: values[0], includeUntracked: values[1] }, 'Stashing uncommitted changes'); - }, target); - } - } - ], [ - { - title: 'Reset uncommitted changes' + ELLIPSIS, - visible: visibility.reset, - onClick: () => { - dialog.showSelect('Are you sure you want to reset the uncommitted changes to HEAD?', this.config.dialogDefaults.resetUncommitted.mode, [ - { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, - { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } - ], 'Yes, reset', (mode) => { - runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: 'HEAD', resetMode: mode }, 'Resetting uncommitted changes'); - }, target); - } - }, { - title: 'Clean untracked files' + ELLIPSIS, - visible: visibility.clean, - onClick: () => { - dialog.showCheckbox('Are you sure you want to clean all untracked files?', 'Clean untracked directories', true, 'Yes, clean', directories => { - runAction({ command: 'cleanUntrackedFiles', repo: this.currentRepo, directories: directories }, 'Cleaning untracked files'); - }, target); - } - } - ], [ - { - title: 'Open Source Control View', - visible: visibility.openSourceControlView, - onClick: () => { - sendMessage({ command: 'viewScm' }); - } - } - ]]; - } - - private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction { - const issueLinks: { url: string, displayText: string }[] = []; - - let issueLinking: IssueLinking | null, match: RegExpExecArray | null; - if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) { - issueLinking.regexp.lastIndex = 0; - while (match = issueLinking.regexp.exec(refName)) { - if (match[0].length === 0) break; - issueLinks.push({ - url: generateIssueLinkFromMatch(match, issueLinking), - displayText: match[0] - }); - } - } - - return { - title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''), - visible: issueLinks.length > 0, - onClick: () => { - if (issueLinks.length > 1) { - dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => { - sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url }); - }, target); - } else if (issueLinks.length === 1) { - sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url }); - } - } - }; - } - - - /* Actions */ - - private addTagAction(hash: string, initialName: string, initialType: GG.TagType, initialMessage: string, initialPushToRemote: string | null, target: DialogTarget & CommitTarget, isInitialLoad: boolean = true) { - let mostRecentTagsIndex = -1; - for (let i = 0; i < this.commits.length; i++) { - if (this.commits[i].tags.length > 0 && (mostRecentTagsIndex === -1 || this.commits[i].date > this.commits[mostRecentTagsIndex].date)) { - mostRecentTagsIndex = i; - } - } - const mostRecentTags = mostRecentTagsIndex > -1 ? this.commits[mostRecentTagsIndex].tags.map((tag) => '"' + tag.name + '"') : []; - - const inputs: DialogInput[] = [ - { type: DialogInputType.TextRef, name: 'Name', default: initialName, info: mostRecentTags.length > 0 ? 'The most recent tag' + (mostRecentTags.length > 1 ? 's' : '') + ' in the loaded commits ' + (mostRecentTags.length > 1 ? 'are' : 'is') + ' ' + formatCommaSeparatedList(mostRecentTags) + '.' : undefined }, - { type: DialogInputType.Select, name: 'Type', default: initialType === GG.TagType.Annotated ? 'annotated' : 'lightweight', options: [{ name: 'Annotated', value: 'annotated' }, { name: 'Lightweight', value: 'lightweight' }] }, - { type: DialogInputType.Text, name: 'Message', default: initialMessage, placeholder: 'Optional', info: 'A message can only be added to an annotated tag.' } - ]; - if (this.gitRemotes.length > 1) { - const options = [{ name: 'Don\'t push', value: '-1' }]; - this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); - const defaultOption = initialPushToRemote !== null - ? this.gitRemotes.indexOf(initialPushToRemote) - : isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote - ? this.gitRemotes.indexOf(this.getPushRemote()) - : -1; - inputs.push({ type: DialogInputType.Select, name: 'Push to remote', options: options, default: defaultOption.toString(), info: 'Once this tag has been added, push it to this remote.' }); - } else if (this.gitRemotes.length === 1) { - const defaultValue = initialPushToRemote !== null || (isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote); - inputs.push({ type: DialogInputType.Checkbox, name: 'Push to remote', value: defaultValue, info: 'Once this tag has been added, push it to the repositories remote.' }); - } - - dialog.showForm('Add tag to commit ' + abbrevCommit(hash) + ':', inputs, 'Add Tag', (values) => { - const tagName = values[0]; - const type = values[1] === 'annotated' ? GG.TagType.Annotated : GG.TagType.Lightweight; - const message = values[2]; - const pushToRemote = this.gitRemotes.length > 1 && values[3] !== '-1' - ? this.gitRemotes[parseInt(values[3])] - : this.gitRemotes.length === 1 && values[3] - ? this.gitRemotes[0] - : null; - - const runAddTagAction = (force: boolean) => { - runAction({ - command: 'addTag', - repo: this.currentRepo, - tagName: tagName, - commitHash: hash, - type: type, - message: message, - pushToRemote: pushToRemote, - pushSkipRemoteCheck: globalState.pushTagSkipRemoteCheck, - force: force - }, 'Adding Tag'); - }; - - if (this.gitTags.includes(tagName)) { - dialog.showTwoButtons('A tag named ' + escapeHtml(tagName) + ' already exists, do you want to replace it with this new tag?', 'Yes, replace the existing tag', () => { - runAddTagAction(true); - }, 'No, choose another tag name', () => { - this.addTagAction(hash, tagName, type, message, pushToRemote, target, false); - }, target); - } else { - runAddTagAction(false); - } - }, target); - } - - private checkoutBranchAction(refName: string, remote: string | null, prefillName: string | null, target: DialogTarget & (CommitTarget | RefTarget)) { - if (remote !== null) { - dialog.showRefInput('Enter the name of the new branch you would like to create when checking out ' + escapeHtml(refName) + ':', (prefillName !== null ? prefillName : (remote !== '' ? refName.substring(remote.length + 1) : refName)), 'Checkout Branch', newBranch => { - if (this.gitBranches.includes(newBranch)) { - const canPullFromRemote = remote !== ''; - dialog.showTwoButtons('The name ' + escapeHtml(newBranch) + ' is already used by another branch:', 'Choose another branch name', () => { - this.checkoutBranchAction(refName, remote, newBranch, target); - }, 'Checkout the existing branch' + (canPullFromRemote ? ' & pull changes' : ''), () => { - runAction({ - command: 'checkoutBranch', - repo: this.currentRepo, - branchName: newBranch, - remoteBranch: null, - pullAfterwards: canPullFromRemote - ? { - branchName: refName.substring(remote.length + 1), - remote: remote, - createNewCommit: this.config.dialogDefaults.pullBranch.noFastForward, - squash: this.config.dialogDefaults.pullBranch.squash - } - : null - }, 'Checking out Branch' + (canPullFromRemote ? ' & Pulling Changes' : '')); - }, target); - } else { - runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: newBranch, remoteBranch: refName, pullAfterwards: null }, 'Checking out Branch'); - } - }, target); - } else { - runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: refName, remoteBranch: null, pullAfterwards: null }, 'Checking out Branch'); - } - } - - private createBranchAction(hash: string, initialName: string, initialCheckOut: boolean, target: DialogTarget & CommitTarget) { - dialog.showForm('Create branch at commit ' + abbrevCommit(hash) + ':', [ - { type: DialogInputType.TextRef, name: 'Name', default: initialName }, - { type: DialogInputType.Checkbox, name: 'Check out', value: initialCheckOut } - ], 'Create Branch', (values) => { - const branchName = values[0], checkOut = values[1]; - if (this.gitBranches.includes(branchName)) { - dialog.showTwoButtons('A branch named ' + escapeHtml(branchName) + ' already exists, do you want to replace it with this new branch?', 'Yes, replace the existing branch', () => { - runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: true }, 'Creating Branch'); - }, 'No, choose another branch name', () => { - this.createBranchAction(hash, branchName, checkOut, target); - }, target); - } else { - runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: false }, 'Creating Branch'); - } - }, target); - } - - private deleteTagAction(refName: string, deleteOnRemote: string | null) { - runAction({ command: 'deleteTag', repo: this.currentRepo, tagName: refName, deleteOnRemote: deleteOnRemote }, 'Deleting Tag'); - } - - private fetchFromRemotesAction() { - runAction({ command: 'fetch', repo: this.currentRepo, name: null, prune: this.config.fetchAndPrune, pruneTags: this.config.fetchAndPruneTags }, 'Fetching from Remote(s)'); - } - - private mergeAction(obj: string, name: string, actionOn: GG.MergeActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { - dialog.showForm('Are you sure you want to merge ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '?', [ - { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.merge.noFastForward }, - { type: DialogInputType.Checkbox, name: 'Allow unrelated histories', value: this.config.dialogDefaults.merge.allowUnrelatedHistories, info: 'Allow merging branches from two completely different repositories or branches.' }, - { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.merge.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this ' + actionOn.toLowerCase() + '.' }, - { type: DialogInputType.Checkbox, name: 'No Commit', value: this.config.dialogDefaults.merge.noCommit, info: 'The changes of the merge will be staged but not committed, so that you can review and/or modify the merge result before committing.' } - ], 'Yes, merge', (values) => { - runAction({ command: 'merge', repo: this.currentRepo, obj: obj, actionOn: actionOn, createNewCommit: values[0], allowUnrelatedHistories: values[1], squash: values[2], noCommit: values[3] }, 'Merging ' + actionOn); - }, target); - } - - private rebaseAction(obj: string, name: string, actionOn: GG.RebaseActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { - dialog.showForm('Are you sure you want to rebase ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' on ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + '?', [ - { type: DialogInputType.Checkbox, name: 'Interactive Rebase (launch in new Terminal)', value: this.config.dialogDefaults.rebase.interactive }, - { type: DialogInputType.Checkbox, name: 'Ignore Date', value: this.config.dialogDefaults.rebase.ignoreDate, info: 'Only applicable to a non-interactive rebase.' } - ], 'Yes, rebase', (values) => { - let interactive = values[0]; - runAction({ command: 'rebase', repo: this.currentRepo, obj: obj, actionOn: actionOn, ignoreDate: values[1], interactive: interactive }, interactive ? 'Launching Interactive Rebase' : 'Rebasing on ' + actionOn); - }, target); - } - - - /* Table Utils */ - - private makeTableResizable() { - let colHeadersElem = document.getElementById('tableColHeaders')!, cols = >document.getElementsByClassName('tableColHeader'); - let columnWidths: GG.ColumnWidth[], mouseX = -1, col = -1, colIndex = -1; - - const makeTableFixedLayout = () => { - cols[0].style.width = columnWidths[0] + 'px'; - cols[0].style.padding = ''; - for (let i = 2; i < cols.length; i++) { - cols[i].style.width = columnWidths[parseInt(cols[i].dataset.col!)] + 'px'; - } - this.tableElem.className = 'fixedLayout'; - this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); - this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); - }; - - for (let i = 0; i < cols.length; i++) { - let col = parseInt(cols[i].dataset.col!); - cols[i].innerHTML += (i > 0 ? '' : '') + (i < cols.length - 1 ? '' : ''); - } - - let cWidths = this.gitRepos[this.currentRepo].columnWidths; - if (cWidths === null) { // Initialise auto column layout if it is the first time viewing the repo. - let defaults = this.config.defaultColumnVisibility; - columnWidths = [COLUMN_AUTO, COLUMN_AUTO, defaults.date ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.author ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.commit ? COLUMN_AUTO : COLUMN_HIDDEN]; - this.saveColumnWidths(columnWidths); - } else { - columnWidths = [cWidths[0], COLUMN_AUTO, cWidths[1], cWidths[2], cWidths[3]]; - } - - if (columnWidths[0] !== COLUMN_AUTO) { - // Table should have fixed layout - makeTableFixedLayout(); - } else { - // Table should have automatic layout - this.tableElem.className = 'autoLayout'; - - let colWidth = cols[0].offsetWidth, graphWidth = this.graph.getContentWidth(); - let maxWidth = Math.round(this.viewElem.clientWidth * 0.333); - if (Math.max(graphWidth, colWidth) > maxWidth) { - this.graph.limitMaxWidth(maxWidth); - graphWidth = maxWidth; - this.tableElem.className += ' limitGraphWidth'; - this.tableElem.style.setProperty(CSS_PROP_LIMIT_GRAPH_WIDTH, maxWidth + 'px'); - } else { - this.graph.limitMaxWidth(-1); - this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); - } - - if (colWidth < Math.max(graphWidth, 64)) { - cols[0].style.padding = '6px ' + Math.floor((Math.max(graphWidth, 64) - (colWidth - COLUMN_LEFT_RIGHT_PADDING)) / 2) + 'px'; - } - } - - const processResizingColumn: EventListener = (e) => { - if (col > -1) { - let mouseEvent = e; - let mouseDeltaX = mouseEvent.clientX - mouseX; - - if (col === 0) { - if (columnWidths[0] + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -columnWidths[0] + COLUMN_MIN_WIDTH; - if (cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - COLUMN_MIN_WIDTH; - columnWidths[0] += mouseDeltaX; - cols[0].style.width = columnWidths[0] + 'px'; - this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); - } else { - let colWidth = col !== 1 ? columnWidths[col] : cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING; - let nextCol = col + 1; - while (columnWidths[nextCol] === COLUMN_HIDDEN) nextCol++; - - if (colWidth + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -colWidth + COLUMN_MIN_WIDTH; - if (columnWidths[nextCol] - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = columnWidths[nextCol] - COLUMN_MIN_WIDTH; - if (col !== 1) { - columnWidths[col] += mouseDeltaX; - cols[colIndex].style.width = columnWidths[col] + 'px'; - } - columnWidths[nextCol] -= mouseDeltaX; - cols[colIndex + 1].style.width = columnWidths[nextCol] + 'px'; - } - mouseX = mouseEvent.clientX; - } - }; - const stopResizingColumn: EventListener = () => { - if (col > -1) { - col = -1; - colIndex = -1; - mouseX = -1; - eventOverlay.remove(); - this.saveColumnWidths(columnWidths); - } - }; - - addListenerToClass('resizeCol', 'mousedown', (e) => { - if (e.target === null) return; - col = parseInt((e.target).dataset.col!); - while (columnWidths[col] === COLUMN_HIDDEN) col--; - mouseX = (e).clientX; - - let isAuto = columnWidths[0] === COLUMN_AUTO; - for (let i = 0; i < cols.length; i++) { - let curCol = parseInt(cols[i].dataset.col!); - if (isAuto && curCol !== 1) columnWidths[curCol] = cols[i].clientWidth - COLUMN_LEFT_RIGHT_PADDING; - if (curCol === col) colIndex = i; - } - if (isAuto) makeTableFixedLayout(); - eventOverlay.create('colResize', processResizingColumn, stopResizingColumn); - }); - - colHeadersElem.addEventListener('contextmenu', (e: MouseEvent) => { - handledEvent(e); - - const toggleColumnState = (col: number, defaultWidth: number) => { - columnWidths[col] = columnWidths[col] !== COLUMN_HIDDEN ? COLUMN_HIDDEN : columnWidths[0] === COLUMN_AUTO ? COLUMN_AUTO : defaultWidth - COLUMN_LEFT_RIGHT_PADDING; - this.saveColumnWidths(columnWidths); - this.render(); - }; - - const commitOrdering = getCommitOrdering(this.gitRepos[this.currentRepo].commitOrdering); - const changeCommitOrdering = (repoCommitOrdering: GG.RepoCommitOrdering) => { - this.saveRepoStateValue(this.currentRepo, 'commitOrdering', repoCommitOrdering); - this.refresh(true); - }; - - contextMenu.show([ - [ - { - title: 'Date', - visible: true, - checked: columnWidths[2] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(2, 128) - }, - { - title: 'Author', - visible: true, - checked: columnWidths[3] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(3, 128) - }, - { - title: 'Commit', - visible: true, - checked: columnWidths[4] !== COLUMN_HIDDEN, - onClick: () => toggleColumnState(4, 80) - } - ], - [ - { - title: 'Commit Timestamp Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.Date, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Date) - }, - { - title: 'Author Timestamp Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.AuthorDate, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.AuthorDate) - }, - { - title: 'Topological Order', - visible: true, - checked: commitOrdering === GG.CommitOrdering.Topological, - onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Topological) - } - ] - ], true, null, e, this.viewElem); - }); - } - - public getColumnVisibility() { - let colWidths = this.gitRepos[this.currentRepo].columnWidths; - if (colWidths !== null) { - return { date: colWidths[1] !== COLUMN_HIDDEN, author: colWidths[2] !== COLUMN_HIDDEN, commit: colWidths[3] !== COLUMN_HIDDEN }; - } else { - let defaults = this.config.defaultColumnVisibility; - return { date: defaults.date, author: defaults.author, commit: defaults.commit }; - } - } - - private getNumColumns() { - let colVisibility = this.getColumnVisibility(); - return 2 + (colVisibility.date ? 1 : 0) + (colVisibility.author ? 1 : 0) + (colVisibility.commit ? 1 : 0); - } - - /** - * Scroll the view to the previous or next stash. - * @param next TRUE => Jump to the next stash, FALSE => Jump to the previous stash. - */ - private scrollToStash(next: boolean) { - const stashCommits = this.commits.filter((commit) => commit.stash !== null); - if (stashCommits.length > 0) { - const curTime = (new Date()).getTime(); - if (this.lastScrollToStash.time < curTime - 5000) { - // Reset the lastScrollToStash hash if it was more than 5 seconds ago - this.lastScrollToStash.hash = null; - } - - const lastScrollToStashCommitIndex = this.lastScrollToStash.hash !== null - ? stashCommits.findIndex((commit) => commit.hash === this.lastScrollToStash.hash) - : -1; - let scrollToStashCommitIndex = lastScrollToStashCommitIndex + (next ? 1 : -1); - if (scrollToStashCommitIndex >= stashCommits.length) { - scrollToStashCommitIndex = 0; - } else if (scrollToStashCommitIndex < 0) { - scrollToStashCommitIndex = stashCommits.length - 1; - } - this.scrollToCommit(stashCommits[scrollToStashCommitIndex].hash, true, true); - this.lastScrollToStash.time = curTime; - this.lastScrollToStash.hash = stashCommits[scrollToStashCommitIndex].hash; - } - } - - /** - * Scroll the view to a commit (if it exists). - * @param hash The hash of the commit to scroll to. - * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. - * @param flash Should the commit flash after it has been scrolled to. - * @param openDetails Open details of the specified commit. - * @param persistently Persistently find the commit even if it is not exists. - */ - public scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { - this.scrollToCommitArgs.persistently = false; - - const elem = findCommitElemWithId(getCommitElems(), this.getCommitId(hash)); - if (elem === null) { - if (persistently) { - // Scroll to the last loaded commit for trigger loadMoreCommits() - const commits = document.getElementsByClassName('commit'); - if (commits.length === 0) { - return; - } - const lastCommit = commits[commits.length - 1]; - lastCommit.scrollIntoView(); - - this.scrollToCommitArgs = { - hash: hash, - alwaysCenterCommit: alwaysCenterCommit, - flash: flash, - openDetails: openDetails, - persistently: persistently - }; - } - // Do nothing - return; - } - let elemTop = this.controlsElem.clientHeight + elem.offsetTop; - if (alwaysCenterCommit || elemTop - 8 < this.viewElem.scrollTop || elemTop + 32 - this.viewElem.clientHeight > this.viewElem.scrollTop) { - this.viewElem.scroll(0, this.controlsElem.clientHeight + elem.offsetTop + 12 - this.viewElem.clientHeight / 2); - } - - if (flash && !elem.classList.contains('flash')) { - elem.classList.add('flash'); - setTimeout(() => { - elem.classList.remove('flash'); - }, 850); - } - - if (openDetails) { - this.loadCommitDetails(elem); - } - } - - private loadMoreCommits() { - this.footerElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; - this.maxCommits += this.config.loadMoreCommits; - this.saveState(); - this.requestLoadRepoInfoAndCommits(false, true); - } - - private alignTableHeaderToControls() { - if (!this.tableColHeadersElem) { - return; - } - } - - - /* Observers */ - - private observeWindowSizeChanges() { - let windowWidth = window.outerWidth, windowHeight = window.outerHeight; - window.addEventListener('resize', () => { - if (windowWidth === window.outerWidth && windowHeight === window.outerHeight) { - this.renderGraph(); - } else { - windowWidth = window.outerWidth; - windowHeight = window.outerHeight; - } - - if (this.config.stickyHeader) { - this.alignTableHeaderToControls(); - } - }); - } - - private observeWebviewStyleChanges() { - let fontFamily = getVSCodeStyle(CSS_PROP_FONT_FAMILY), - editorFontFamily = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), - findMatchColour = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), - selectionBackgroundColor = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); - - const setFlashColour = (colour: string) => { - document.body.style.setProperty('--git-graph-flashPrimary', modifyColourOpacity(colour, 0.7)); - document.body.style.setProperty('--git-graph-flashSecondary', modifyColourOpacity(colour, 0.5)); - }; - const setSelectionBackgroundColorExists = () => { - alterClass(document.body, 'selection-background-color-exists', selectionBackgroundColor); - }; - - this.findWidget.setColour(findMatchColour); - setFlashColour(findMatchColour); - setSelectionBackgroundColorExists(); - - (new MutationObserver(() => { - let ff = getVSCodeStyle(CSS_PROP_FONT_FAMILY), - eff = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), - fmc = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), - sbc = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); - - if (ff !== fontFamily || eff !== editorFontFamily) { - fontFamily = ff; - editorFontFamily = eff; - this.repoDropdown.refresh(); - this.branchDropdown.refresh(); - this.authorDropdown.refresh(); - } - if (fmc !== findMatchColour) { - findMatchColour = fmc; - this.findWidget.setColour(findMatchColour); - setFlashColour(findMatchColour); - } - if (selectionBackgroundColor !== sbc) { - selectionBackgroundColor = sbc; - setSelectionBackgroundColorExists(); - } - })).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); - } - - private observeViewScroll() { - let active = this.viewElem.scrollTop > 0, timeout: NodeJS.Timer | null = null; - this.viewElem.addEventListener('scroll', () => { - const scrollTop = this.viewElem.scrollTop; - if (active !== scrollTop > 0) { - active = scrollTop > 0; - } - - if (this.config.loadMoreCommitsAutomatically && this.moreCommitsAvailable && !this.currentRepoRefreshState.inProgress) { - const viewHeight = this.viewElem.clientHeight, contentHeight = this.viewElem.scrollHeight; - if (scrollTop > 0 && viewHeight > 0 && contentHeight > 0 && (scrollTop + viewHeight) >= contentHeight - 25) { - // If the user has scrolled such that the bottom of the visible view is within 25px of the end of the content, load more commits. - this.loadMoreCommits(); - } - } - - if (timeout !== null) clearTimeout(timeout); - timeout = setTimeout(() => { - this.scrollTop = scrollTop; - this.saveState(); - timeout = null; - }, 250); - }); - } - - private observeKeyboardEvents() { - document.addEventListener('keydown', (e) => { - if (contextMenu.isOpen()) { - if (e.key === 'Escape') { - contextMenu.close(); - handledEvent(e); - } - } else if (dialog.isOpen()) { - if (e.key === 'Escape') { - dialog.close(); - handledEvent(e); - } else if (e.keyCode ? e.keyCode === 13 : e.key === 'Enter') { - // Use keyCode === 13 to detect 'Enter' events if available (for compatibility with IME Keyboards used by Chinese / Japanese / Korean users) - dialog.submit(); - handledEvent(e); - } - } else if (this.expandedCommit !== null && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - const curHashIndex = this.commitLookup[this.expandedCommit.commitHash]; - let newHashIndex = -1; - - if (e.ctrlKey || e.metaKey) { - // Up / Down navigates according to the order of commits on the branch - if (e.shiftKey) { - // Follow commits on alternative branches when possible - if (e.key === 'ArrowUp') { - newHashIndex = this.graph.getAlternativeChildIndex(curHashIndex); - } else if (e.key === 'ArrowDown') { - newHashIndex = this.graph.getAlternativeParentIndex(curHashIndex); - } - } else { - // Follow commits on the same branch - if (e.key === 'ArrowUp') { - newHashIndex = this.graph.getFirstChildIndex(curHashIndex); - } else if (e.key === 'ArrowDown') { - newHashIndex = this.graph.getFirstParentIndex(curHashIndex); - } - } - } else { - // Up / Down navigates according to the order of commits in the table - if (e.key === 'ArrowUp' && curHashIndex > 0) { - newHashIndex = curHashIndex - 1; - } else if (e.key === 'ArrowDown' && curHashIndex < this.commits.length - 1) { - newHashIndex = curHashIndex + 1; - } - } - - if (newHashIndex > -1) { - handledEvent(e); - const elem = findCommitElemWithId(getCommitElems(), newHashIndex); - if (elem !== null) this.loadCommitDetails(elem); - } - } else if (e.key && (e.ctrlKey || e.metaKey)) { - const key = e.key.toLowerCase(), keybindings = this.config.keybindings; - if (key === keybindings.scrollToStash) { - this.scrollToStash(!e.shiftKey); - handledEvent(e); - } else if (!e.shiftKey) { - if (key === keybindings.refresh) { - this.refresh(true, true); - handledEvent(e); - } else if (key === keybindings.find) { - this.findWidget.show(true); - handledEvent(e); - } else if (key === keybindings.scrollToHead && this.commitHead !== null) { - this.scrollToCommit(this.commitHead, true, true); - handledEvent(e); - } - } - } else if (e.key === 'Escape') { - if (this.repoDropdown.isOpen()) { - this.repoDropdown.close(); - handledEvent(e); - } else if (this.branchDropdown.isOpen()) { - this.branchDropdown.close(); - handledEvent(e); - } else if (this.authorDropdown.isOpen()) { - this.authorDropdown.close(); - handledEvent(e); - } else if (this.settingsWidget.isVisible()) { - this.settingsWidget.close(); - handledEvent(e); - } else if (this.findWidget.isVisible()) { - this.findWidget.close(); - handledEvent(e); - } else if (this.expandedCommit !== null) { - this.closeCommitDetails(true); - handledEvent(e); - } - } - }); - } - - private observeUrls() { - const followInternalLink = (e: MouseEvent) => { - if (e.target !== null && isInternalUrlElem(e.target)) { - const value = unescapeHtml((e.target).dataset.value!); - switch ((e.target).dataset.type!) { - case 'commit': - if (typeof this.commitLookup[value] === 'number' && (this.expandedCommit === null || this.expandedCommit.commitHash !== value || this.expandedCommit.compareWithHash !== null)) { - const elem = findCommitElemWithId(getCommitElems(), this.commitLookup[value]); - if (elem !== null) this.loadCommitDetails(elem); - } - break; - } - } - }; - - document.body.addEventListener('click', followInternalLink); - - document.body.addEventListener('contextmenu', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - - const isExternalUrl = isExternalUrlElem(eventTarget), isInternalUrl = isInternalUrlElem(eventTarget); - if (isExternalUrl || isInternalUrl) { - const viewElem: HTMLElement | null = eventTarget.closest('#view'); - let eventElem: HTMLElement | null; - - let target: (ContextMenuTarget & CommitTarget) | RepoTarget, isInDialog = false; - if (this.expandedCommit !== null && eventTarget.closest('#cdv') !== null) { - // URL is in the Commit Details View - target = { - type: TargetType.CommitDetailsView, - hash: this.expandedCommit.commitHash, - index: this.commitLookup[this.expandedCommit.commitHash], - elem: eventTarget - }; - GitGraphView.closeCdvContextMenuIfOpen(this.expandedCommit); - this.expandedCommit.contextMenuOpen.summary = true; - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // URL is in the Commits - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - target = { - type: TargetType.Commit, - hash: commit.hash, - index: parseInt(eventElem.dataset.id!), - elem: eventTarget - }; - } else { - // URL is in a dialog - target = { - type: TargetType.Repo - }; - isInDialog = true; - } - - handledEvent(e); - contextMenu.show([ - [ - { - title: 'Open URL', - visible: isExternalUrl, - onClick: () => { - sendMessage({ command: 'openExternalUrl', url: (eventTarget).href }); - } - }, - { - title: 'Follow Internal Link', - visible: isInternalUrl, - onClick: () => followInternalLink(e) - }, - { - title: 'Copy URL to Clipboard', - visible: isExternalUrl, - onClick: () => { - sendMessage({ command: 'copyToClipboard', type: 'External URL', data: (eventTarget).href }); - } - } - ] - ], false, target, e, viewElem || document.body, () => { - if (target.type === TargetType.CommitDetailsView && this.expandedCommit !== null) { - this.expandedCommit.contextMenuOpen.summary = false; - } - }, isInDialog ? 'dialogContextMenu' : null); - } - }); - } - - private observeTableEvents() { - - // Register Click Event Handler - this.tableElem.addEventListener('click', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was clicked - e.stopPropagation(); - if (contextMenu.isOpen()) { - contextMenu.close(); - } - - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // .commit was clicked - if (this.expandedCommit !== null) { - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - - if (this.expandedCommit.commitHash === commit.hash) { - this.closeCommitDetails(true); - } else if ((e).ctrlKey || (e).metaKey) { - if (this.expandedCommit.compareWithHash === commit.hash) { - this.closeCommitComparison(true); - } else if (this.expandedCommit.commitElem !== null) { - this.loadCommitComparison(this.expandedCommit.commitElem, eventElem); - } - } else { - this.loadCommitDetails(eventElem); - } - } else { - this.loadCommitDetails(eventElem); - } - } - }); - - // Register Double Click Event Handler - this.tableElem.addEventListener('dblclick', (e: MouseEvent) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was double clicked - e.stopPropagation(); - closeDialogAndContextMenu(); - const commitElem = eventElem.closest('.commit')!; - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - if (eventElem.classList.contains(CLASS_REF_HEAD) || eventElem.classList.contains(CLASS_REF_REMOTE)) { - let sourceElem = eventElem.children[1]; - let refName = unescapeHtml(eventElem.dataset.name!), isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); - if (isHead && isRemoteCombinedWithHead) { - refName = unescapeHtml((eventTarget).dataset.fullref!); - sourceElem = eventTarget; - isHead = false; - } - - const target: ContextMenuTarget & DialogTarget & RefTarget = { - type: TargetType.Ref, - hash: commit.hash, - index: parseInt(commitElem.dataset.id!), - ref: refName, - elem: sourceElem - }; - - this.checkoutBranchAction(refName, isHead ? null : unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!), null, target); - } - } - }); - - // Register ContextMenu Event Handler - this.tableElem.addEventListener('contextmenu', (e: Event) => { - if (e.target === null) return; - const eventTarget = e.target; - if (isUrlElem(eventTarget)) return; - let eventElem: HTMLElement | null; - - if ((eventElem = eventTarget.closest('.gitRef')) !== null) { - // .gitRef was right clicked - handledEvent(e); - const commitElem = eventElem.closest('.commit')!; - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - const target: ContextMenuTarget & DialogTarget & RefTarget = { - type: TargetType.Ref, - hash: commit.hash, - index: parseInt(commitElem.dataset.id!), - ref: unescapeHtml(eventElem.dataset.name!), - elem: eventElem.children[1] - }; - - let actions: ContextMenuActions; - if (eventElem.classList.contains(CLASS_REF_STASH)) { - actions = this.getStashContextMenuActions(target); - } else if (eventElem.classList.contains(CLASS_REF_TAG)) { - actions = this.getTagContextMenuActions(eventElem.dataset.tagtype === 'annotated', target); - } else { - let isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); - if (isHead && isRemoteCombinedWithHead) { - target.ref = unescapeHtml((eventTarget).dataset.fullref!); - target.elem = eventTarget; - isHead = false; - } - if (isHead) { - actions = this.getBranchContextMenuActions(target); - } else { - const remote = unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!); - actions = this.getRemoteBranchContextMenuActions(remote, target); - } - } - - contextMenu.show(actions, false, target, e, this.viewElem); - - } else if ((eventElem = eventTarget.closest('.commit')) !== null) { - // .commit was right clicked - handledEvent(e); - const commit = this.getCommitOfElem(eventElem); - if (commit === null) return; - - const target: ContextMenuTarget & DialogTarget & CommitTarget = { - type: TargetType.Commit, - hash: commit.hash, - index: parseInt(eventElem.dataset.id!), - elem: eventElem - }; - - let actions: ContextMenuActions; - if (commit.hash === UNCOMMITTED) { - actions = this.getUncommittedChangesContextMenuActions(target); - } else if (commit.stash !== null) { - target.ref = commit.stash.selector; - actions = this.getStashContextMenuActions(target); - } else { - actions = this.getCommitContextMenuActions(target); - } - - contextMenu.show(actions, false, target, e, this.viewElem); - } - }); - } - - - /* Commit Details View */ - - public loadCommitDetails(commitElem: HTMLElement) { - const commit = this.getCommitOfElem(commitElem); - if (commit === null) return; - - this.closeCommitDetails(false); - this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, null, null); - commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - this.renderCommitDetailsView(false); - this.requestCommitDetails(commit.hash, false); - } - - public closeCommitDetails(saveAndRender: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - const elem = document.getElementById('cdv'), isDocked = this.isCdvDocked(); - if (elem !== null) { - elem.remove(); - } - if (isDocked) { - this.viewElem.style.bottom = '0px'; - } - if (expandedCommit.commitElem !== null) { - expandedCommit.commitElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - if (expandedCommit.compareWithElem !== null) { - expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - this.expandedCommit = null; - if (saveAndRender) { - this.saveState(); - if (!isDocked) { - this.renderGraph(); - } - } - } - - public showCommitDetails(commitDetails: GG.GitCommitDetails, fileTree: FileTreeFolder, avatar: string | null, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.commitHash !== commitDetails.hash || expandedCommit.compareWithHash !== null) return; - - if (!this.isCdvDocked()) { - const elem = document.getElementById('cdv'); - if (elem !== null) elem.remove(); - } - - expandedCommit.commitDetails = commitDetails; - if (haveFilesChanged(expandedCommit.fileChanges, commitDetails.fileChanges)) { - expandedCommit.fileChanges = commitDetails.fileChanges; - expandedCommit.fileTree = fileTree; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - } - expandedCommit.avatar = avatar; - expandedCommit.codeReview = codeReview; - if (!refresh) { - expandedCommit.lastViewedFile = lastViewedFile; - } - expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.loading = false; - this.saveState(); - - this.renderCommitDetailsView(refresh); - } - - public createFileTree(gitFiles: ReadonlyArray, codeReview: GG.CodeReview | null) { - let contents: FileTreeFolderContents = {}, i, j, path, absPath, cur: FileTreeFolder; - let files: FileTreeFolder = { type: 'folder', name: '', folderPath: '', contents: contents, open: true, reviewed: true }; - - for (i = 0; i < gitFiles.length; i++) { - cur = files; - path = gitFiles[i].newFilePath.split('/'); - absPath = this.currentRepo; - for (j = 0; j < path.length; j++) { - absPath += '/' + path[j]; - if (typeof this.gitRepos[absPath] !== 'undefined') { - if (typeof cur.contents[path[j]] === 'undefined') { - cur.contents[path[j]] = { type: 'repo', name: path[j], path: absPath }; - } - break; - } else if (j < path.length - 1) { - if (typeof cur.contents[path[j]] === 'undefined') { - contents = {}; - cur.contents[path[j]] = { type: 'folder', name: path[j], folderPath: absPath.substring(this.currentRepo.length + 1), contents: contents, open: true, reviewed: true }; - } - cur = cur.contents[path[j]]; - } else if (path[j] !== '') { - cur.contents[path[j]] = { type: 'file', name: path[j], index: i, reviewed: codeReview === null || !codeReview.remainingFiles.includes(gitFiles[i].newFilePath) }; - } - } - } - if (codeReview !== null) calcFileTreeFoldersReviewed(files); - return files; - } - - - /* Commit Comparison View */ - - private loadCommitComparison(commitElem: HTMLElement, compareWithElem: HTMLElement) { - const commit = this.getCommitOfElem(commitElem); - const compareWithCommit = this.getCommitOfElem(compareWithElem); - - if (commit !== null && compareWithCommit !== null) { - if (this.expandedCommit !== null) { - if (this.expandedCommit.commitHash !== commit.hash) { - this.closeCommitDetails(false); - } else if (this.expandedCommit.compareWithHash !== compareWithCommit.hash) { - this.closeCommitComparison(false); - } - } - - this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, compareWithCommit.hash, compareWithElem); - commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - this.renderCommitDetailsView(false); - this.requestCommitComparison(commit.hash, compareWithCommit.hash, false); - } - } - - public closeCommitComparison(saveAndRequestCommitDetails: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.compareWithHash === null) return; - - if (expandedCommit.compareWithElem !== null) { - expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); - } - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - if (saveAndRequestCommitDetails) { - if (expandedCommit.commitElem !== null) { - this.saveExpandedCommitLoading(expandedCommit.index, expandedCommit.commitHash, expandedCommit.commitElem, null, null); - this.renderCommitDetailsView(false); - this.requestCommitDetails(expandedCommit.commitHash, false); - } else { - this.closeCommitDetails(true); - } - } - } - - public showCommitComparison(commitHash: string, compareWithHash: string, fileChanges: ReadonlyArray, fileTree: FileTreeFolder, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.compareWithElem === null || expandedCommit.commitHash !== commitHash || expandedCommit.compareWithHash !== compareWithHash) return; - - if (haveFilesChanged(expandedCommit.fileChanges, fileChanges)) { - expandedCommit.fileChanges = fileChanges; - expandedCommit.fileTree = fileTree; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - } - expandedCommit.codeReview = codeReview; - if (!refresh) { - expandedCommit.lastViewedFile = lastViewedFile; - } - expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); - expandedCommit.loading = false; - this.saveState(); - - this.renderCommitDetailsView(refresh); - } - - - /* Render Commit Details / Comparison View */ - - private renderCommitDetailsView(refresh: boolean) { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.commitElem === null) return; - - let elem = document.getElementById('cdv'), html = '
    ', isDocked = this.isCdvDocked(); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const codeReviewPossible = !expandedCommit.loading && commitOrder.to !== UNCOMMITTED; - const externalDiffPossible = !expandedCommit.loading && (expandedCommit.compareWithHash !== null || this.commits[this.commitLookup[expandedCommit.commitHash]].parents.length > 0); - - if (elem === null) { - elem = document.createElement(isDocked ? 'div' : 'tr'); - elem.id = 'cdv'; - elem.className = isDocked ? 'docked' : 'inline'; - this.setCdvHeight(elem, isDocked); - if (isDocked) { - document.body.appendChild(elem); - } else { - insertAfter(elem, expandedCommit.commitElem); - } - } - - if (expandedCommit.loading) { - html += '
    ' + SVG_ICONS.loading + ' Loading ' + (expandedCommit.compareWithHash === null ? expandedCommit.commitHash !== UNCOMMITTED ? 'Commit Details' : 'Uncommitted Changes' : 'Commit Comparison') + ' ...
    '; - } else { - html += '
    '; - if (expandedCommit.compareWithHash === null) { - // Commit details should be shown - if (expandedCommit.commitHash !== UNCOMMITTED) { - const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { - commits: true, - emoji: true, - issueLinking: true, - markdown: this.config.markdown, - multiline: true, - urls: true - }); - const commitDetails = expandedCommit.commitDetails!; - const parents = commitDetails.parents.length > 0 - ? commitDetails.parents.map((parent) => { - const escapedParent = escapeHtml(parent); - return typeof this.commitLookup[parent] === 'number' - ? '' + escapedParent + '' - : escapedParent; - }).join(', ') - : 'None'; - html += '' - + 'Commit: ' + escapeHtml(commitDetails.hash) + '
    ' - + 'Parents: ' + parents + '
    ' - + 'Author: ' + escapeHtml(commitDetails.author) + (commitDetails.authorEmail !== '' ? ' <' + escapeHtml(commitDetails.authorEmail) + '>' : '') + '
    ' - + (commitDetails.authorDate !== commitDetails.committerDate ? 'Author Date: ' + formatLongDate(commitDetails.authorDate) + '
    ' : '') - + 'Committer: ' + escapeHtml(commitDetails.committer) + (commitDetails.committerEmail !== '' ? ' <' + escapeHtml(commitDetails.committerEmail) + '>' : '') + (commitDetails.signature !== null ? generateSignatureHtml(commitDetails.signature) : '') + '
    ' - + '' + (commitDetails.authorDate !== commitDetails.committerDate ? 'Committer ' : '') + 'Date: ' + formatLongDate(commitDetails.committerDate) - + '
    ' - + (expandedCommit.avatar !== null ? '' : '') - + '


    ' + textFormatter.format(commitDetails.body); - } else { - html += 'Displaying all uncommitted changes.'; - } - } else { - // Commit comparison should be shown - html += 'Displaying all changes from ' + commitOrder.from + ' to ' + (commitOrder.to !== UNCOMMITTED ? commitOrder.to : 'Uncommitted Changes') + '.'; - } - html += '
    ' + (!isDocked ? '
    ' + SVG_ICONS.collapse + '
    ' : '') + '
    ' + generateFileViewHtml(expandedCommit.fileTree!, expandedCommit.fileChanges!, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, this.getFileViewType(), commitOrder.to === UNCOMMITTED) + '
    '; - } - html += '
    ' + SVG_ICONS.close + '
    ' + - (codeReviewPossible ? '
    ' + SVG_ICONS.review + '
    ' : '') + - (!expandedCommit.loading ? '
    ' + SVG_ICONS.fileList + '
    ' + SVG_ICONS.fileTree + '
    ' + SVG_ICONS.collapseAll + '
    ' + SVG_ICONS.expandAll + '
    ' : '') + - (externalDiffPossible ? '
    ' + SVG_ICONS.linkExternal + '
    ' : '') + - '
    '; - - elem.innerHTML = isDocked ? html : '
    ' + html + '
    '; - this.setCdvDivider(); - this.setCdvHeight(elem, isDocked); - if (!isDocked) this.renderGraph(); - - if (!refresh) { - if (isDocked) { - let elemTop = this.controlsElem.clientHeight + expandedCommit.commitElem.offsetTop; - if (elemTop - 8 < this.viewElem.scrollTop) { - // Commit is above what is visible on screen - this.viewElem.scroll(0, elemTop - 8); - } else if (elemTop - this.viewElem.clientHeight + 32 > this.viewElem.scrollTop) { - // Commit is below what is visible on screen - this.viewElem.scroll(0, elemTop - this.viewElem.clientHeight + 32); - } - } else { - let elemTop = this.controlsElem.clientHeight + elem.offsetTop, cdvHeight = this.gitRepos[this.currentRepo].cdvHeight; - if (this.config.commitDetailsView.autoCenter) { - // Center Commit Detail View setting is enabled - // elemTop - commit height [24px] + (commit details view height + commit height [24px]) / 2 - (view height) / 2 - this.viewElem.scroll(0, elemTop - 12 + (cdvHeight - this.viewElem.clientHeight) / 2); - } else if (elemTop - 32 < this.viewElem.scrollTop) { - // Commit Detail View is opening above what is visible on screen - // elemTop - commit height [24px] - desired gap from top [8px] < view scroll offset - this.viewElem.scroll(0, elemTop - 32); - } else if (elemTop + cdvHeight - this.viewElem.clientHeight + 8 > this.viewElem.scrollTop) { - // Commit Detail View is opening below what is visible on screen - // elemTop + commit details view height + desired gap from bottom [8px] - view height > view scroll offset - this.viewElem.scroll(0, elemTop + cdvHeight - this.viewElem.clientHeight + 8); - } - } - } - - this.makeCdvResizable(); - document.getElementById('cdvClose')!.addEventListener('click', () => { - this.closeCommitDetails(true); - }); - - if (!expandedCommit.loading) { - this.makeCdvFileViewInteractive(); - this.renderCdvFileViewTypeBtns(); - this.renderCdvExternalDiffBtn(); - this.makeCdvDividerDraggable(); - - observeElemScroll('cdvSummary', expandedCommit.scrollTop.summary, (scrollTop) => { - if (this.expandedCommit === null) return; - this.expandedCommit.scrollTop.summary = scrollTop; - if (this.expandedCommit.contextMenuOpen.summary) { - this.expandedCommit.contextMenuOpen.summary = false; - contextMenu.close(); - } - }, () => this.saveState()); - - observeElemScroll('cdvFilesView', expandedCommit.scrollTop.fileView, (scrollTop) => { - if (this.expandedCommit === null) return; - this.expandedCommit.scrollTop.fileView = scrollTop; - if (this.expandedCommit.contextMenuOpen.fileView > -1) { - this.expandedCommit.contextMenuOpen.fileView = -1; - contextMenu.close(); - } - }, () => this.saveState()); - - document.getElementById('cdvFileViewTypeTree')!.addEventListener('click', () => { - this.changeFileViewType(GG.FileViewType.Tree); - }); - - document.getElementById('cdvFileViewTypeList')!.addEventListener('click', () => { - this.changeFileViewType(GG.FileViewType.List); - }); - document.getElementById('cdvCollapse')!.addEventListener('click', () => { - this.openFolders(false); - }); - document.getElementById('cdvExpand')!.addEventListener('click', () => { - this.openFolders(true); - }); - let cdvSummaryToggleBtn = document.getElementById('cdvSummaryToggleBtn'); - if (cdvSummaryToggleBtn !== null) cdvSummaryToggleBtn.addEventListener('click', () => { - this.gitRepos[this.currentRepo].isCdvSummaryHidden = !(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - this.saveRepoState(); - this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - }); - this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); - - if (codeReviewPossible) { - this.renderCodeReviewBtn(); - document.getElementById('cdvCodeReview')!.addEventListener('click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || e.target === null) return; - let sourceElem = (e.target).closest('#cdvCodeReview')!; - if (sourceElem.classList.contains(CLASS_ACTIVE)) { - sendMessage({ command: 'endCodeReview', repo: this.currentRepo, id: expandedCommit.codeReview!.id }); - this.endCodeReview(); - } else { - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const id = expandedCommit.compareWithHash !== null ? commitOrder.from + '-' + commitOrder.to : expandedCommit.commitHash; - sendMessage({ - command: 'startCodeReview', - repo: this.currentRepo, - id: id, - commitHash: expandedCommit.commitHash, - compareWithHash: expandedCommit.compareWithHash, - files: getFilesInTree(expandedCommit.fileTree!, expandedCommit.fileChanges!), - lastViewedFile: expandedCommit.lastViewedFile - }); - } - }); - } - - if (externalDiffPossible) { - document.getElementById('cdvExternalDiff')!.addEventListener('click', () => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || this.gitConfig === null || (this.gitConfig.diffTool === null && this.gitConfig.guiDiffTool === null)) return; - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - runAction({ - command: 'openExternalDirDiff', - repo: this.currentRepo, - fromHash: commitOrder.from, - toHash: commitOrder.to, - isGui: this.gitConfig.guiDiffTool !== null - }, 'Opening External Directory Diff'); - }); - } - } - } - - private hideCdvSummary(hide: boolean) { - let btnIcon = document.getElementById('cdvSummaryToggleBtn')?.getElementsByTagName('svg')?.[0] ?? null; - let cdvSummary = document.getElementById('cdvSummary'); - if (hide && !this.isCdvDocked()) { - if (btnIcon) btnIcon.style.transform = 'rotate(90deg)'; - cdvSummary!.classList.add('hidden'); - } else { - if (btnIcon) btnIcon.style.transform = 'rotate(-90deg)'; - cdvSummary!.classList.remove('hidden'); - } - let elem = document.getElementById('cdv'); - if (elem !== null) this.setCdvHeight(elem, this.isCdvDocked()); - } - - private setCdvHeight(elem: HTMLElement, isDocked: boolean) { - let height = this.gitRepos[this.currentRepo].cdvHeight, windowHeight = window.innerHeight; - if (height > windowHeight - 40) { - height = Math.max(windowHeight - 40, 100); - if (height !== this.gitRepos[this.currentRepo].cdvHeight) { - this.gitRepos[this.currentRepo].cdvHeight = height; - this.saveRepoState(); - } - } - - let heightPx = height + 'px'; - if (isDocked) { - this.viewElem.style.bottom = heightPx; - elem.style.height = heightPx; - return; - } - let inlineElem = document.getElementById('cdvContentWrapper'); - if (!inlineElem) { - elem.style.height = heightPx; - return; - } - if (this.gitRepos[this.currentRepo].isCdvSummaryHidden) { - inlineElem.style.height = heightPx; - elem.style.height = '0px'; - } else { - inlineElem.style.removeProperty('height'); - elem.style.height = heightPx; - } - this.renderGraph(); - } - - private setCdvDivider() { - let percent = (this.gitRepos[this.currentRepo].cdvDivider * 100).toFixed(2) + '%'; - let summaryElem = document.getElementById('cdvSummary'), dividerElem = document.getElementById('cdvDivider'), filesElem = document.getElementById('cdvFiles'); - if (summaryElem !== null) summaryElem.style.width = percent; - if (dividerElem !== null) dividerElem.style.left = percent; - if (filesElem !== null) filesElem.style.left = percent; - } - - private makeCdvResizable() { - let prevY = -1; - - const processResizingCdvHeight: EventListener = (e) => { - if (prevY < 0) return; - let delta = (e).pageY - prevY, isDocked = this.isCdvDocked(), windowHeight = window.innerHeight; - prevY = (e).pageY; - let height = this.gitRepos[this.currentRepo].cdvHeight + (isDocked ? -delta : delta); - if (height < 100) height = 100; - else if (height > 600) height = 600; - if (height > windowHeight - 40) height = Math.max(windowHeight - 40, 100); - - if (this.gitRepos[this.currentRepo].cdvHeight !== height) { - this.gitRepos[this.currentRepo].cdvHeight = height; - let elem = document.getElementById('cdv'); - if (elem !== null) this.setCdvHeight(elem, isDocked); - if (!isDocked) this.renderGraph(); - } - }; - const stopResizingCdvHeight: EventListener = (e) => { - if (prevY < 0) return; - processResizingCdvHeight(e); - this.saveRepoState(); - prevY = -1; - eventOverlay.remove(); - }; - - addListenerToClass('cdvHeightResize', 'mousedown', (e) => { - prevY = (e).pageY; - eventOverlay.create('rowResize', processResizingCdvHeight, stopResizingCdvHeight); - }); - } - - private makeCdvDividerDraggable() { - let minX = -1, width = -1; - - const processDraggingCdvDivider: EventListener = (e) => { - if (minX < 0) return; - let percent = ((e).clientX - minX) / width; - if (percent < 0.2) percent = 0.2; - else if (percent > 0.8) percent = 0.8; - - if (this.gitRepos[this.currentRepo].cdvDivider !== percent) { - this.gitRepos[this.currentRepo].cdvDivider = percent; - this.setCdvDivider(); - } - }; - const stopDraggingCdvDivider: EventListener = (e) => { - if (minX < 0) return; - processDraggingCdvDivider(e); - this.saveRepoState(); - minX = -1; - eventOverlay.remove(); - }; - - document.getElementById('cdvDivider')!.addEventListener('mousedown', () => { - const contentElem = document.getElementById('cdvContent'); - if (contentElem === null) return; - - const bounds = contentElem.getBoundingClientRect(); - minX = bounds.left; - width = bounds.width; - eventOverlay.create('colResize', processDraggingCdvDivider, stopDraggingCdvDivider); - }); - } - - /** - * Updates the state of a file in the Commit Details View. - * @param file The file that was affected. - * @param fileElem The HTML Element of the file. - * @param isReviewed TRUE/FALSE => Set the files reviewed state accordingly, NULL => Don't update the files reviewed state. - * @param fileWasViewed Was the file viewed - if so, set it to be the last viewed file. - */ - private cdvUpdateFileState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean | null, fileWasViewed: boolean) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'), filePath = file.newFilePath; - if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; - - if (fileWasViewed) { - expandedCommit.lastViewedFile = filePath; - let lastViewedElem = document.getElementById('cdvLastFileViewed'); - if (lastViewedElem !== null) lastViewedElem.remove(); - lastViewedElem = document.createElement('span'); - lastViewedElem.id = 'cdvLastFileViewed'; - lastViewedElem.title = 'Last File Viewed'; - lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; - insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); - } - - if (expandedCommit.codeReview !== null) { - if (isReviewed !== null) { - if (isReviewed) { - expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); - } else { - expandedCommit.codeReview.remainingFiles.push(filePath); - } - - alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); - updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); - } - - sendMessage({ - command: 'updateCodeReview', - repo: this.currentRepo, - id: expandedCommit.codeReview.id, - remainingFiles: expandedCommit.codeReview.remainingFiles, - lastViewedFile: expandedCommit.lastViewedFile - }); - - if (expandedCommit.codeReview.remainingFiles.length === 0) { - expandedCommit.codeReview = null; - this.renderCodeReviewBtn(); - } - } - - this.saveState(); - } - - private isCdvDocked() { - return this.config.commitDetailsView.location === GG.CommitDetailsViewLocation.DockedToBottom; - } - - public isCdvOpen(commitHash: string, compareWithHash: string | null) { - return this.expandedCommit !== null && this.expandedCommit.commitHash === commitHash && this.expandedCommit.compareWithHash === compareWithHash; - } - - private getCommitOrder(hash1: string, hash2: string) { - if (this.commitLookup[hash1] > this.commitLookup[hash2]) { - return { from: hash1, to: hash2 }; - } else { - return { from: hash2, to: hash1 }; - } - } - - private getFileViewType() { - return this.gitRepos[this.currentRepo].fileViewType === GG.FileViewType.Default - ? this.config.commitDetailsView.fileViewType - : this.gitRepos[this.currentRepo].fileViewType; - } - - private setFileViewType(type: GG.FileViewType) { - this.gitRepos[this.currentRepo].fileViewType = type; - this.saveRepoState(); - } - - private changeFileViewType(type: GG.FileViewType) { - const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'); - if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.fileChanges === null || filesElem === null) return; - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - this.setFileViewType(type); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - filesElem.innerHTML = generateFileViewHtml(expandedCommit.fileTree, expandedCommit.fileChanges, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, type, commitOrder.to === UNCOMMITTED); - this.makeCdvFileViewInteractive(); - this.renderCdvFileViewTypeBtns(); - } - - private openFolders(open: boolean) { - let expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileTree === null) return; - let folders = document.getElementsByClassName('fileTreeFolder'); - for (let i = 0; i < folders.length; i++) { - let sourceElem = (folders[i]); - let parent = sourceElem.parentElement!; - if (open) { - parent.classList.remove('closed'); - sourceElem.children[0].children[0].innerHTML = SVG_ICONS.openFolder; - parent.children[1].classList.remove('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), true); - - } else { - parent.classList.add('closed'); - sourceElem.children[0].children[0].innerHTML = SVG_ICONS.closedFolder; - parent.children[1].classList.add('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), false); - } - } - this.saveState(); - } - - private makeCdvFileViewInteractive() { - const getFileElemOfEventTarget = (target: EventTarget) => (target).closest('.fileTreeFileRecord'); - const getFileOfFileElem = (fileChanges: ReadonlyArray, fileElem: HTMLElement) => fileChanges[parseInt(fileElem.dataset.index!)]; - - const getCommitHashForFile = (file: GG.GitFileChange, expandedCommit: ExpandedCommit) => { - const commit = this.commits[this.commitLookup[expandedCommit.commitHash]]; - if (expandedCommit.compareWithHash !== null) { - return this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; - } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { - return commit.stash.untrackedFilesHash!; - } else { - return expandedCommit.commitHash; - } - }; - - const triggerViewFileDiff = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - let commit = this.commits[this.commitLookup[expandedCommit.commitHash]], fromHash: string, toHash: string, fileStatus = file.type; - if (expandedCommit.compareWithHash !== null) { - // Commit Comparison - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash); - fromHash = commitOrder.from; - toHash = commitOrder.to; - } else if (commit.stash !== null) { - // Stash Commit - if (fileStatus === GG.GitFileStatus.Untracked) { - fromHash = commit.stash.untrackedFilesHash!; - toHash = commit.stash.untrackedFilesHash!; - fileStatus = GG.GitFileStatus.Added; - } else { - fromHash = commit.stash.baseHash; - toHash = expandedCommit.commitHash; - } - } else { - // Single Commit - fromHash = expandedCommit.commitHash; - toHash = expandedCommit.commitHash; - } - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ - command: 'viewDiff', - repo: this.currentRepo, - fromHash: fromHash, - toHash: toHash, - oldFilePath: file.oldFilePath, - newFilePath: file.newFilePath, - type: fileStatus - }); - }; - - const triggerCopyFilePath = (file: GG.GitFileChange, absolute: boolean) => { - sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath, absolute: absolute }); - }; - - const triggerResetFileToRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - const commitHash = getCommitHashForFile(file, expandedCommit); - dialog.showConfirmation('Are you sure you want to reset ' + escapeHtml(file.newFilePath) + ' to it\'s state at commit ' + abbrevCommit(commitHash) + '? Any uncommitted changes made to this file will be overwritten.', 'Yes, reset file', () => { - runAction({ command: 'resetFileToRevision', repo: this.currentRepo, commitHash: commitHash, filePath: file.newFilePath }, 'Resetting file'); - }, { - type: TargetType.CommitDetailsView, - hash: commitHash, - elem: fileElem - }); - }; - - const triggerViewFileAtRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, null, true); - sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null) return; - - this.cdvUpdateFileState(file, fileElem, true, true); - sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); - }; - - addListenerToClass('fileTreeFolder', 'click', (e) => { - let expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileTree === null || e.target === null) return; - - let sourceElem = (e.target).closest('.fileTreeFolder'); - let parent = sourceElem.parentElement!; - parent.classList.toggle('closed'); - let isOpen = !parent.classList.contains('closed'); - parent.children[0].children[0].innerHTML = isOpen ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder; - parent.children[1].classList.toggle('hidden'); - alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), isOpen); - this.saveState(); - }); - - addListenerToClass('fileTreeRepo', 'click', (e) => { - if (e.target === null) return; - this.loadRepos(this.gitRepos, null, { - repo: decodeURIComponent(((e.target).closest('.fileTreeRepo')).dataset.path!) - }); - }); - - addListenerToClass('fileTreeFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const sourceElem = (e.target).closest('.fileTreeFile'), fileElem = getFileElemOfEventTarget(e.target); - if (!sourceElem.classList.contains('gitDiffPossible')) return; - triggerViewFileDiff(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('copyGitFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem), true); - }); - - addListenerToClass('viewGitFileAtRevision', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerViewFileAtRevision(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('openGitFile', 'click', (e) => { - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - - const fileElem = getFileElemOfEventTarget(e.target); - triggerOpenFile(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); - }); - - addListenerToClass('fileTreeFileRecord', 'contextmenu', (e: Event) => { - handledEvent(e); - const expandedCommit = this.expandedCommit; - if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; - const fileElem = getFileElemOfEventTarget(e.target); - const file = getFileOfFileElem(expandedCommit.fileChanges, fileElem); - const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); - const isUncommitted = commitOrder.to === UNCOMMITTED; - - GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); - expandedCommit.contextMenuOpen.fileView = parseInt(fileElem.dataset.index!); - - const target: ContextMenuTarget & CommitTarget = { - type: TargetType.CommitDetailsView, - hash: expandedCommit.commitHash, - index: this.commitLookup[expandedCommit.commitHash], - elem: fileElem - }; - const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); - const fileExistsAtThisRevision = file.type !== GG.GitFileStatus.Deleted && !isUncommitted; - const fileExistsAtThisRevisionAndDiffPossible = fileExistsAtThisRevision && diffPossible; - const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); - const visibility = this.config.contextMenuActionsVisibility.commitDetailsViewFile; - - contextMenu.show([ - [ - { - title: 'View Diff', - visible: visibility.viewDiff && diffPossible, - onClick: () => triggerViewFileDiff(file, fileElem) - }, - { - title: 'View File at this Revision', - visible: visibility.viewFileAtThisRevision && fileExistsAtThisRevisionAndDiffPossible, - onClick: () => triggerViewFileAtRevision(file, fileElem) - }, - { - title: 'View Diff with Working File', - visible: visibility.viewDiffWithWorkingFile && fileExistsAtThisRevisionAndDiffPossible, - onClick: () => triggerViewFileDiffWithWorkingFile(file, fileElem) - }, - { - title: 'Open File', - visible: visibility.openFile && file.type !== GG.GitFileStatus.Deleted, - onClick: () => triggerOpenFile(file, fileElem) - } - ], - [ - { - title: 'Mark as Reviewed', - visible: visibility.markAsReviewed && codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) - }, - { - title: 'Mark as Not Reviewed', - visible: visibility.markAsNotReviewed && expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, - onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) - } - ], - [ - { - title: 'Reset File to this Revision' + ELLIPSIS, - visible: visibility.resetFileToThisRevision && fileExistsAtThisRevision && expandedCommit.compareWithHash === null, - onClick: () => triggerResetFileToRevision(file, fileElem) - } - ], - [ - { - title: 'Copy Absolute File Path to Clipboard', - visible: visibility.copyAbsoluteFilePath, - onClick: () => triggerCopyFilePath(file, true) - }, - { - title: 'Copy Relative File Path to Clipboard', - visible: visibility.copyRelativeFilePath, - onClick: () => triggerCopyFilePath(file, false) - } - ] - ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { - expandedCommit.contextMenuOpen.fileView = -1; - }); - }); - } - - private renderCdvFileViewTypeBtns() { - if (this.expandedCommit === null) return; - let treeBtnElem = document.getElementById('cdvFileViewTypeTree'), listBtnElem = document.getElementById('cdvFileViewTypeList'); - if (treeBtnElem === null || listBtnElem === null) return; - - let listView = this.getFileViewType() === GG.FileViewType.List; - alterClass(treeBtnElem, CLASS_ACTIVE, !listView); - alterClass(listBtnElem, CLASS_ACTIVE, listView); - setFolderBtns(); - function setFolderBtns() { - let btns = document.getElementsByClassName('cdvFolderBtn'); - for (let i = 0; i < btns.length; i++) { - if (listView) - btns[i].classList.add('hidden'); - else - btns[i].classList.remove('hidden'); - } - } - } - - private renderCdvExternalDiffBtn() { - if (this.expandedCommit === null) return; - const externalDiffBtnElem = document.getElementById('cdvExternalDiff'); - if (externalDiffBtnElem === null) return; - - alterClass(externalDiffBtnElem, CLASS_ENABLED, this.gitConfig !== null && (this.gitConfig.diffTool !== null || this.gitConfig.guiDiffTool !== null)); - const toolName = this.gitConfig !== null - ? this.gitConfig.guiDiffTool !== null - ? this.gitConfig.guiDiffTool - : this.gitConfig.diffTool - : null; - externalDiffBtnElem.title = 'Open External Directory Diff' + (toolName !== null ? ' with "' + toolName + '"' : ''); - } - - private static closeCdvContextMenuIfOpen(expandedCommit: ExpandedCommit) { - if (expandedCommit.contextMenuOpen.summary || expandedCommit.contextMenuOpen.fileView > -1) { - expandedCommit.contextMenuOpen.summary = false; - expandedCommit.contextMenuOpen.fileView = -1; - contextMenu.close(); - } - } - - - /* Code Review */ - - public startCodeReview(commitHash: string, compareWithHash: string | null, codeReview: GG.CodeReview) { - if (this.expandedCommit === null || this.expandedCommit.commitHash !== commitHash || this.expandedCommit.compareWithHash !== compareWithHash) return; - this.saveAndRenderCodeReview(codeReview); - } - - public endCodeReview() { - if (this.expandedCommit === null || this.expandedCommit.codeReview === null) return; - this.saveAndRenderCodeReview(null); - } - - private saveAndRenderCodeReview(codeReview: GG.CodeReview | null) { - let filesElem = document.getElementById('cdvFilesView'); - if (this.expandedCommit === null || this.expandedCommit.fileTree === null || filesElem === null) return; - - this.expandedCommit.codeReview = codeReview; - setFileTreeReviewed(this.expandedCommit.fileTree, codeReview === null); - this.saveState(); - this.renderCodeReviewBtn(); - updateFileTreeHtml(filesElem, this.expandedCommit.fileTree); - } - - private renderCodeReviewBtn() { - if (this.expandedCommit === null) return; - let btnElem = document.getElementById('cdvCodeReview'); - if (btnElem === null) return; - - let active = this.expandedCommit.codeReview !== null; - alterClass(btnElem, CLASS_ACTIVE, active); - btnElem.title = (active ? 'End' : 'Start') + ' Code Review'; - } -} - - -/* Main */ - -const contextMenu = new ContextMenu(), dialog = new Dialog(), eventOverlay = new EventOverlay(); -let loaded = false; - -window.addEventListener('load', () => { - if (loaded) return; - loaded = true; - - TextFormatter.registerCustomEmojiMappings(initialState.config.customEmojiShortcodeMappings); - - const viewElem = document.getElementById('view'); - if (viewElem === null) return; - - const gitGraph = new GitGraphView(viewElem, VSCODE_API.getState()); - const imageResizer = new ImageResizer(); - - /* Command Processing */ - window.addEventListener('message', event => { - const msg: GG.ResponseMessage = event.data; - switch (msg.command) { - case 'addRemote': - refreshOrDisplayError(msg.error, 'Unable to Add Remote', true); - break; - case 'addTag': - if (msg.pushToRemote !== null && msg.errors.length === 2 && msg.errors[0] === null && isExtensionErrorInfo(msg.errors[1], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { - gitGraph.refresh(false); - handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.errors[1]!); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Add Tag'); - } - break; - case 'applyStash': - refreshOrDisplayError(msg.error, 'Unable to Apply Stash'); - break; - case 'branchFromStash': - refreshOrDisplayError(msg.error, 'Unable to Create Branch from Stash'); - break; - case 'checkoutBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Checkout Branch' + (msg.pullAfterwards !== null ? ' & Pull Changes' : '')); - break; - case 'checkoutCommit': - refreshOrDisplayError(msg.error, 'Unable to Checkout Commit'); - break; - case 'cherrypickCommit': - refreshAndDisplayErrors(msg.errors, 'Unable to Cherry Pick Commit'); - break; - case 'cleanUntrackedFiles': - refreshOrDisplayError(msg.error, 'Unable to Clean Untracked Files'); - break; - case 'commitDetails': - if (msg.commitDetails !== null) { - gitGraph.showCommitDetails(msg.commitDetails, gitGraph.createFileTree(msg.commitDetails.fileChanges, msg.codeReview), msg.avatar, msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); - } else { - gitGraph.closeCommitDetails(true); - dialog.showError('Unable to load Commit Details', msg.error, null, null); - } - break; - case 'compareCommits': - if (msg.error === null) { - gitGraph.showCommitComparison(msg.commitHash, msg.compareWithHash, msg.fileChanges, gitGraph.createFileTree(msg.fileChanges, msg.codeReview), msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); - } else { - gitGraph.closeCommitComparison(true); - dialog.showError('Unable to load Commit Comparison', msg.error, null, null); - } - break; - case 'copyFilePath': - finishOrDisplayError(msg.error, 'Unable to Copy File Path to Clipboard'); - break; - case 'copyToClipboard': - finishOrDisplayError(msg.error, 'Unable to Copy ' + msg.type + ' to Clipboard'); - break; - case 'createArchive': - finishOrDisplayError(msg.error, 'Unable to Create Archive', true); - break; - case 'createBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Create Branch'); - break; - case 'createPullRequest': - finishOrDisplayErrors(msg.errors, 'Unable to Create Pull Request', () => { - if (msg.push) { - gitGraph.refresh(false); - } - }, true); - break; - case 'deleteBranch': - handleResponseDeleteBranch(msg); - break; - case 'deleteRemote': - refreshOrDisplayError(msg.error, 'Unable to Delete Remote', true); - break; - case 'deleteRemoteBranch': - refreshOrDisplayError(msg.error, 'Unable to Delete Remote Branch'); - break; - case 'deleteTag': - refreshOrDisplayError(msg.error, 'Unable to Delete Tag'); - break; - case 'deleteUserDetails': - finishOrDisplayErrors(msg.errors, 'Unable to Remove Git User Details', () => gitGraph.requestLoadConfig(), true); - break; - case 'dropCommit': - refreshOrDisplayError(msg.error, 'Unable to Drop Commit'); - break; - case 'dropStash': - refreshOrDisplayError(msg.error, 'Unable to Drop Stash'); - break; - case 'editRemote': - refreshOrDisplayError(msg.error, 'Unable to Save Changes to Remote', true); - break; - case 'editUserDetails': - finishOrDisplayErrors(msg.errors, 'Unable to Save Git User Details', () => gitGraph.requestLoadConfig(), true); - break; - case 'exportRepoConfig': - refreshOrDisplayError(msg.error, 'Unable to Export Repository Configuration'); - break; - case 'fetch': - refreshOrDisplayError(msg.error, 'Unable to Fetch from Remote(s)'); - break; - case 'fetchAvatar': - imageResizer.resize(msg.image, (resizedImage) => { - gitGraph.loadAvatar(msg.email, resizedImage); - }); - break; - case 'fetchIntoLocalBranch': - refreshOrDisplayError(msg.error, 'Unable to Fetch into Local Branch'); - break; - case 'loadCommits': - gitGraph.processLoadCommitsResponse(msg); - break; - case 'loadConfig': - gitGraph.processLoadConfig(msg); - break; - case 'loadRepoInfo': - gitGraph.processLoadRepoInfoResponse(msg); - break; - case 'loadRepos': - gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); - break; - case 'scrollToCommit': - gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); // if graph exist - gitGraph.scrollToCommitArgs = { // if graph is creating - hash: msg.hash, - alwaysCenterCommit: msg.alwaysCenterCommit, - flash: msg.flash, - openDetails: msg.openDetails, - persistently: msg.persistently - }; - break; - case 'merge': - refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); - break; - case 'openExtensionSettings': - finishOrDisplayError(msg.error, 'Unable to Open Extension Settings'); - break; - case 'openExternalDirDiff': - finishOrDisplayError(msg.error, 'Unable to Open External Directory Diff', true); - break; - case 'openExternalUrl': - finishOrDisplayError(msg.error, 'Unable to Open External URL'); - break; - case 'openFile': - finishOrDisplayError(msg.error, 'Unable to Open File'); - break; - case 'openTerminal': - finishOrDisplayError(msg.error, 'Unable to Open Terminal', true); - break; - case 'popStash': - refreshOrDisplayError(msg.error, 'Unable to Pop Stash'); - break; - case 'pruneRemote': - refreshOrDisplayError(msg.error, 'Unable to Prune Remote'); - break; - case 'pullBranch': - refreshOrDisplayError(msg.error, 'Unable to Pull Branch'); - break; - case 'pushBranch': - refreshAndDisplayErrors(msg.errors, 'Unable to Push Branch', msg.willUpdateBranchConfig); - break; - case 'pushStash': - refreshOrDisplayError(msg.error, 'Unable to Stash Uncommitted Changes'); - break; - case 'pushTag': - if (msg.errors.length === 1 && isExtensionErrorInfo(msg.errors[0], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { - handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.errors[0]!); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Push Tag'); - } - break; - case 'rebase': - if (msg.error === null) { - if (msg.interactive) { - dialog.closeActionRunning(); - } else { - gitGraph.refresh(false); - } - } else { - dialog.showError('Unable to Rebase current branch on ' + msg.actionOn, msg.error, null, null); - } - break; - case 'refresh': - gitGraph.refresh(false); - break; - case 'renameBranch': - refreshOrDisplayError(msg.error, 'Unable to Rename Branch'); - break; - case 'resetFileToRevision': - refreshOrDisplayError(msg.error, 'Unable to Reset File to Revision'); - break; - case 'resetToCommit': - refreshOrDisplayError(msg.error, 'Unable to Reset to Commit'); - break; - case 'revertCommit': - refreshOrDisplayError(msg.error, 'Unable to Revert Commit'); - break; - case 'setGlobalViewState': - finishOrDisplayError(msg.error, 'Unable to save the Global View State'); - break; - case 'setWorkspaceViewState': - finishOrDisplayError(msg.error, 'Unable to save the Workspace View State'); - break; - case 'startCodeReview': - if (msg.error === null) { - gitGraph.startCodeReview(msg.commitHash, msg.compareWithHash, msg.codeReview); - } else { - dialog.showError('Unable to Start Code Review', msg.error, null, null); - } - break; - case 'tagDetails': - if (msg.details !== null) { - gitGraph.renderTagDetails(msg.tagName, msg.commitHash, msg.details); - } else { - dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); - } - break; - case 'updateCodeReview': - if (msg.error !== null) { - dialog.showError('Unable to update Code Review', msg.error, null, null); - } - break; - case 'viewDiff': - finishOrDisplayError(msg.error, 'Unable to View Diff'); - break; - case 'viewDiffWithWorkingFile': - finishOrDisplayError(msg.error, 'Unable to View Diff with Working File'); - break; - case 'viewFileAtRevision': - finishOrDisplayError(msg.error, 'Unable to View File at Revision'); - break; - case 'viewScm': - finishOrDisplayError(msg.error, 'Unable to open the Source Control View'); - break; - } - }); - - function handleResponseDeleteBranch(msg: GG.ResponseDeleteBranch) { - if (msg.errors.length > 0 && msg.errors[0] !== null && msg.errors[0].includes('git branch -D')) { - dialog.showConfirmation('The branch ' + escapeHtml(msg.branchName) + ' is not fully merged. Would you like to force delete it?', 'Yes, force delete branch', () => { - runAction({ command: 'deleteBranch', repo: msg.repo, branchName: msg.branchName, forceDelete: true, deleteOnRemotes: msg.deleteOnRemotes }, 'Deleting Branch'); - }, { type: TargetType.Repo }); - } else { - refreshAndDisplayErrors(msg.errors, 'Unable to Delete Branch'); - } - } - - function handleResponsePushTagCommitNotOnRemote(repo: string, tagName: string, remotes: string[], commitHash: string, error: string) { - const remotesNotContainingCommit: string[] = parseExtensionErrorInfo(error, GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote); - - const html = '' + SVG_ICONS.alert + 'Warning: Commit is not on Remote' + (remotesNotContainingCommit.length > 1 ? 's ' : ' ') + '
    ' + - '' + - '

    The tag ' + escapeHtml(tagName) + ' is on a commit that isn\'t on any known branch on the remote' + (remotesNotContainingCommit.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotesNotContainingCommit.map((remote) => '' + escapeHtml(remote) + '')) + '.

    ' + - '

    Would you like to proceed to push the tag to the remote' + (remotes.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotes.map((remote) => '' + escapeHtml(remote) + '')) + ' anyway?

    ' + - '
    '; - - dialog.showForm(html, [{ type: DialogInputType.Checkbox, name: 'Always Proceed', value: false }], 'Proceed to Push', (values) => { - if (values[0]) { - updateGlobalViewState('pushTagSkipRemoteCheck', true); - } - runAction({ - command: 'pushTag', - repo: repo, - tagName: tagName, - remotes: remotes, - commitHash: commitHash, - skipRemoteCheck: true - }, 'Pushing Tag'); - }, { type: TargetType.Repo }, 'Cancel', null, true); - } - - function refreshOrDisplayError(error: GG.ErrorInfo, errorMessage: string, configChanges: boolean = false) { - if (error === null) { - gitGraph.refresh(false, configChanges); - } else { - dialog.showError(errorMessage, error, null, null); - } - } - - function refreshAndDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, configChanges: boolean = false) { - const reducedErrors = reduceErrorInfos(errors); - if (reducedErrors.error !== null) { - dialog.showError(errorMessage, reducedErrors.error, null, null); - } - if (reducedErrors.partialOrCompleteSuccess) { - gitGraph.refresh(false, configChanges); - } else if (configChanges) { - gitGraph.requestLoadConfig(); - } - } - - function finishOrDisplayError(error: GG.ErrorInfo, errorMessage: string, dismissActionRunning: boolean = false) { - if (error !== null) { - dialog.showError(errorMessage, error, null, null); - } else if (dismissActionRunning) { - dialog.closeActionRunning(); - } - } - - function finishOrDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, partialOrCompleteSuccessCallback: () => void, dismissActionRunning: boolean = false) { - const reducedErrors = reduceErrorInfos(errors); - finishOrDisplayError(reducedErrors.error, errorMessage, dismissActionRunning); - if (reducedErrors.partialOrCompleteSuccess) { - partialOrCompleteSuccessCallback(); - } - } - - function reduceErrorInfos(errors: GG.ErrorInfo[]) { - let error: GG.ErrorInfo = null, partialOrCompleteSuccess = false; - for (let i = 0; i < errors.length; i++) { - if (errors[i] !== null) { - error = error !== null ? error + '\n\n' + errors[i] : errors[i]; - } else { - partialOrCompleteSuccess = true; - } - } - - return { - error: error, - partialOrCompleteSuccess: partialOrCompleteSuccess - }; - } - - /** - * Checks whether the given ErrorInfo has an ErrorInfoExtensionPrefix. - * @param error The ErrorInfo to check. - * @param prefix The ErrorInfoExtensionPrefix to test. - * @returns TRUE => ErrorInfo has the ErrorInfoExtensionPrefix, FALSE => ErrorInfo doesn\'t have the ErrorInfoExtensionPrefix - */ - function isExtensionErrorInfo(error: GG.ErrorInfo, prefix: GG.ErrorInfoExtensionPrefix) { - return error !== null && error.startsWith(prefix); - } - - /** - * Parses the JSON data from an ErrorInfo prefixed by the provided ErrorInfoExtensionPrefix. - * @param error The ErrorInfo to parse. - * @param prefix The ErrorInfoExtensionPrefix used by `error`. - * @returns The parsed JSON data. - */ - function parseExtensionErrorInfo(error: string, prefix: GG.ErrorInfoExtensionPrefix) { - return JSON.parse(error.substring(prefix.length)); - } -}); - - -/* File Tree Methods (for the Commit Details & Comparison Views) */ - -function generateFileViewHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, type: GG.FileViewType, isUncommitted: boolean) { - return type === GG.FileViewType.List - ? generateFileListHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted) - : generateFileTreeHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, true); -} - -function generateFileTreeHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean, topLevelFolder: boolean): string { - const curFolderInfo = topLevelFolder || !initialState.config.commitDetailsView.fileTreeCompactFolders - ? { folder: folder, name: folder.name, pathSeg: folder.name } - : getCurrentFolderInfo(folder, folder.name, folder.name); - - const children = sortFolderKeys(curFolderInfo.folder).map((key) => { - const cur = curFolderInfo.folder.contents[key]; - return cur.type === 'folder' - ? generateFileTreeHtml(cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, false) - : generateFileTreeLeafHtml(cur.name, cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); - }); - - return (topLevelFolder ? '' : '' + (curFolderInfo.folder.open ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder) + '' + escapeHtml(curFolderInfo.name) + '') + - '
      ' + children.join('') + '
    ' + - (topLevelFolder ? '' : ''); -} - -function getCurrentFolderInfo(folder: FileTreeFolder, name: string, pathSeg: string): { folder: FileTreeFolder, name: string, pathSeg: string } { - const keys = Object.keys(folder.contents); - let child: FileTreeNode; - return keys.length === 1 && (child = folder.contents[keys[0]]).type === 'folder' - ? getCurrentFolderInfo(child, name + ' / ' + child.name, pathSeg + '/' + child.name) - : { folder: folder, name: name, pathSeg: pathSeg }; -} - -function generateFileListHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { - const sortLeaves = (folder: FileTreeFolder, folderPath: string) => { - let keys = sortFolderKeys(folder); - let items: { relPath: string, leaf: FileTreeLeaf }[] = []; - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - let relPath = (folderPath !== '' ? folderPath + '/' : '') + cur.name; - if (cur.type === 'folder') { - items = items.concat(sortLeaves(cur, relPath)); - } else { - items.push({ relPath: relPath, leaf: cur }); - } - } - return items; - }; - let sortedLeaves = sortLeaves(folder, ''); - let html = ''; - for (let i = 0; i < sortedLeaves.length; i++) { - html += generateFileTreeLeafHtml(sortedLeaves[i].relPath, sortedLeaves[i].leaf, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); - } - return '
      ' + html + '
    '; -} - -function generateFileTreeLeafHtml(name: string, leaf: FileTreeLeaf, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { - let encodedName = encodeURIComponent(name), escapedName = escapeHtml(name); - if (leaf.type === 'file') { - const fileTreeFile = gitFiles[leaf.index]; - const textFile = fileTreeFile.additions !== null && fileTreeFile.deletions !== null; - const diffPossible = fileTreeFile.type === GG.GitFileStatus.Untracked || textFile; - const changeTypeMessage = GIT_FILE_CHANGE_TYPES[fileTreeFile.type] + (fileTreeFile.type === GG.GitFileStatus.Renamed ? ' (' + escapeHtml(fileTreeFile.oldFilePath) + ' → ' + escapeHtml(fileTreeFile.newFilePath) + ')' : ''); - return '
  • ' + SVG_ICONS.file + '' + escapedName + '' + - (initialState.config.enhancedAccessibility ? '' + fileTreeFile.type + '' : '') + - (fileTreeFile.type !== GG.GitFileStatus.Added && fileTreeFile.type !== GG.GitFileStatus.Untracked && fileTreeFile.type !== GG.GitFileStatus.Deleted && textFile ? '(+' + fileTreeFile.additions + '|-' + fileTreeFile.deletions + ')' : '') + - (fileTreeFile.newFilePath === lastViewedFile ? '' + SVG_ICONS.eyeOpen + '' : '') + - '' + SVG_ICONS.copy + '' + - (fileTreeFile.type !== GG.GitFileStatus.Deleted - ? (diffPossible && !isUncommitted ? '' + SVG_ICONS.commit + '' : '') + - '' + SVG_ICONS.openFile + '' - : '' - ) + '
  • '; - } else { - return '
  • ' + SVG_ICONS.closedFolder + '' + escapedName + '
  • '; - } -} - -function alterFileTreeFolderOpen(folder: FileTreeFolder, folderPath: string, open: boolean) { - let path = folderPath.split('/'), i, cur = folder; - for (i = 0; i < path.length; i++) { - if (typeof cur.contents[path[i]] !== 'undefined') { - cur = cur.contents[path[i]]; - if (i === path.length - 1) cur.open = open; - } else { - return; - } - } -} - -function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string, reviewed: boolean) { - let path = filePath.split('/'), i, cur = folder, folders = [folder]; - for (i = 0; i < path.length; i++) { - if (typeof cur.contents[path[i]] !== 'undefined') { - if (i < path.length - 1) { - cur = cur.contents[path[i]]; - folders.push(cur); - } else { - (cur.contents[path[i]]).reviewed = reviewed; - } - } else { - break; - } - } - - // Recalculate whether each of the folders leading to the file are now reviewed (deepest first). - for (i = folders.length - 1; i >= 0; i--) { - let keys = Object.keys(folders[i].contents), entireFolderReviewed = true; - for (let j = 0; j < keys.length; j++) { - let cur = folders[i].contents[keys[j]]; - if ((cur.type === 'folder' || cur.type === 'file') && !cur.reviewed) { - entireFolderReviewed = false; - break; - } - } - folders[i].reviewed = entireFolderReviewed; - } -} - -function setFileTreeReviewed(folder: FileTreeFolder, reviewed: boolean) { - folder.reviewed = reviewed; - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if (cur.type === 'folder') { - setFileTreeReviewed(cur, reviewed); - } else if (cur.type === 'file') { - cur.reviewed = reviewed; - } - } -} - -function calcFileTreeFoldersReviewed(folder: FileTreeFolder) { - const calc = (folder: FileTreeFolder) => { - let reviewed = true; - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if ((cur.type === 'folder' && !calc(cur)) || (cur.type === 'file' && !cur.reviewed)) reviewed = false; - } - folder.reviewed = reviewed; - return reviewed; - }; - calc(folder); -} - -function updateFileTreeHtml(elem: HTMLElement, folder: FileTreeFolder) { - let ul = getChildUl(elem); - if (ul === null) return; - - for (let i = 0; i < ul.children.length; i++) { - let li = ul.children[i]; - let pathSeg = decodeURIComponent(li.dataset.pathseg!); - let child = getChildByPathSegment(folder, pathSeg); - if (child.type === 'folder') { - alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); - updateFileTreeHtml(li, child); - } else if (child.type === 'file') { - alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); - } - } -} - -function updateFileTreeHtmlFileReviewed(elem: HTMLElement, folder: FileTreeFolder, filePath: string) { - let path = filePath; - const update = (elem: HTMLElement, folder: FileTreeFolder) => { - let ul = getChildUl(elem); - if (ul === null) return; - - for (let i = 0; i < ul.children.length; i++) { - let li = ul.children[i]; - let pathSeg = decodeURIComponent(li.dataset.pathseg!); - if (path === pathSeg || path.startsWith(pathSeg + '/')) { - let child = getChildByPathSegment(folder, pathSeg); - if (child.type === 'folder') { - alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); - path = path.substring(pathSeg.length + 1); - update(li, child); - } else if (child.type === 'file') { - alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); - } - break; - } - } - }; - update(elem, folder); -} - -function getFilesInTree(folder: FileTreeFolder, gitFiles: ReadonlyArray) { - let files: string[] = []; - const scanFolder = (folder: FileTreeFolder) => { - let keys = Object.keys(folder.contents); - for (let i = 0; i < keys.length; i++) { - let cur = folder.contents[keys[i]]; - if (cur.type === 'folder') { - scanFolder(cur); - } else if (cur.type === 'file') { - files.push(gitFiles[cur.index].newFilePath); - } - } - }; - scanFolder(folder); - return files; -} - -function sortFolderKeys(folder: FileTreeFolder) { - let keys = Object.keys(folder.contents); - keys.sort((a, b) => folder.contents[a].type !== 'file' && folder.contents[b].type === 'file' ? -1 : folder.contents[a].type === 'file' && folder.contents[b].type !== 'file' ? 1 : folder.contents[a].name.localeCompare(folder.contents[b].name)); - return keys; -} - -function getChildByPathSegment(folder: FileTreeFolder, pathSeg: string) { - let cur: FileTreeNode = folder, comps = pathSeg.split('/'); - for (let i = 0; i < comps.length; i++) { - cur = (cur).contents[comps[i]]; - } - return cur; -} - - -/* Repository State Helpers */ - -function getCommitOrdering(repoValue: GG.RepoCommitOrdering): GG.CommitOrdering { - switch (repoValue) { - case GG.RepoCommitOrdering.Default: - return initialState.config.commitOrdering; - case GG.RepoCommitOrdering.Date: - return GG.CommitOrdering.Date; - case GG.RepoCommitOrdering.AuthorDate: - return GG.CommitOrdering.AuthorDate; - case GG.RepoCommitOrdering.Topological: - return GG.CommitOrdering.Topological; - } -} - -function getShowRemoteBranches(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showRemoteBranches - : repoValue === GG.BooleanOverride.Enabled; -} - -function getSimplifyByDecoration(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.simplifyByDecoration - : repoValue === GG.BooleanOverride.Enabled; -} - -function getShowStashes(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showStashes - : repoValue === GG.BooleanOverride.Enabled; -} - -function getShowTags(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.showTags - : repoValue === GG.BooleanOverride.Enabled; -} - -function getIncludeCommitsMentionedByReflogs(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.includeCommitsMentionedByReflogs - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnlyFollowFirstParent(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.onlyFollowFirstParent - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnRepoLoadShowCheckedOutBranch(repoValue: GG.BooleanOverride) { - return repoValue === GG.BooleanOverride.Default - ? initialState.config.onRepoLoad.showCheckedOutBranch - : repoValue === GG.BooleanOverride.Enabled; -} - -function getOnRepoLoadShowSpecificBranches(repoValue: string[] | null) { - return repoValue === null - ? initialState.config.onRepoLoad.showSpecificBranches - : repoValue; -} - - -/* Miscellaneous Helper Methods */ - -function haveFilesChanged(oldFiles: ReadonlyArray | null, newFiles: ReadonlyArray | null) { - if ((oldFiles === null) !== (newFiles === null)) { - return true; - } else if (oldFiles === null && newFiles === null) { - return false; - } else { - return !arraysEqual(oldFiles!, newFiles!, (a, b) => a.additions === b.additions && a.deletions === b.deletions && a.newFilePath === b.newFilePath && a.oldFilePath === b.oldFilePath && a.type === b.type); - } -} - -function abbrevCommit(commitHash: string) { - return commitHash.substring(0, 8); -} - -function getRepoDropdownOptions(repos: Readonly) { - const repoPaths = getSortedRepositoryPaths(repos, initialState.config.repoDropdownOrder); - const paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; - const resolveAmbiguous = (indexes: number[]) => { - // Find ambiguous names within indexes - let firstOccurrence: { [name: string]: number } = {}, ambiguous: { [name: string]: number[] } = {}; - for (let i = 0; i < indexes.length; i++) { - let name = distinctNames[indexes[i]]; - if (typeof firstOccurrence[name] === 'number') { - // name is ambiguous - if (typeof ambiguous[name] === 'undefined') { - // initialise ambiguous array with the first occurrence - ambiguous[name] = [firstOccurrence[name]]; - } - ambiguous[name].push(indexes[i]); // append current ambiguous index - } else { - firstOccurrence[name] = indexes[i]; // set the first occurrence of the name - } - } - - let ambiguousNames = Object.keys(ambiguous); - for (let i = 0; i < ambiguousNames.length; i++) { - // For each ambiguous name, resolve the ambiguous indexes - let ambiguousIndexes = ambiguous[ambiguousNames[i]], retestIndexes = []; - for (let j = 0; j < ambiguousIndexes.length; j++) { - let ambiguousIndex = ambiguousIndexes[j]; - let nextSep = paths[ambiguousIndex].lastIndexOf('/', paths[ambiguousIndex].length - distinctNames[ambiguousIndex].length - 2); - if (firstSep[ambiguousIndex] < nextSep) { - // prepend the addition path and retest - distinctNames[ambiguousIndex] = paths[ambiguousIndex].substring(nextSep + 1); - retestIndexes.push(ambiguousIndex); - } else { - distinctNames[ambiguousIndex] = paths[ambiguousIndex]; - } - } - if (retestIndexes.length > 1) { - // If there are 2 or more indexes that may be ambiguous - resolveAmbiguous(retestIndexes); - } - } - }; - - // Initialise recursion - const indexes = []; - for (let i = 0; i < repoPaths.length; i++) { - firstSep.push(repoPaths[i].indexOf('/')); - const repo = repos[repoPaths[i]]; - if (repo.name) { - // A name has been set for the repository - paths.push(repoPaths[i]); - names.push(repo.name); - distinctNames.push(repo.name); - } else if (firstSep[i] === repoPaths[i].length - 1 || firstSep[i] === -1) { - // Path has no slashes, or a single trailing slash ==> use the path as the name - paths.push(repoPaths[i]); - names.push(repoPaths[i]); - distinctNames.push(repoPaths[i]); - } else { - paths.push(repoPaths[i].endsWith('/') ? repoPaths[i].substring(0, repoPaths[i].length - 1) : repoPaths[i]); // Remove trailing slash if it exists - let name = paths[i].substring(paths[i].lastIndexOf('/') + 1); - names.push(name); - distinctNames.push(name); - indexes.push(i); - } - } - resolveAmbiguous(indexes); - - const options: DropdownOption[] = []; - for (let i = 0; i < repoPaths.length; i++) { - let hint; - if (names[i] === distinctNames[i]) { - // Name is distinct, no hint needed - hint = ''; - } else { - // Hint path is the prefix of the distinctName before the common suffix with name - let hintPath = distinctNames[i].substring(0, distinctNames[i].length - names[i].length - 1); - - // Keep two informative directories - let hintComps = hintPath.split('/'); - let keepDirs = hintComps[0] !== '' ? 2 : 3; - if (hintComps.length > keepDirs) hintComps.splice(keepDirs, hintComps.length - keepDirs, '...'); - - // Construct the hint - hint = (distinctNames[i] !== paths[i] ? '.../' : '') + hintComps.join('/'); - } - options.push({ name: names[i], value: repoPaths[i], hint: hint }); - } - return options; -} - -function runAction(msg: GG.RequestMessage, action: string) { - dialog.showActionRunning(action); - sendMessage(msg); -} - -function getBranchLabels(heads: ReadonlyArray, remotes: ReadonlyArray) { - let headLabels: { name: string; remotes: string[] }[] = [], headLookup: { [name: string]: number } = {}, remoteLabels: ReadonlyArray; - for (let i = 0; i < heads.length; i++) { - headLabels.push({ name: heads[i], remotes: [] }); - headLookup[heads[i]] = i; - } - if (initialState.config.referenceLabels.combineLocalAndRemoteBranchLabels) { - let remainingRemoteLabels = []; - for (let i = 0; i < remotes.length; i++) { - if (remotes[i].remote !== null) { // If the remote of the remote branch ref is known - let branchName = remotes[i].name.substring(remotes[i].remote!.length + 1); - if (typeof headLookup[branchName] === 'number') { - headLabels[headLookup[branchName]].remotes.push(remotes[i].remote!); - continue; - } - } - remainingRemoteLabels.push(remotes[i]); - } - remoteLabels = remainingRemoteLabels; - } else { - remoteLabels = remotes; - } - return { heads: headLabels, remotes: remoteLabels }; -} - -function findCommitElemWithId(elems: HTMLCollectionOf, id: number | null) { - if (id === null) return null; - let findIdStr = id.toString(); - for (let i = 0; i < elems.length; i++) { - if (findIdStr === elems[i].dataset.id) return elems[i]; - } - return null; -} - -function generateSignatureHtml(signature: GG.GitSignature) { - return '' - + (signature.status === GG.GitSignatureStatus.GoodAndValid - ? SVG_ICONS.passed - : signature.status === GG.GitSignatureStatus.Bad - ? SVG_ICONS.failed - : SVG_ICONS.inconclusive) - + ''; -} - -function closeDialogAndContextMenu() { - if (dialog.isOpen()) dialog.close(); - if (contextMenu.isOpen()) contextMenu.close(); -} +class GitGraphView { + private gitRepos: GG.GitRepoSet; + private gitBranches: ReadonlyArray = []; + private gitBranchHead: string | null = null; + private gitConfig: GG.GitRepoConfig | null = null; + private gitRemotes: ReadonlyArray = []; + private gitStashes: ReadonlyArray = []; + private gitTags: ReadonlyArray = []; + private commits: GG.GitCommit[] = []; + private commitHead: string | null = null; + private commitLookup: { [hash: string]: number } = {}; + private onlyFollowFirstParent: boolean = false; + private avatars: AvatarImageCollection = {}; + private currentBranches: string[] | null = null; + private currentAuthors: string[] | null = null; + + private currentRepo!: string; + private currentRepoLoading: boolean = true; + private currentRepoRefreshState: { + inProgress: boolean; + hard: boolean; + loadRepoInfoRefreshId: number; + loadCommitsRefreshId: number; + repoInfoChanges: boolean; + configChanges: boolean; + requestingRepoInfo: boolean; + requestingConfig: boolean; + }; + private loadViewTo: GG.LoadGitGraphViewTo = null; + + public scrollToCommitArgs: { + hash: string, + alwaysCenterCommit: boolean, + flash: boolean, + openDetails: boolean, + persistently: boolean + }; + + private readonly graph: Graph; + private readonly config: Config; + + private moreCommitsAvailable: boolean = false; + private expandedCommit: ExpandedCommit | null = null; + private maxCommits: number; + private scrollTop = 0; + private renderedGitBranchHead: string | null = null; + + private lastScrollToStash: { + time: number, + hash: string | null + } = { time: 0, hash: null }; + + private readonly findWidget: FindWidget; + private readonly settingsWidget: SettingsWidget; + private readonly repoDropdown: Dropdown; + private readonly branchDropdown: Dropdown; + private readonly authorDropdown: Dropdown; + + private readonly viewElem: HTMLElement; + private readonly controlsElem: HTMLElement; + private readonly tableElem: HTMLElement; + private tableColHeadersElem: HTMLElement | null; + private readonly footerElem: HTMLElement; + private readonly showRemoteBranchesElem: HTMLInputElement; + private readonly simplifyByDecorationElem: HTMLInputElement; + private readonly refreshBtnElem: HTMLElement; + + constructor(viewElem: HTMLElement, prevState: WebViewState | null) { + this.gitRepos = initialState.repos; + this.config = initialState.config; + this.maxCommits = this.config.initialLoadCommits; + this.viewElem = viewElem; + this.currentRepoRefreshState = { + inProgress: false, + hard: true, + loadRepoInfoRefreshId: initialState.loadRepoInfoRefreshId, + loadCommitsRefreshId: initialState.loadCommitsRefreshId, + repoInfoChanges: false, + configChanges: false, + requestingRepoInfo: false, + requestingConfig: false + }; + + this.scrollToCommitArgs = { + hash: '', + alwaysCenterCommit: false, + flash: false, + openDetails: false, + persistently: false + }; + + this.controlsElem = document.getElementById('controls')!; + this.tableElem = document.getElementById('commitTable')!; + this.tableColHeadersElem = document.getElementById('tableColHeaders')!; + this.footerElem = document.getElementById('footer')!; + + viewElem.focus(); + + this.graph = new Graph('commitGraph', viewElem, this.config.graph, this.config.mute); + + this.repoDropdown = new Dropdown('repoDropdown', true, false, 'Repos', (values) => { + this.loadRepo(values[0]); + }); + + this.branchDropdown = new Dropdown('branchDropdown', false, true, 'Branches', (values) => { + this.currentBranches = values; + this.maxCommits = this.config.initialLoadCommits; + this.saveState(); + this.clearCommits(); + this.requestLoadRepoInfoAndCommits(true, true); + }); + this.authorDropdown = new Dropdown('authorDropdown', false, true, 'Authors', (values) => { + this.currentAuthors = values; + this.maxCommits = this.config.initialLoadCommits; + this.saveState(); + this.clearCommits(); + this.requestLoadRepoInfoAndCommits(true, true); + }); + this.showRemoteBranchesElem = document.getElementById('showRemoteBranchesCheckbox')!; + this.showRemoteBranchesElem.addEventListener('change', () => { + this.saveRepoStateValue(this.currentRepo, 'showRemoteBranchesV2', this.showRemoteBranchesElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); + this.refresh(true); + }); + this.simplifyByDecorationElem = document.getElementById('simplifyByDecorationCheckbox')!; + this.simplifyByDecorationElem.addEventListener('change', () => { + this.saveRepoStateValue(this.currentRepo, 'simplifyByDecoration', this.simplifyByDecorationElem.checked ? GG.BooleanOverride.Enabled : GG.BooleanOverride.Disabled); + this.refresh(true); + }); + + this.refreshBtnElem = document.getElementById('refreshBtn')!; + this.refreshBtnElem.addEventListener('click', () => { + if (!this.refreshBtnElem.classList.contains(CLASS_REFRESHING)) { + this.refresh(true, true); + } + }); + this.renderRefreshButton(); + + this.findWidget = new FindWidget(this); + this.settingsWidget = new SettingsWidget(this); + + alterClass(document.body, CLASS_BRANCH_LABELS_ALIGNED_TO_GRAPH, this.config.referenceLabels.branchLabelsAlignedToGraph); + alterClass(document.body, CLASS_TAG_LABELS_RIGHT_ALIGNED, this.config.referenceLabels.tagLabelsOnRight); + + this.observeWindowSizeChanges(); + this.observeWebviewStyleChanges(); + this.observeViewScroll(); + this.observeKeyboardEvents(); + this.observeUrls(); + this.observeTableEvents(); + + if (prevState && !prevState.currentRepoLoading && typeof this.gitRepos[prevState.currentRepo] !== 'undefined') { + this.currentRepo = prevState.currentRepo; + this.currentBranches = prevState.currentBranches; + this.currentAuthors = prevState.currentAuthors; + this.maxCommits = prevState.maxCommits; + this.expandedCommit = prevState.expandedCommit; + this.avatars = prevState.avatars; + this.gitConfig = prevState.gitConfig; + this.loadRepoInfo(prevState.gitBranches, prevState.gitBranchHead, prevState.gitRemotes, prevState.gitStashes, true); + this.loadCommits(prevState.commits, prevState.commitHead, prevState.gitTags, prevState.moreCommitsAvailable, prevState.onlyFollowFirstParent); + this.findWidget.restoreState(prevState.findWidget); + this.settingsWidget.restoreState(prevState.settingsWidget); + this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[prevState.currentRepo].showRemoteBranchesV2); + this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[prevState.currentRepo].simplifyByDecoration); + } + + let loadViewTo = initialState.loadViewTo; + if (loadViewTo === null && prevState && prevState.currentRepoLoading && typeof prevState.currentRepo !== 'undefined') { + loadViewTo = { repo: prevState.currentRepo }; + } + + if (!this.loadRepos(this.gitRepos, initialState.lastActiveRepo, loadViewTo)) { + if (prevState) { + this.scrollTop = prevState.scrollTop; + this.viewElem.scroll(0, this.scrollTop); + } + this.requestLoadRepoInfoAndCommits(false, false); + } + + const currentBtn = document.getElementById('currentBtn')!, fetchBtn = document.getElementById('fetchBtn')!, findBtn = document.getElementById('findBtn')!, settingsBtn = document.getElementById('settingsBtn')!, terminalBtn = document.getElementById('terminalBtn')!; + currentBtn.innerHTML = SVG_ICONS.current; + currentBtn.addEventListener('click', () => { + if (this.commitHead) { + this.scrollToCommit(this.commitHead, true, true, false, true); + } + }); + fetchBtn.title = 'Fetch' + (this.config.fetchAndPrune ? ' & Prune' : '') + ' from Remote(s)'; + fetchBtn.innerHTML = SVG_ICONS.download; + fetchBtn.addEventListener('click', () => this.fetchFromRemotesAction()); + findBtn.innerHTML = SVG_ICONS.search; + findBtn.addEventListener('click', () => this.findWidget.show(true)); + settingsBtn.innerHTML = SVG_ICONS.gear; + settingsBtn.addEventListener('click', () => this.settingsWidget.show(this.currentRepo)); + terminalBtn.innerHTML = SVG_ICONS.terminal; + terminalBtn.addEventListener('click', () => { + runAction({ + command: 'openTerminal', + repo: this.currentRepo, + name: this.gitRepos[this.currentRepo].name || getRepoName(this.currentRepo) + }, 'Opening Terminal'); + }); + } + + + /* Loading Data */ + + public loadRepos(repos: GG.GitRepoSet, lastActiveRepo: string | null, loadViewTo: GG.LoadGitGraphViewTo) { + this.gitRepos = repos; + this.saveState(); + + let newRepo: string; + if (loadViewTo !== null && this.currentRepo !== loadViewTo.repo && typeof repos[loadViewTo.repo] !== 'undefined') { + newRepo = loadViewTo.repo; + } else if (typeof repos[this.currentRepo] === 'undefined') { + newRepo = lastActiveRepo !== null && typeof repos[lastActiveRepo] !== 'undefined' + ? lastActiveRepo + : getSortedRepositoryPaths(repos, this.config.repoDropdownOrder)[0]; + } else { + newRepo = this.currentRepo; + } + + alterClass(this.controlsElem, 'singleRepo', Object.keys(repos).length === 1); + this.renderRepoDropdownOptions(newRepo); + + if (loadViewTo !== null) { + if (loadViewTo.repo === newRepo) { + this.loadViewTo = loadViewTo; + } else { + this.loadViewTo = null; + showErrorMessage('Unable to load the Git Graph View for the repository "' + loadViewTo.repo + '". It is not currently included in Git Graph.'); + } + } else { + this.loadViewTo = null; + } + + if (this.currentRepo !== newRepo) { + this.loadRepo(newRepo); + return true; + } else { + this.finaliseRepoLoad(false); + return false; + } + } + + private loadRepo(repo: string) { + this.currentRepo = repo; + this.currentRepoLoading = true; + this.showRemoteBranchesElem.checked = getShowRemoteBranches(this.gitRepos[this.currentRepo].showRemoteBranchesV2); + this.simplifyByDecorationElem.checked = getSimplifyByDecoration(this.gitRepos[this.currentRepo].simplifyByDecoration); + this.maxCommits = this.config.initialLoadCommits; + this.gitConfig = null; + this.gitRemotes = []; + this.gitStashes = []; + this.gitTags = []; + this.currentBranches = null; + this.currentAuthors = null; + this.renderFetchButton(); + this.closeCommitDetails(false); + this.settingsWidget.close(); + this.saveState(); + this.refresh(true); + } + + private loadRepoInfo(branchOptions: ReadonlyArray, branchHead: string | null, remotes: ReadonlyArray, stashes: ReadonlyArray, isRepo: boolean) { + // Changes to this.gitStashes are reflected as changes to the commits when loadCommits is run + this.gitStashes = stashes; + + if (!isRepo || (!this.currentRepoRefreshState.hard && arraysStrictlyEqual(this.gitBranches, branchOptions) && this.gitBranchHead === branchHead && arraysStrictlyEqual(this.gitRemotes, remotes))) { + this.saveState(); + this.finaliseLoadRepoInfo(false, isRepo); + return; + } + + // Changes to these properties must be indicated as a repository info change + this.gitBranches = branchOptions; + this.gitBranchHead = branchHead; + this.gitRemotes = remotes; + + // Update the state of the fetch button + this.renderFetchButton(); + + const filterCurrentBranches = () => { + // Configure current branches + if (this.currentBranches !== null && !(this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES)) { + // Filter any branches that are currently selected, but no longer exist + const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); + this.currentBranches = this.currentBranches.filter((branch) => + this.gitBranches.includes(branch) || globPatterns.includes(branch) || branch === 'HEAD' + ); + } + }; + + filterCurrentBranches(); + if (this.currentBranches === null || this.currentBranches.length === 0) { + // No branches are currently selected + const onRepoLoadShowCheckedOutBranch = getOnRepoLoadShowCheckedOutBranch(this.gitRepos[this.currentRepo].onRepoLoadShowCheckedOutBranch); + const onRepoLoadShowSpecificBranches = getOnRepoLoadShowSpecificBranches(this.gitRepos[this.currentRepo].onRepoLoadShowSpecificBranches); + this.currentBranches = []; + if (onRepoLoadShowSpecificBranches.length > 0) { + // Show specific branches if they exist in the repository + const globPatterns = this.config.customBranchGlobPatterns.map((pattern) => pattern.glob); + this.currentBranches.push(...onRepoLoadShowSpecificBranches.filter((branch) => + this.gitBranches.includes(branch) || globPatterns.includes(branch) + )); + } + if (onRepoLoadShowCheckedOutBranch && this.gitBranchHead !== null && !this.currentBranches.includes(this.gitBranchHead)) { + // Show the checked-out branch, and it hasn't already been added as a specific branch + this.currentBranches.push(this.gitBranchHead); + } + if (this.currentBranches.length === 0) { + this.currentBranches.push(SHOW_ALL_BRANCHES); + } + } + filterCurrentBranches(); + + this.saveState(); + + // Set up branch dropdown options + this.branchDropdown.setOptions(this.getBranchOptions(true), this.currentBranches); + this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); + + // Remove hidden remotes that no longer exist + let hiddenRemotes = this.gitRepos[this.currentRepo].hideRemotes; + let hideRemotes = hiddenRemotes.filter((hiddenRemote) => remotes.includes(hiddenRemote)); + if (hiddenRemotes.length !== hideRemotes.length) { + this.saveRepoStateValue(this.currentRepo, 'hideRemotes', hideRemotes); + } + + this.finaliseLoadRepoInfo(true, isRepo); + } + + private finaliseLoadRepoInfo(repoInfoChanges: boolean, isRepo: boolean) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + if (isRepo) { + refreshState.repoInfoChanges = refreshState.repoInfoChanges || repoInfoChanges; + refreshState.requestingRepoInfo = false; + this.requestLoadCommits(); + } else { + dialog.closeActionRunning(); + refreshState.inProgress = false; + this.loadViewTo = null; + this.renderRefreshButton(); + sendMessage({ command: 'loadRepos', check: true }); + } + } + } + + private loadCommits(commits: GG.GitCommit[], commitHead: string | null, tags: ReadonlyArray, moreAvailable: boolean, onlyFollowFirstParent: boolean) { + // This list of tags is just used to provide additional information in the dialogs. Tag information included in commits is used for all other purposes (e.g. rendering, context menus) + const tagsChanged = !arraysStrictlyEqual(this.gitTags, tags); + this.gitTags = tags; + + if (!this.currentRepoLoading && !this.currentRepoRefreshState.hard && this.moreCommitsAvailable === moreAvailable && this.onlyFollowFirstParent === onlyFollowFirstParent && this.commitHead === commitHead && commits.length > 0 && arraysEqual(this.commits, commits, (a, b) => + a.hash === b.hash && + arraysStrictlyEqual(a.heads, b.heads) && + arraysEqual(a.tags, b.tags, (a, b) => a.name === b.name && a.annotated === b.annotated) && + arraysEqual(a.remotes, b.remotes, (a, b) => a.name === b.name && a.remote === b.remote) && + arraysStrictlyEqual(a.parents, b.parents) && + ((a.stash === null && b.stash === null) || (a.stash !== null && b.stash !== null && a.stash.selector === b.stash.selector)) + ) && this.renderedGitBranchHead === this.gitBranchHead) { + + if (this.commits[0].hash === UNCOMMITTED) { + this.commits[0] = commits[0]; + this.saveState(); + this.renderUncommittedChanges(); + if (this.expandedCommit !== null && this.expandedCommit.commitElem !== null) { + if (this.expandedCommit.compareWithHash === null) { + // Commit Details View is open + if (this.expandedCommit.commitHash === UNCOMMITTED) { + this.requestCommitDetails(this.expandedCommit.commitHash, true); + } + } else { + // Commit Comparison is open + if (this.expandedCommit.compareWithElem !== null && (this.expandedCommit.commitHash === UNCOMMITTED || this.expandedCommit.compareWithHash === UNCOMMITTED)) { + this.requestCommitComparison(this.expandedCommit.commitHash, this.expandedCommit.compareWithHash, true); + } + } + } + } else if (tagsChanged) { + this.saveState(); + } + this.finaliseLoadCommits(); + return; + } + + const currentRepoLoading = this.currentRepoLoading; + this.currentRepoLoading = false; + this.moreCommitsAvailable = moreAvailable; + this.onlyFollowFirstParent = onlyFollowFirstParent; + this.commits = commits; + this.commitHead = commitHead; + this.commitLookup = {}; + + let i: number, expandedCommitVisible = false, expandedCompareWithCommitVisible = false, avatarsNeeded: { [email: string]: string[] } = {}, commit; + for (i = 0; i < this.commits.length; i++) { + commit = this.commits[i]; + this.commitLookup[commit.hash] = i; + if (this.expandedCommit !== null) { + if (this.expandedCommit.commitHash === commit.hash) { + expandedCommitVisible = true; + } else if (this.expandedCommit.compareWithHash === commit.hash) { + expandedCompareWithCommitVisible = true; + } + } + if (this.config.fetchAvatars && typeof this.avatars[commit.email] !== 'string' && commit.email !== '') { + if (typeof avatarsNeeded[commit.email] === 'undefined') { + avatarsNeeded[commit.email] = [commit.hash]; + } else { + avatarsNeeded[commit.email].push(commit.hash); + } + } + } + + if (this.expandedCommit !== null && (!expandedCommitVisible || (this.expandedCommit.compareWithHash !== null && !expandedCompareWithCommitVisible))) { + this.closeCommitDetails(false); + } + + this.saveState(); + + this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); + this.render(); + + if (currentRepoLoading && this.config.onRepoLoad.scrollToHead && this.commitHead !== null) { + this.scrollToCommit(this.commitHead, true); + } + + this.finaliseLoadCommits(); + this.requestAvatars(avatarsNeeded); + } + + private finaliseLoadCommits() { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + dialog.closeActionRunning(); + + if (dialog.isTargetDynamicSource()) { + if (refreshState.repoInfoChanges) { + dialog.close(); + } else { + dialog.refresh(this.getCommits()); + } + } + + if (contextMenu.isTargetDynamicSource()) { + if (refreshState.repoInfoChanges) { + contextMenu.close(); + } else { + contextMenu.refresh(this.getCommits()); + } + } + + refreshState.inProgress = false; + this.renderRefreshButton(); + } + + this.finaliseRepoLoad(true); + + if (this.scrollToCommitArgs.persistently) { + this.scrollToCommit(this.scrollToCommitArgs.hash, this.scrollToCommitArgs.alwaysCenterCommit, this.scrollToCommitArgs.flash, this.scrollToCommitArgs.openDetails, this.scrollToCommitArgs.persistently); + } + } + + private finaliseRepoLoad(didLoadRepoData: boolean) { + if (this.loadViewTo !== null && this.currentRepo === this.loadViewTo.repo) { + if (this.loadViewTo.commitDetails && (this.expandedCommit === null || this.expandedCommit.commitHash !== this.loadViewTo.commitDetails.commitHash || this.expandedCommit.compareWithHash !== this.loadViewTo.commitDetails.compareWithHash)) { + const commitIndex = this.getCommitId(this.loadViewTo.commitDetails.commitHash); + const compareWithIndex = this.loadViewTo.commitDetails.compareWithHash !== null ? this.getCommitId(this.loadViewTo.commitDetails.compareWithHash) : null; + const commitElems = getCommitElems(); + const commitElem = findCommitElemWithId(commitElems, commitIndex); + const compareWithElem = findCommitElemWithId(commitElems, compareWithIndex); + + if (commitElem !== null && (this.loadViewTo.commitDetails.compareWithHash === null || compareWithElem !== null)) { + if (compareWithElem !== null) { + this.loadCommitComparison(commitElem, compareWithElem); + } else { + this.loadCommitDetails(commitElem); + } + } else { + showErrorMessage('Unable to resume Code Review, it could not be found in the latest ' + this.maxCommits + ' commits that were loaded in this repository.'); + } + } else if (this.loadViewTo.runCommandOnLoad) { + switch (this.loadViewTo.runCommandOnLoad) { + case 'fetch': + this.fetchFromRemotesAction(); + break; + } + } + } + this.loadViewTo = null; + + if (this.gitConfig === null || (didLoadRepoData && this.currentRepoRefreshState.configChanges)) { + this.requestLoadConfig(); + } + } + + private clearCommits() { + closeDialogAndContextMenu(); + this.moreCommitsAvailable = false; + this.commits = []; + this.commitHead = null; + this.commitLookup = {}; + this.renderedGitBranchHead = null; + this.closeCommitDetails(false); + this.saveState(); + this.graph.loadCommits(this.commits, this.commitHead, this.commitLookup, this.onlyFollowFirstParent); + this.tableElem.innerHTML = ''; + this.footerElem.innerHTML = ''; + this.renderGraph(); + this.findWidget.refresh(); + } + + public processLoadRepoInfoResponse(msg: GG.ResponseLoadRepoInfo) { + if (msg.error === null) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress && refreshState.loadRepoInfoRefreshId === msg.refreshId) { + this.loadRepoInfo(msg.branches, msg.head, msg.remotes, msg.stashes, msg.isRepo); + } + } else { + this.displayLoadDataError('Unable to load Repository Info', msg.error); + } + } + + public processLoadCommitsResponse(msg: GG.ResponseLoadCommits) { + if (msg.error === null) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress && refreshState.loadCommitsRefreshId === msg.refreshId) { + this.loadCommits(msg.commits, msg.head, msg.tags, msg.moreCommitsAvailable, msg.onlyFollowFirstParent); + } + } else { + const error = this.gitBranches.length === 0 && msg.error.indexOf('bad revision \'HEAD\'') > -1 + ? 'There are no commits in this repository.' + : msg.error; + this.displayLoadDataError('Unable to load Commits', error); + } + } + + public processLoadConfig(msg: GG.ResponseLoadConfig) { + this.currentRepoRefreshState.requestingConfig = false; + if (msg.config !== null && this.currentRepo === msg.repo) { + this.gitConfig = msg.config; + this.saveState(); + + this.renderCdvExternalDiffBtn(); + } + this.settingsWidget.refresh(); + this.authorDropdown.setOptions(this.getAuthorOptions(), this.currentAuthors); + } + + private displayLoadDataError(message: string, reason: string) { + this.clearCommits(); + this.currentRepoRefreshState.inProgress = false; + this.loadViewTo = null; + this.renderRefreshButton(); + dialog.showError(message, reason, 'Retry', () => { + this.refresh(true); + }); + } + + public loadAvatar(email: string, image: string) { + this.avatars[email] = image; + this.saveState(); + let avatarsElems = >document.getElementsByClassName('avatar'), escapedEmail = escapeHtml(email); + for (let i = 0; i < avatarsElems.length; i++) { + if (avatarsElems[i].dataset.email === escapedEmail) { + avatarsElems[i].innerHTML = ''; + } + } + } + + + /* Getters */ + + public getBranches(): ReadonlyArray { + return this.gitBranches; + } + + public getBranchOptions(includeShowAll?: boolean): ReadonlyArray { + const options: DialogSelectInputOption[] = []; + if (includeShowAll) { + options.push({ name: 'Show All', value: SHOW_ALL_BRANCHES }); + } + options.push({ name: 'HEAD', value: 'HEAD' }); + for (let i = 0; i < this.config.customBranchGlobPatterns.length; i++) { + options.push({ name: 'Glob: ' + this.config.customBranchGlobPatterns[i].name, value: this.config.customBranchGlobPatterns[i].glob }); + } + for (let i = 0; i < this.gitBranches.length; i++) { + options.push({ name: this.gitBranches[i].indexOf('remotes/') === 0 ? this.gitBranches[i].substring(8) : this.gitBranches[i], value: this.gitBranches[i] }); + } + return options; + } + public getAuthorOptions(): ReadonlyArray { + const options: DialogSelectInputOption[] = []; + options.push({ name: 'All', value: SHOW_ALL_BRANCHES }); + if (this.gitConfig && this.gitConfig.authors) { + for (let i = 0; i < this!.gitConfig!.authors.length; i++) { + const author = this!.gitConfig!.authors[i]; + options.push({ name: author.name, value: author.name }); + } + } + return options; + } + public getCommitId(hash: string) { + if (typeof this.commitLookup[hash] === 'number') { + return this.commitLookup[hash]; + } + // If a full match isn't found, try to find a matching partial hash + for (const key in this.commitLookup) { + if (key.startsWith(hash)) { + return this.commitLookup[key]; + } + } + return null; + } + + private getCommitOfElem(elem: HTMLElement) { + let id = parseInt(elem.dataset.id!); + return id < this.commits.length ? this.commits[id] : null; + } + + public getCommits(): ReadonlyArray { + return this.commits; + } + + private getPushRemote(branch: string | null = null) { + const possibleRemotes = []; + if (this.gitConfig !== null) { + if (branch !== null && typeof this.gitConfig.branches[branch] !== 'undefined') { + possibleRemotes.push(this.gitConfig.branches[branch].pushRemote, this.gitConfig.branches[branch].remote); + } + possibleRemotes.push(this.gitConfig.pushDefault); + } + possibleRemotes.push('origin'); + return possibleRemotes.find((remote) => remote !== null && this.gitRemotes.includes(remote)) || this.gitRemotes[0]; + } + + public getRepoConfig(): Readonly | null { + return this.gitConfig; + } + + public getRepoState(repo: string): Readonly | null { + return typeof this.gitRepos[repo] !== 'undefined' + ? this.gitRepos[repo] + : null; + } + + public isConfigLoading(): boolean { + return this.currentRepoRefreshState.requestingConfig; + } + + + /* Refresh */ + + public refresh(hard: boolean, configChanges: boolean = false) { + if (hard) { + this.clearCommits(); + } + this.requestLoadRepoInfoAndCommits(hard, false, configChanges); + } + + + /* Requests */ + + private requestLoadRepoInfo() { + const repoState = this.gitRepos[this.currentRepo]; + sendMessage({ + command: 'loadRepoInfo', + repo: this.currentRepo, + refreshId: ++this.currentRepoRefreshState.loadRepoInfoRefreshId, + showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), + simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), + showStashes: getShowStashes(repoState.showStashes), + hideRemotes: repoState.hideRemotes + }); + } + + private requestLoadCommits() { + const repoState = this.gitRepos[this.currentRepo]; + sendMessage({ + command: 'loadCommits', + repo: this.currentRepo, + refreshId: ++this.currentRepoRefreshState.loadCommitsRefreshId, + branches: this.currentBranches === null || (this.currentBranches.length === 1 && this.currentBranches[0] === SHOW_ALL_BRANCHES) ? null : this.currentBranches, + authors: this.currentAuthors === null || (this.currentAuthors.length === 1 && this.currentAuthors[0] === SHOW_ALL_BRANCHES) ? null : this.currentAuthors, + maxCommits: this.maxCommits, + showTags: getShowTags(repoState.showTags), + showRemoteBranches: getShowRemoteBranches(repoState.showRemoteBranchesV2), + simplifyByDecoration: getSimplifyByDecoration(repoState.simplifyByDecoration), + includeCommitsMentionedByReflogs: getIncludeCommitsMentionedByReflogs(repoState.includeCommitsMentionedByReflogs), + onlyFollowFirstParent: getOnlyFollowFirstParent(repoState.onlyFollowFirstParent), + commitOrdering: getCommitOrdering(repoState.commitOrdering), + remotes: this.gitRemotes, + hideRemotes: repoState.hideRemotes, + stashes: this.gitStashes + }); + } + + private requestLoadRepoInfoAndCommits(hard: boolean, skipRepoInfo: boolean, configChanges: boolean = false) { + const refreshState = this.currentRepoRefreshState; + if (refreshState.inProgress) { + refreshState.hard = refreshState.hard || hard; + refreshState.configChanges = refreshState.configChanges || configChanges; + if (!skipRepoInfo) { + // This request will trigger a loadCommit request after the loadRepoInfo request has completed. + // Invalidate any previous commit requests in progress. + refreshState.loadCommitsRefreshId++; + } + } else { + refreshState.hard = hard; + refreshState.inProgress = true; + refreshState.repoInfoChanges = false; + refreshState.configChanges = configChanges; + refreshState.requestingRepoInfo = false; + } + + this.renderRefreshButton(); + if (this.commits.length === 0) { + this.tableElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; + } + + if (skipRepoInfo) { + if (!refreshState.requestingRepoInfo) { + this.requestLoadCommits(); + } + } else { + refreshState.requestingRepoInfo = true; + this.requestLoadRepoInfo(); + } + } + + public requestLoadConfig() { + this.currentRepoRefreshState.requestingConfig = true; + sendMessage({ command: 'loadConfig', repo: this.currentRepo, remotes: this.gitRemotes }); + this.settingsWidget.refresh(); + } + + public requestCommitDetails(hash: string, refresh: boolean) { + let commit = this.commits[this.commitLookup[hash]]; + sendMessage({ + command: 'commitDetails', + repo: this.currentRepo, + commitHash: hash, + hasParents: commit.parents.length > 0, + stash: commit.stash, + avatarEmail: this.config.fetchAvatars && hash !== UNCOMMITTED ? commit.email : null, + refresh: refresh + }); + } + + public requestCommitComparison(hash: string, compareWithHash: string, refresh: boolean) { + let commitOrder = this.getCommitOrder(hash, compareWithHash); + sendMessage({ + command: 'compareCommits', + repo: this.currentRepo, + commitHash: hash, compareWithHash: compareWithHash, + fromHash: commitOrder.from, toHash: commitOrder.to, + refresh: refresh + }); + } + + private requestAvatars(avatars: { [email: string]: string[] }) { + let emails = Object.keys(avatars), remote = this.gitRemotes.length > 0 ? this.gitRemotes.includes('origin') ? 'origin' : this.gitRemotes[0] : null; + for (let i = 0; i < emails.length; i++) { + sendMessage({ command: 'fetchAvatar', repo: this.currentRepo, remote: remote, email: emails[i], commits: avatars[emails[i]] }); + } + } + + + /* State */ + + public saveState() { + let expandedCommit; + if (this.expandedCommit !== null) { + expandedCommit = Object.assign({}, this.expandedCommit); + expandedCommit.commitElem = null; + expandedCommit.compareWithElem = null; + expandedCommit.contextMenuOpen = { + summary: false, + fileView: -1 + }; + } else { + expandedCommit = null; + } + + VSCODE_API.setState({ + currentRepo: this.currentRepo, + currentRepoLoading: this.currentRepoLoading, + gitRepos: this.gitRepos, + gitBranches: this.gitBranches, + gitBranchHead: this.gitBranchHead, + gitConfig: this.gitConfig, + gitRemotes: this.gitRemotes, + gitStashes: this.gitStashes, + gitTags: this.gitTags, + commits: this.commits, + commitHead: this.commitHead, + avatars: this.avatars, + currentBranches: this.currentBranches, + currentAuthors: this.currentAuthors, + moreCommitsAvailable: this.moreCommitsAvailable, + maxCommits: this.maxCommits, + onlyFollowFirstParent: this.onlyFollowFirstParent, + expandedCommit: expandedCommit, + scrollTop: this.scrollTop, + findWidget: this.findWidget.getState(), + settingsWidget: this.settingsWidget.getState() + }); + } + + public saveRepoState() { + sendMessage({ command: 'setRepoState', repo: this.currentRepo, state: this.gitRepos[this.currentRepo] }); + } + + private saveColumnWidths(columnWidths: GG.ColumnWidth[]) { + this.gitRepos[this.currentRepo].columnWidths = [columnWidths[0], columnWidths[2], columnWidths[3], columnWidths[4]]; + this.saveRepoState(); + } + + private saveExpandedCommitLoading(index: number, commitHash: string, commitElem: HTMLElement, compareWithHash: string | null, compareWithElem: HTMLElement | null) { + this.expandedCommit = { + index: index, + commitHash: commitHash, + commitElem: commitElem, + compareWithHash: compareWithHash, + compareWithElem: compareWithElem, + commitDetails: null, + fileChanges: null, + fileTree: null, + avatar: null, + codeReview: null, + lastViewedFile: null, + loading: true, + scrollTop: { + summary: 0, + fileView: 0 + }, + contextMenuOpen: { + summary: false, + fileView: -1 + } + }; + this.saveState(); + } + + public saveRepoStateValue(repo: string, key: K, value: GG.GitRepoState[K]) { + if (repo === this.currentRepo) { + this.gitRepos[this.currentRepo][key] = value; + this.saveRepoState(); + } + } + + + /* Renderers */ + + private render() { + this.renderTable(); + this.renderGraph(); + } + + private renderGraph() { + if (typeof this.currentRepo === 'undefined') { + // Only render the graph if a repo is loaded (or a repo is currently being loaded) + return; + } + + const colHeadersElem = document.getElementById('tableColHeaders'); + const cdvHeight = this.gitRepos[this.currentRepo].isCdvSummaryHidden ? 0 : this.gitRepos[this.currentRepo].cdvHeight; + const headerHeight = colHeadersElem !== null ? colHeadersElem.clientHeight + 1 : 0; + const expandedCommit = this.isCdvDocked() ? null : this.expandedCommit; + const expandedCommitElem = expandedCommit !== null ? document.getElementById('cdv') : null; + + // Update the graphs grid dimensions + this.config.graph.grid.expandY = expandedCommitElem !== null + ? expandedCommitElem.getBoundingClientRect().height + : cdvHeight; + this.config.graph.grid.y = this.commits.length > 0 && this.tableElem.children.length > 0 + ? (this.tableElem.children[0].clientHeight - headerHeight - (expandedCommit !== null ? cdvHeight : 0)) / this.commits.length + : this.config.graph.grid.y; + this.config.graph.grid.offsetY = headerHeight + this.config.graph.grid.y / 2; + + this.graph.render(expandedCommit); + } + + private renderTable() { + const colVisibility = this.getColumnVisibility(); + const currentHash = this.commits.length > 0 && this.commits[0].hash === UNCOMMITTED ? UNCOMMITTED : this.commitHead; + const vertexColours = this.graph.getVertexColours(); + const widthsAtVertices = this.config.referenceLabels.branchLabelsAlignedToGraph ? this.graph.getWidthsAtVertices() : []; + const mutedCommits = this.graph.getMutedCommits(currentHash); + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + emoji: true, + issueLinking: true, + markdown: this.config.markdown + }); + + let html = 'GraphDescription' + + (colVisibility.date ? 'Date' : '') + + (colVisibility.author ? 'Author' : '') + + (colVisibility.commit ? 'Commit' : '') + + ''; + + for (let i = 0; i < this.commits.length; i++) { + let commit = this.commits[i]; + let message = '' + textFormatter.format(commit.message) + ''; + let date = formatShortDate(commit.date); + let branchLabels = getBranchLabels(commit.heads, commit.remotes); + let refBranches = '', refTags = '', j, k, refName, remoteName, refActive, refHtml, branchCheckedOutAtCommit: string | null = null; + + for (j = 0; j < branchLabels.heads.length; j++) { + refName = escapeHtml(branchLabels.heads[j].name); + refActive = branchLabels.heads[j].name === this.gitBranchHead; + refHtml = '' + SVG_ICONS.branch + '' + refName + ''; + for (k = 0; k < branchLabels.heads[j].remotes.length; k++) { + remoteName = escapeHtml(branchLabels.heads[j].remotes[k]); + refHtml += '' + remoteName + ''; + } + refHtml += ''; + refBranches = refActive ? refHtml + refBranches : refBranches + refHtml; + if (refActive) branchCheckedOutAtCommit = this.gitBranchHead; + } + for (j = 0; j < branchLabels.remotes.length; j++) { + refName = escapeHtml(branchLabels.remotes[j].name); + refBranches += '' + SVG_ICONS.branch + '' + refName + ''; + } + + for (j = 0; j < commit.tags.length; j++) { + refName = escapeHtml(commit.tags[j].name); + refTags += '' + SVG_ICONS.tag + '' + refName + ''; + } + + if (commit.stash !== null) { + refName = escapeHtml(commit.stash.selector); + refBranches = '' + SVG_ICONS.stash + '' + escapeHtml(commit.stash.selector.substring(5)) + '' + refBranches; + } + + const commitDot = commit.hash === this.commitHead + ? '' + : ''; + + html += '' + + (this.config.referenceLabels.branchLabelsAlignedToGraph ? '' + getResizeColHtml(0) + (refBranches !== '' ? '' + getResizeColHtml(1) + '' + commitDot : '' + getResizeColHtml(0) + '' + getResizeColHtml(1) + '' + commitDot + refBranches) + (this.config.referenceLabels.tagLabelsOnRight ? message + refTags : refTags + message) + '' + + (colVisibility.date ? '' + getResizeColHtml(2) + date.formatted + '' : '') + + (colVisibility.author ? '' + getResizeColHtml(3) + (this.config.fetchAvatars ? '' + (typeof this.avatars[commit.email] === 'string' ? '' : '') + '' : '') + escapeHtml(commit.author) + '' : '') + + (colVisibility.commit ? '' + getResizeColHtml(4) + abbrevCommit(commit.hash) + '' : '') + + ''; + + + } + function getResizeColHtml(col: number) { + return (col > 0 ? '' : '') + (col < 4 ? '' : ''); + } + this.tableElem.innerHTML = '' + html + '
    '; + this.footerElem.innerHTML = this.moreCommitsAvailable ? '
    Load More Commits
    ' : ''; + this.makeTableResizable(); + this.findWidget.refresh(); + this.renderedGitBranchHead = this.gitBranchHead; + + if (this.moreCommitsAvailable) { + document.getElementById('loadMoreCommitsBtn')!.addEventListener('click', () => { + this.loadMoreCommits(); + }); + } + + if (this.expandedCommit !== null) { + const expandedCommit = this.expandedCommit, elems = getCommitElems(); + const commitElem = findCommitElemWithId(elems, this.getCommitId(expandedCommit.commitHash)); + const compareWithElem = expandedCommit.compareWithHash !== null ? findCommitElemWithId(elems, this.getCommitId(expandedCommit.compareWithHash)) : null; + + if (commitElem === null || (expandedCommit.compareWithHash !== null && compareWithElem === null)) { + this.closeCommitDetails(false); + this.saveState(); + } else { + expandedCommit.index = parseInt(commitElem.dataset.id!); + expandedCommit.commitElem = commitElem; + expandedCommit.compareWithElem = compareWithElem; + this.saveState(); + if (expandedCommit.compareWithHash === null) { + // Commit Details View is open + if (!expandedCommit.loading && expandedCommit.commitDetails !== null && expandedCommit.fileTree !== null) { + this.showCommitDetails(expandedCommit.commitDetails, expandedCommit.fileTree, expandedCommit.avatar, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); + if (expandedCommit.commitHash === UNCOMMITTED) { + this.requestCommitDetails(expandedCommit.commitHash, true); + } + } else { + this.loadCommitDetails(commitElem); + } + } else { + // Commit Comparison is open + if (!expandedCommit.loading && expandedCommit.fileChanges !== null && expandedCommit.fileTree !== null) { + this.showCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, expandedCommit.fileChanges, expandedCommit.fileTree, expandedCommit.codeReview, expandedCommit.lastViewedFile, true); + if (expandedCommit.commitHash === UNCOMMITTED || expandedCommit.compareWithHash === UNCOMMITTED) { + this.requestCommitComparison(expandedCommit.commitHash, expandedCommit.compareWithHash, true); + } + } else { + this.loadCommitComparison(commitElem, compareWithElem!); + } + } + } + } + + if (this.config.stickyHeader) { + this.tableColHeadersElem = document.getElementById('tableColHeaders'); + this.alignTableHeaderToControls(); + } + } + + private renderUncommittedChanges() { + const colVisibility = this.getColumnVisibility(), date = formatShortDate(this.commits[0].date); + document.getElementById('uncommittedChanges')!.innerHTML = '' + escapeHtml(this.commits[0].message) + '' + + (colVisibility.date ? '' + date.formatted + '' : '') + + (colVisibility.author ? '*' : '') + + (colVisibility.commit ? '*' : ''); + } + + private renderFetchButton() { + alterClass(this.controlsElem, CLASS_FETCH_SUPPORTED, this.gitRemotes.length > 0); + } + + public renderRefreshButton() { + const enabled = !this.currentRepoRefreshState.inProgress; + this.refreshBtnElem.title = enabled ? 'Refresh' : 'Refreshing'; + this.refreshBtnElem.innerHTML = enabled ? SVG_ICONS.refresh : SVG_ICONS.loading; + alterClass(this.refreshBtnElem, CLASS_REFRESHING, !enabled); + } + + public renderTagDetails(tagName: string, commitHash: string, details: GG.GitTagDetails) { + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + commits: true, + emoji: true, + issueLinking: true, + markdown: this.config.markdown, + multiline: true, + urls: true + }); + dialog.showMessage( + 'Tag ' + escapeHtml(tagName) + '
    ' + + 'Object: ' + escapeHtml(details.hash) + '
    ' + + 'Commit: ' + escapeHtml(commitHash) + '
    ' + + 'Tagger: ' + escapeHtml(details.taggerName) + ' <' + escapeHtml(details.taggerEmail) + '>' + (details.signature !== null ? generateSignatureHtml(details.signature) : '') + '
    ' + + 'Date: ' + formatLongDate(details.taggerDate) + '

    ' + + textFormatter.format(details.message) + + '
    ' + ); + } + + public renderRepoDropdownOptions(repo?: string) { + this.repoDropdown.setOptions(getRepoDropdownOptions(this.gitRepos), [repo || this.currentRepo]); + } + + + /* Context Menu Generation */ + + private getBranchContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { + const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.branch; + const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(refName); + + return [[ + { + title: 'Checkout Branch', + visible: visibility.checkout && this.gitBranchHead !== refName, + onClick: () => this.checkoutBranchAction(refName, null, null, target) + }, { + title: 'Rename Branch' + ELLIPSIS, + visible: visibility.rename, + onClick: () => { + dialog.showRefInput('Enter the new name for branch ' + escapeHtml(refName) + ':', refName, 'Rename Branch', (newName) => { + runAction({ command: 'renameBranch', repo: this.currentRepo, oldName: refName, newName: newName }, 'Renaming Branch'); + }, target); + } + }, { + title: 'Delete Branch' + ELLIPSIS, + visible: visibility.delete && this.gitBranchHead !== refName, + onClick: () => { + let remotesWithBranch = this.gitRemotes.filter(remote => this.gitBranches.includes('remotes/' + remote + '/' + refName)); + let inputs: DialogInput[] = [{ type: DialogInputType.Checkbox, name: 'Force Delete', value: this.config.dialogDefaults.deleteBranch.forceDelete }]; + if (remotesWithBranch.length > 0) { + inputs.push({ + type: DialogInputType.Checkbox, + name: 'Delete this branch on the remote' + (this.gitRemotes.length > 1 ? 's' : ''), + value: false, + info: 'This branch is on the remote' + (remotesWithBranch.length > 1 ? 's: ' : ' ') + formatCommaSeparatedList(remotesWithBranch.map((remote) => '"' + remote + '"')) + }); + } + dialog.showForm('Are you sure you want to delete the branch ' + escapeHtml(refName) + '?', inputs, 'Yes, delete', (values) => { + runAction({ command: 'deleteBranch', repo: this.currentRepo, branchName: refName, forceDelete: values[0], deleteOnRemotes: remotesWithBranch.length > 0 && values[1] ? remotesWithBranch : [] }, 'Deleting Branch'); + }, target); + } + }, { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge && this.gitBranchHead !== refName, + onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.Branch, target) + }, { + title: 'Rebase current Branch on Branch' + ELLIPSIS, + visible: visibility.rebase && this.gitBranchHead !== refName, + onClick: () => this.rebaseAction(refName, refName, GG.RebaseActionOn.Branch, target) + }, { + title: 'Push Branch' + ELLIPSIS, + visible: visibility.push && this.gitRemotes.length > 0, + onClick: () => { + const multipleRemotes = this.gitRemotes.length > 1; + const inputs: DialogInput[] = [ + { type: DialogInputType.Checkbox, name: 'Set Upstream', value: true }, + { + type: DialogInputType.Radio, + name: 'Push Mode', + options: [ + { name: 'Normal', value: GG.GitPushBranchMode.Normal }, + { name: 'Force With Lease', value: GG.GitPushBranchMode.ForceWithLease }, + { name: 'Force', value: GG.GitPushBranchMode.Force } + ], + default: GG.GitPushBranchMode.Normal + } + ]; + + if (multipleRemotes) { + inputs.unshift({ + type: DialogInputType.Select, + name: 'Push to Remote(s)', + defaults: [this.getPushRemote(refName)], + options: this.gitRemotes.map((remote) => ({ name: remote, value: remote })), + multiple: true + }); + } + + dialog.showForm('Are you sure you want to push the branch ' + escapeHtml(refName) + '' + (multipleRemotes ? '' : ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '') + '?', inputs, 'Yes, push', (values) => { + const remotes = multipleRemotes ? values.shift() : [this.gitRemotes[0]]; + const setUpstream = values[0]; + runAction({ + command: 'pushBranch', + repo: this.currentRepo, + branchName: refName, + remotes: remotes, + setUpstream: setUpstream, + mode: values[1], + willUpdateBranchConfig: setUpstream && remotes.length > 0 && (this.gitConfig === null || typeof this.gitConfig.branches[refName] === 'undefined' || this.gitConfig.branches[refName].remote !== remotes[remotes.length - 1]) + }, 'Pushing Branch'); + }, target); + } + } + ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), + { + title: 'Create Pull Request' + ELLIPSIS, + visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null, + onClick: () => { + const config = this.gitRepos[this.currentRepo].pullRequestConfig; + if (config === null) return; + dialog.showCheckbox('Are you sure you want to create a Pull Request for branch ' + escapeHtml(refName) + '?', 'Push branch before creating the Pull Request', true, 'Yes, create Pull Request', (push) => { + runAction({ command: 'createPullRequest', repo: this.currentRepo, config: config, sourceRemote: config.sourceRemote, sourceOwner: config.sourceOwner, sourceRepo: config.sourceRepo, sourceBranch: refName, push: push }, 'Creating Pull Request'); + }, target); + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); + } + }, + { + title: 'Select in Branches Dropdown', + visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.selectOption(refName) + }, + { + title: 'Unselect in Branches Dropdown', + visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.unselectOption(refName) + } + ], [ + { + title: 'Copy Branch Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); + } + } + ]]; + } + + private getCommitContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { + const hash = target.hash, visibility = this.config.contextMenuActionsVisibility.commit; + const commit = this.commits[this.commitLookup[hash]]; + return [[ + { + title: 'Add Tag' + ELLIPSIS, + visible: visibility.addTag, + onClick: () => this.addTagAction(hash, '', this.config.dialogDefaults.addTag.type, '', null, target) + }, { + title: 'Create Branch' + ELLIPSIS, + visible: visibility.createBranch, + onClick: () => this.createBranchAction(hash, '', this.config.dialogDefaults.createBranch.checkout, target) + } + ], [ + { + title: 'Checkout' + (globalState.alwaysAcceptCheckoutCommit ? '' : ELLIPSIS), + visible: visibility.checkout, + onClick: () => { + const checkoutCommit = () => runAction({ command: 'checkoutCommit', repo: this.currentRepo, commitHash: hash }, 'Checking out Commit'); + if (globalState.alwaysAcceptCheckoutCommit) { + checkoutCommit(); + } else { + dialog.showCheckbox('Are you sure you want to checkout commit ' + abbrevCommit(hash) + '? This will result in a \'detached HEAD\' state.', 'Always Accept', false, 'Yes, checkout', (alwaysAccept) => { + if (alwaysAccept) { + updateGlobalViewState('alwaysAcceptCheckoutCommit', true); + } + checkoutCommit(); + }, target); + } + } + }, { + title: 'Cherry Pick' + ELLIPSIS, + visible: visibility.cherrypick, + onClick: () => { + const isMerge = commit.parents.length > 1; + let inputs: DialogInput[] = []; + if (isMerge) { + let options = commit.parents.map((hash, index) => ({ + name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), + value: (index + 1).toString() + })); + inputs.push({ + type: DialogInputType.Select, + name: 'Parent Hash', + options: options, + default: '1', + info: 'Choose the parent hash on the main branch, to cherry pick the commit relative to.' + }); + } + inputs.push({ + type: DialogInputType.Checkbox, + name: 'Record Origin', + value: this.config.dialogDefaults.cherryPick.recordOrigin, + info: 'Record that this commit was the origin of the cherry pick by appending a line to the original commit message that states "(cherry picked from commit ...​)".' + }, { + type: DialogInputType.Checkbox, + name: 'No Commit', + value: this.config.dialogDefaults.cherryPick.noCommit, + info: 'Cherry picked changes will be staged but not committed, so that you can select and commit specific parts of this commit.' + }); + + dialog.showForm('Are you sure you want to cherry pick commit ' + abbrevCommit(hash) + '?', inputs, 'Yes, cherry pick', (values) => { + let parentIndex = isMerge ? parseInt(values.shift()) : 0; + runAction({ + command: 'cherrypickCommit', + repo: this.currentRepo, + commitHash: hash, + parentIndex: parentIndex, + recordOrigin: values[0], + noCommit: values[1] + }, 'Cherry picking Commit'); + }, target); + } + }, { + title: 'Revert' + ELLIPSIS, + visible: visibility.revert, + onClick: () => { + if (commit.parents.length > 1) { + let options = commit.parents.map((hash, index) => ({ + name: abbrevCommit(hash) + (typeof this.commitLookup[hash] === 'number' ? ': ' + this.commits[this.commitLookup[hash]].message : ''), + value: (index + 1).toString() + })); + dialog.showSelect('Are you sure you want to revert merge commit ' + abbrevCommit(hash) + '? Choose the parent hash on the main branch, to revert the commit relative to:', '1', options, 'Yes, revert', (parentIndex) => { + runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: parseInt(parentIndex) }, 'Reverting Commit'); + }, target); + } else { + dialog.showConfirmation('Are you sure you want to revert commit ' + abbrevCommit(hash) + '?', 'Yes, revert', () => { + runAction({ command: 'revertCommit', repo: this.currentRepo, commitHash: hash, parentIndex: 0 }, 'Reverting Commit'); + }, target); + } + } + }, { + title: 'Drop' + ELLIPSIS, + visible: visibility.drop && this.graph.dropCommitPossible(this.commitLookup[hash]), + onClick: () => { + dialog.showConfirmation('Are you sure you want to permanently drop commit ' + abbrevCommit(hash) + '?' + (this.onlyFollowFirstParent ? '
    Note: By enabling "Only follow the first parent of commits", some commits may have been hidden from the Git Graph View that could affect the outcome of performing this action.' : ''), 'Yes, drop', () => { + runAction({ command: 'dropCommit', repo: this.currentRepo, commitHash: hash }, 'Dropping Commit'); + }, target); + } + } + ], [ + { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge, + onClick: () => this.mergeAction(hash, abbrevCommit(hash), GG.MergeActionOn.Commit, target) + }, { + title: 'Rebase current Branch on this Commit' + ELLIPSIS, + visible: visibility.rebase, + onClick: () => this.rebaseAction(hash, abbrevCommit(hash), GG.RebaseActionOn.Commit, target) + }, { + title: 'Reset current branch to this Commit' + ELLIPSIS, + visible: visibility.reset, + onClick: () => { + dialog.showSelect('Are you sure you want to reset ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' to commit ' + abbrevCommit(hash) + '?', this.config.dialogDefaults.resetCommit.mode, [ + { name: 'Soft - Keep all changes, but reset head', value: GG.GitResetMode.Soft }, + { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, + { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } + ], 'Yes, reset', (mode) => { + runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: hash, resetMode: mode }, 'Resetting to Commit'); + }, target); + } + } + ], [ + { + title: 'Copy Commit Hash to Clipboard', + visible: visibility.copyHash, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Commit Hash', data: hash }); + } + }, + { + title: 'Copy Commit Subject to Clipboard', + visible: visibility.copySubject, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Commit Subject', data: commit.message }); + } + } + ]]; + } + + private getRemoteBranchContextMenuActions(remote: string, target: DialogTarget & RefTarget): ContextMenuActions { + const refName = target.ref, visibility = this.config.contextMenuActionsVisibility.remoteBranch; + const branchName = remote !== '' ? refName.substring(remote.length + 1) : ''; + const prefixedRefName = 'remotes/' + refName; + const isSelectedInBranchesDropdown = this.branchDropdown.isSelected(prefixedRefName); + return [[ + { + title: 'Checkout Branch' + ELLIPSIS, + visible: visibility.checkout, + onClick: () => this.checkoutBranchAction(refName, remote, null, target) + }, { + title: 'Delete Remote Branch' + ELLIPSIS, + visible: visibility.delete && remote !== '', + onClick: () => { + dialog.showConfirmation('Are you sure you want to delete the remote branch ' + escapeHtml(refName) + '?', 'Yes, delete', () => { + runAction({ command: 'deleteRemoteBranch', repo: this.currentRepo, branchName: branchName, remote: remote }, 'Deleting Remote Branch'); + }, target); + } + }, { + title: 'Fetch into local branch' + ELLIPSIS, + visible: visibility.fetch && remote !== '' && this.gitBranches.includes(branchName) && this.gitBranchHead !== branchName, + onClick: () => { + dialog.showForm('Are you sure you want to fetch the remote branch ' + escapeHtml(refName) + ' into the local branch ' + escapeHtml(branchName) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Force Fetch', + value: this.config.dialogDefaults.fetchIntoLocalBranch.forceFetch, + info: 'Force the local branch to be reset to this remote branch.' + }], 'Yes, fetch', (values) => { + runAction({ command: 'fetchIntoLocalBranch', repo: this.currentRepo, remote: remote, remoteBranch: branchName, localBranch: branchName, force: values[0] }, 'Fetching Branch'); + }, target); + } + }, { + title: 'Merge into current branch' + ELLIPSIS, + visible: visibility.merge, + onClick: () => this.mergeAction(refName, refName, GG.MergeActionOn.RemoteTrackingBranch, target) + }, { + title: 'Pull into current branch' + ELLIPSIS, + visible: visibility.pull && remote !== '', + onClick: () => { + dialog.showForm('Are you sure you want to pull the remote branch ' + escapeHtml(refName) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '? If a merge is required:', [ + { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.pullBranch.noFastForward }, + { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.pullBranch.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this remote branch.' } + ], 'Yes, pull', (values) => { + runAction({ command: 'pullBranch', repo: this.currentRepo, branchName: branchName, remote: remote, createNewCommit: values[0], squash: values[1] }, 'Pulling Branch'); + }, target); + } + } + ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), + { + title: 'Create Pull Request', + visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' && + (this.gitRepos[this.currentRepo].pullRequestConfig!.sourceRemote === remote || this.gitRepos[this.currentRepo].pullRequestConfig!.destRemote === remote), + onClick: () => { + const config = this.gitRepos[this.currentRepo].pullRequestConfig; + if (config === null) return; + const isDestRemote = config.destRemote === remote; + runAction({ + command: 'createPullRequest', + repo: this.currentRepo, + config: config, + sourceRemote: isDestRemote ? config.destRemote! : config.sourceRemote, + sourceOwner: isDestRemote ? config.destOwner : config.sourceOwner, + sourceRepo: isDestRemote ? config.destRepo : config.sourceRepo, + sourceBranch: branchName, + push: false + }, 'Creating Pull Request'); + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: refName }, 'Creating Archive'); + } + }, + { + title: 'Select in Branches Dropdown', + visible: visibility.selectInBranchesDropdown && !isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.selectOption(prefixedRefName) + }, + { + title: 'Unselect in Branches Dropdown', + visible: visibility.unselectInBranchesDropdown && isSelectedInBranchesDropdown, + onClick: () => this.branchDropdown.unselectOption(prefixedRefName) + } + ], [ + { + title: 'Copy Branch Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Branch Name', data: refName }); + } + } + ]]; + } + + private getStashContextMenuActions(target: DialogTarget & RefTarget): ContextMenuActions { + const hash = target.hash, selector = target.ref, visibility = this.config.contextMenuActionsVisibility.stash; + return [[ + { + title: 'Apply Stash' + ELLIPSIS, + visible: visibility.apply, + onClick: () => { + dialog.showForm('Are you sure you want to apply the stash ' + escapeHtml(selector.substring(5)) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Reinstate Index', + value: this.config.dialogDefaults.applyStash.reinstateIndex, + info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' + }], 'Yes, apply stash', (values) => { + runAction({ command: 'applyStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Applying Stash'); + }, target); + } + }, { + title: 'Create Branch from Stash' + ELLIPSIS, + visible: visibility.createBranch, + onClick: () => { + dialog.showRefInput('Create a branch from stash ' + escapeHtml(selector.substring(5)) + ' with the name:', '', 'Create Branch', (branchName) => { + runAction({ command: 'branchFromStash', repo: this.currentRepo, selector: selector, branchName: branchName }, 'Creating Branch'); + }, target); + } + }, { + title: 'Pop Stash' + ELLIPSIS, + visible: visibility.pop, + onClick: () => { + dialog.showForm('Are you sure you want to pop the stash ' + escapeHtml(selector.substring(5)) + '?', [{ + type: DialogInputType.Checkbox, + name: 'Reinstate Index', + value: this.config.dialogDefaults.popStash.reinstateIndex, + info: 'Attempt to reinstate the indexed changes, in addition to the working tree\'s changes.' + }], 'Yes, pop stash', (values) => { + runAction({ command: 'popStash', repo: this.currentRepo, selector: selector, reinstateIndex: values[0] }, 'Popping Stash'); + }, target); + } + }, { + title: 'Drop Stash' + ELLIPSIS, + visible: visibility.drop, + onClick: () => { + dialog.showConfirmation('Are you sure you want to drop the stash ' + escapeHtml(selector.substring(5)) + '?', 'Yes, drop', () => { + runAction({ command: 'dropStash', repo: this.currentRepo, selector: selector }, 'Dropping Stash'); + }, target); + } + } + ], [ + { + title: 'Copy Stash Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Stash Name', data: selector }); + } + }, { + title: 'Copy Stash Hash to Clipboard', + visible: visibility.copyHash, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Stash Hash', data: hash }); + } + } + ]]; + } + + private getTagContextMenuActions(isAnnotated: boolean, target: DialogTarget & RefTarget): ContextMenuActions { + const hash = target.hash, tagName = target.ref, visibility = this.config.contextMenuActionsVisibility.tag; + return [[ + { + title: 'View Details', + visible: visibility.viewDetails && isAnnotated, + onClick: () => { + runAction({ command: 'tagDetails', repo: this.currentRepo, tagName: tagName, commitHash: hash }, 'Retrieving Tag Details'); + } + }, { + title: 'Delete Tag' + ELLIPSIS, + visible: visibility.delete, + onClick: () => { + let message = 'Are you sure you want to delete the tag ' + escapeHtml(tagName) + '?'; + if (this.gitRemotes.length > 1) { + let options = [{ name: 'Don\'t delete on any remote', value: '-1' }]; + this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); + dialog.showSelect(message + '
    Do you also want to delete the tag on a remote:', '-1', options, 'Yes, delete', remoteIndex => { + this.deleteTagAction(tagName, remoteIndex !== '-1' ? this.gitRemotes[parseInt(remoteIndex)] : null); + }, target); + } else if (this.gitRemotes.length === 1) { + dialog.showCheckbox(message, 'Also delete on remote', false, 'Yes, delete', deleteOnRemote => { + this.deleteTagAction(tagName, deleteOnRemote ? this.gitRemotes[0] : null); + }, target); + } else { + dialog.showConfirmation(message, 'Yes, delete', () => { + this.deleteTagAction(tagName, null); + }, target); + } + } + }, { + title: 'Push Tag' + ELLIPSIS, + visible: visibility.push && this.gitRemotes.length > 0, + onClick: () => { + const runPushTagAction = (remotes: string[]) => { + runAction({ + command: 'pushTag', + repo: this.currentRepo, + tagName: tagName, + remotes: remotes, + commitHash: hash, + skipRemoteCheck: globalState.pushTagSkipRemoteCheck + }, 'Pushing Tag'); + }; + + if (this.gitRemotes.length === 1) { + dialog.showConfirmation('Are you sure you want to push the tag ' + escapeHtml(tagName) + ' to the remote ' + escapeHtml(this.gitRemotes[0]) + '?', 'Yes, push', () => { + runPushTagAction([this.gitRemotes[0]]); + }, target); + } else if (this.gitRemotes.length > 1) { + const defaults = [this.getPushRemote()]; + const options = this.gitRemotes.map((remote) => ({ name: remote, value: remote })); + dialog.showMultiSelect('Are you sure you want to push the tag ' + escapeHtml(tagName) + '? Select the remote(s) to push the tag to:', defaults, options, 'Yes, push', (remotes) => { + runPushTagAction(remotes); + }, target); + } + } + } + ], [ + { + title: 'Create Archive', + visible: visibility.createArchive, + onClick: () => { + runAction({ command: 'createArchive', repo: this.currentRepo, ref: tagName }, 'Creating Archive'); + } + }, + { + title: 'Copy Tag Name to Clipboard', + visible: visibility.copyName, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'Tag Name', data: tagName }); + } + } + ]]; + } + + private getUncommittedChangesContextMenuActions(target: DialogTarget & CommitTarget): ContextMenuActions { + let visibility = this.config.contextMenuActionsVisibility.uncommittedChanges; + return [[ + { + title: 'Stash uncommitted changes' + ELLIPSIS, + visible: visibility.stash, + onClick: () => { + dialog.showForm('Are you sure you want to stash the uncommitted changes?', [ + { type: DialogInputType.Text, name: 'Message', default: '', placeholder: 'Optional' }, + { type: DialogInputType.Checkbox, name: 'Include Untracked', value: this.config.dialogDefaults.stashUncommittedChanges.includeUntracked, info: 'Include all untracked files in the stash, and then clean them from the working directory.' } + ], 'Yes, stash', (values) => { + runAction({ command: 'pushStash', repo: this.currentRepo, message: values[0], includeUntracked: values[1] }, 'Stashing uncommitted changes'); + }, target); + } + } + ], [ + { + title: 'Reset uncommitted changes' + ELLIPSIS, + visible: visibility.reset, + onClick: () => { + dialog.showSelect('Are you sure you want to reset the uncommitted changes to HEAD?', this.config.dialogDefaults.resetUncommitted.mode, [ + { name: 'Mixed - Keep working tree, but reset index', value: GG.GitResetMode.Mixed }, + { name: 'Hard - Discard all changes', value: GG.GitResetMode.Hard } + ], 'Yes, reset', (mode) => { + runAction({ command: 'resetToCommit', repo: this.currentRepo, commit: 'HEAD', resetMode: mode }, 'Resetting uncommitted changes'); + }, target); + } + }, { + title: 'Clean untracked files' + ELLIPSIS, + visible: visibility.clean, + onClick: () => { + dialog.showCheckbox('Are you sure you want to clean all untracked files?', 'Clean untracked directories', true, 'Yes, clean', directories => { + runAction({ command: 'cleanUntrackedFiles', repo: this.currentRepo, directories: directories }, 'Cleaning untracked files'); + }, target); + } + } + ], [ + { + title: 'Open Source Control View', + visible: visibility.openSourceControlView, + onClick: () => { + sendMessage({ command: 'viewScm' }); + } + } + ]]; + } + + private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction { + const issueLinks: { url: string, displayText: string }[] = []; + + let issueLinking: IssueLinking | null, match: RegExpExecArray | null; + if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) { + issueLinking.regexp.lastIndex = 0; + while (match = issueLinking.regexp.exec(refName)) { + if (match[0].length === 0) break; + issueLinks.push({ + url: generateIssueLinkFromMatch(match, issueLinking), + displayText: match[0] + }); + } + } + + return { + title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''), + visible: issueLinks.length > 0, + onClick: () => { + if (issueLinks.length > 1) { + dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => { + sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url }); + }, target); + } else if (issueLinks.length === 1) { + sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url }); + } + } + }; + } + + + /* Actions */ + + private addTagAction(hash: string, initialName: string, initialType: GG.TagType, initialMessage: string, initialPushToRemote: string | null, target: DialogTarget & CommitTarget, isInitialLoad: boolean = true) { + let mostRecentTagsIndex = -1; + for (let i = 0; i < this.commits.length; i++) { + if (this.commits[i].tags.length > 0 && (mostRecentTagsIndex === -1 || this.commits[i].date > this.commits[mostRecentTagsIndex].date)) { + mostRecentTagsIndex = i; + } + } + const mostRecentTags = mostRecentTagsIndex > -1 ? this.commits[mostRecentTagsIndex].tags.map((tag) => '"' + tag.name + '"') : []; + + const inputs: DialogInput[] = [ + { type: DialogInputType.TextRef, name: 'Name', default: initialName, info: mostRecentTags.length > 0 ? 'The most recent tag' + (mostRecentTags.length > 1 ? 's' : '') + ' in the loaded commits ' + (mostRecentTags.length > 1 ? 'are' : 'is') + ' ' + formatCommaSeparatedList(mostRecentTags) + '.' : undefined }, + { type: DialogInputType.Select, name: 'Type', default: initialType === GG.TagType.Annotated ? 'annotated' : 'lightweight', options: [{ name: 'Annotated', value: 'annotated' }, { name: 'Lightweight', value: 'lightweight' }] }, + { type: DialogInputType.Text, name: 'Message', default: initialMessage, placeholder: 'Optional', info: 'A message can only be added to an annotated tag.' } + ]; + if (this.gitRemotes.length > 1) { + const options = [{ name: 'Don\'t push', value: '-1' }]; + this.gitRemotes.forEach((remote, i) => options.push({ name: remote, value: i.toString() })); + const defaultOption = initialPushToRemote !== null + ? this.gitRemotes.indexOf(initialPushToRemote) + : isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote + ? this.gitRemotes.indexOf(this.getPushRemote()) + : -1; + inputs.push({ type: DialogInputType.Select, name: 'Push to remote', options: options, default: defaultOption.toString(), info: 'Once this tag has been added, push it to this remote.' }); + } else if (this.gitRemotes.length === 1) { + const defaultValue = initialPushToRemote !== null || (isInitialLoad && this.config.dialogDefaults.addTag.pushToRemote); + inputs.push({ type: DialogInputType.Checkbox, name: 'Push to remote', value: defaultValue, info: 'Once this tag has been added, push it to the repositories remote.' }); + } + + dialog.showForm('Add tag to commit ' + abbrevCommit(hash) + ':', inputs, 'Add Tag', (values) => { + const tagName = values[0]; + const type = values[1] === 'annotated' ? GG.TagType.Annotated : GG.TagType.Lightweight; + const message = values[2]; + const pushToRemote = this.gitRemotes.length > 1 && values[3] !== '-1' + ? this.gitRemotes[parseInt(values[3])] + : this.gitRemotes.length === 1 && values[3] + ? this.gitRemotes[0] + : null; + + const runAddTagAction = (force: boolean) => { + runAction({ + command: 'addTag', + repo: this.currentRepo, + tagName: tagName, + commitHash: hash, + type: type, + message: message, + pushToRemote: pushToRemote, + pushSkipRemoteCheck: globalState.pushTagSkipRemoteCheck, + force: force + }, 'Adding Tag'); + }; + + if (this.gitTags.includes(tagName)) { + dialog.showTwoButtons('A tag named ' + escapeHtml(tagName) + ' already exists, do you want to replace it with this new tag?', 'Yes, replace the existing tag', () => { + runAddTagAction(true); + }, 'No, choose another tag name', () => { + this.addTagAction(hash, tagName, type, message, pushToRemote, target, false); + }, target); + } else { + runAddTagAction(false); + } + }, target); + } + + private checkoutBranchAction(refName: string, remote: string | null, prefillName: string | null, target: DialogTarget & (CommitTarget | RefTarget)) { + if (remote !== null) { + dialog.showRefInput('Enter the name of the new branch you would like to create when checking out ' + escapeHtml(refName) + ':', (prefillName !== null ? prefillName : (remote !== '' ? refName.substring(remote.length + 1) : refName)), 'Checkout Branch', newBranch => { + if (this.gitBranches.includes(newBranch)) { + const canPullFromRemote = remote !== ''; + dialog.showTwoButtons('The name ' + escapeHtml(newBranch) + ' is already used by another branch:', 'Choose another branch name', () => { + this.checkoutBranchAction(refName, remote, newBranch, target); + }, 'Checkout the existing branch' + (canPullFromRemote ? ' & pull changes' : ''), () => { + runAction({ + command: 'checkoutBranch', + repo: this.currentRepo, + branchName: newBranch, + remoteBranch: null, + pullAfterwards: canPullFromRemote + ? { + branchName: refName.substring(remote.length + 1), + remote: remote, + createNewCommit: this.config.dialogDefaults.pullBranch.noFastForward, + squash: this.config.dialogDefaults.pullBranch.squash + } + : null + }, 'Checking out Branch' + (canPullFromRemote ? ' & Pulling Changes' : '')); + }, target); + } else { + runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: newBranch, remoteBranch: refName, pullAfterwards: null }, 'Checking out Branch'); + } + }, target); + } else { + runAction({ command: 'checkoutBranch', repo: this.currentRepo, branchName: refName, remoteBranch: null, pullAfterwards: null }, 'Checking out Branch'); + } + } + + private createBranchAction(hash: string, initialName: string, initialCheckOut: boolean, target: DialogTarget & CommitTarget) { + dialog.showForm('Create branch at commit ' + abbrevCommit(hash) + ':', [ + { type: DialogInputType.TextRef, name: 'Name', default: initialName }, + { type: DialogInputType.Checkbox, name: 'Check out', value: initialCheckOut } + ], 'Create Branch', (values) => { + const branchName = values[0], checkOut = values[1]; + if (this.gitBranches.includes(branchName)) { + dialog.showTwoButtons('A branch named ' + escapeHtml(branchName) + ' already exists, do you want to replace it with this new branch?', 'Yes, replace the existing branch', () => { + runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: true }, 'Creating Branch'); + }, 'No, choose another branch name', () => { + this.createBranchAction(hash, branchName, checkOut, target); + }, target); + } else { + runAction({ command: 'createBranch', repo: this.currentRepo, branchName: branchName, commitHash: hash, checkout: checkOut, force: false }, 'Creating Branch'); + } + }, target); + } + + private deleteTagAction(refName: string, deleteOnRemote: string | null) { + runAction({ command: 'deleteTag', repo: this.currentRepo, tagName: refName, deleteOnRemote: deleteOnRemote }, 'Deleting Tag'); + } + + private fetchFromRemotesAction() { + runAction({ command: 'fetch', repo: this.currentRepo, name: null, prune: this.config.fetchAndPrune, pruneTags: this.config.fetchAndPruneTags }, 'Fetching from Remote(s)'); + } + + private mergeAction(obj: string, name: string, actionOn: GG.MergeActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { + dialog.showForm('Are you sure you want to merge ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + ' into ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + '?', [ + { type: DialogInputType.Checkbox, name: 'Create a new commit even if fast-forward is possible', value: this.config.dialogDefaults.merge.noFastForward }, + { type: DialogInputType.Checkbox, name: 'Allow unrelated histories', value: this.config.dialogDefaults.merge.allowUnrelatedHistories, info: 'Allow merging branches from two completely different repositories or branches.' }, + { type: DialogInputType.Checkbox, name: 'Squash Commits', value: this.config.dialogDefaults.merge.squash, info: 'Create a single commit on the current branch whose effect is the same as merging this ' + actionOn.toLowerCase() + '.' }, + { type: DialogInputType.Checkbox, name: 'No Commit', value: this.config.dialogDefaults.merge.noCommit, info: 'The changes of the merge will be staged but not committed, so that you can review and/or modify the merge result before committing.' } + ], 'Yes, merge', (values) => { + runAction({ command: 'merge', repo: this.currentRepo, obj: obj, actionOn: actionOn, createNewCommit: values[0], allowUnrelatedHistories: values[1], squash: values[2], noCommit: values[3] }, 'Merging ' + actionOn); + }, target); + } + + private rebaseAction(obj: string, name: string, actionOn: GG.RebaseActionOn, target: DialogTarget & (CommitTarget | RefTarget)) { + dialog.showForm('Are you sure you want to rebase ' + (this.gitBranchHead !== null ? '' + escapeHtml(this.gitBranchHead) + ' (the current branch)' : 'the current branch') + ' on ' + actionOn.toLowerCase() + ' ' + escapeHtml(name) + '?', [ + { type: DialogInputType.Checkbox, name: 'Interactive Rebase (launch in new Terminal)', value: this.config.dialogDefaults.rebase.interactive }, + { type: DialogInputType.Checkbox, name: 'Ignore Date', value: this.config.dialogDefaults.rebase.ignoreDate, info: 'Only applicable to a non-interactive rebase.' } + ], 'Yes, rebase', (values) => { + let interactive = values[0]; + runAction({ command: 'rebase', repo: this.currentRepo, obj: obj, actionOn: actionOn, ignoreDate: values[1], interactive: interactive }, interactive ? 'Launching Interactive Rebase' : 'Rebasing on ' + actionOn); + }, target); + } + + + /* Table Utils */ + + private makeTableResizable() { + let colHeadersElem = document.getElementById('tableColHeaders')!, cols = >document.getElementsByClassName('tableColHeader'); + let columnWidths: GG.ColumnWidth[], mouseX = -1, col = -1, colIndex = -1; + + const makeTableFixedLayout = () => { + cols[0].style.width = columnWidths[0] + 'px'; + cols[0].style.padding = ''; + for (let i = 2; i < cols.length; i++) { + cols[i].style.width = columnWidths[parseInt(cols[i].dataset.col!)] + 'px'; + } + this.tableElem.className = 'fixedLayout'; + this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); + this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); + }; + + for (let i = 0; i < cols.length; i++) { + let col = parseInt(cols[i].dataset.col!); + cols[i].innerHTML += (i > 0 ? '' : '') + (i < cols.length - 1 ? '' : ''); + } + + let cWidths = this.gitRepos[this.currentRepo].columnWidths; + if (cWidths === null) { // Initialise auto column layout if it is the first time viewing the repo. + let defaults = this.config.defaultColumnVisibility; + columnWidths = [COLUMN_AUTO, COLUMN_AUTO, defaults.date ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.author ? COLUMN_AUTO : COLUMN_HIDDEN, defaults.commit ? COLUMN_AUTO : COLUMN_HIDDEN]; + this.saveColumnWidths(columnWidths); + } else { + columnWidths = [cWidths[0], COLUMN_AUTO, cWidths[1], cWidths[2], cWidths[3]]; + } + + if (columnWidths[0] !== COLUMN_AUTO) { + // Table should have fixed layout + makeTableFixedLayout(); + } else { + // Table should have automatic layout + this.tableElem.className = 'autoLayout'; + + let colWidth = cols[0].offsetWidth, graphWidth = this.graph.getContentWidth(); + let maxWidth = Math.round(this.viewElem.clientWidth * 0.333); + if (Math.max(graphWidth, colWidth) > maxWidth) { + this.graph.limitMaxWidth(maxWidth); + graphWidth = maxWidth; + this.tableElem.className += ' limitGraphWidth'; + this.tableElem.style.setProperty(CSS_PROP_LIMIT_GRAPH_WIDTH, maxWidth + 'px'); + } else { + this.graph.limitMaxWidth(-1); + this.tableElem.style.removeProperty(CSS_PROP_LIMIT_GRAPH_WIDTH); + } + + if (colWidth < Math.max(graphWidth, 64)) { + cols[0].style.padding = '6px ' + Math.floor((Math.max(graphWidth, 64) - (colWidth - COLUMN_LEFT_RIGHT_PADDING)) / 2) + 'px'; + } + } + + const processResizingColumn: EventListener = (e) => { + if (col > -1) { + let mouseEvent = e; + let mouseDeltaX = mouseEvent.clientX - mouseX; + + if (col === 0) { + if (columnWidths[0] + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -columnWidths[0] + COLUMN_MIN_WIDTH; + if (cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING - COLUMN_MIN_WIDTH; + columnWidths[0] += mouseDeltaX; + cols[0].style.width = columnWidths[0] + 'px'; + this.graph.limitMaxWidth(columnWidths[0] + COLUMN_LEFT_RIGHT_PADDING); + } else { + let colWidth = col !== 1 ? columnWidths[col] : cols[1].clientWidth - COLUMN_LEFT_RIGHT_PADDING; + let nextCol = col + 1; + while (columnWidths[nextCol] === COLUMN_HIDDEN) nextCol++; + + if (colWidth + mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = -colWidth + COLUMN_MIN_WIDTH; + if (columnWidths[nextCol] - mouseDeltaX < COLUMN_MIN_WIDTH) mouseDeltaX = columnWidths[nextCol] - COLUMN_MIN_WIDTH; + if (col !== 1) { + columnWidths[col] += mouseDeltaX; + cols[colIndex].style.width = columnWidths[col] + 'px'; + } + columnWidths[nextCol] -= mouseDeltaX; + cols[colIndex + 1].style.width = columnWidths[nextCol] + 'px'; + } + mouseX = mouseEvent.clientX; + } + }; + const stopResizingColumn: EventListener = () => { + if (col > -1) { + col = -1; + colIndex = -1; + mouseX = -1; + eventOverlay.remove(); + this.saveColumnWidths(columnWidths); + } + }; + + addListenerToClass('resizeCol', 'mousedown', (e) => { + if (e.target === null) return; + col = parseInt((e.target).dataset.col!); + while (columnWidths[col] === COLUMN_HIDDEN) col--; + mouseX = (e).clientX; + + let isAuto = columnWidths[0] === COLUMN_AUTO; + for (let i = 0; i < cols.length; i++) { + let curCol = parseInt(cols[i].dataset.col!); + if (isAuto && curCol !== 1) columnWidths[curCol] = cols[i].clientWidth - COLUMN_LEFT_RIGHT_PADDING; + if (curCol === col) colIndex = i; + } + if (isAuto) makeTableFixedLayout(); + eventOverlay.create('colResize', processResizingColumn, stopResizingColumn); + }); + + colHeadersElem.addEventListener('contextmenu', (e: MouseEvent) => { + handledEvent(e); + + const toggleColumnState = (col: number, defaultWidth: number) => { + columnWidths[col] = columnWidths[col] !== COLUMN_HIDDEN ? COLUMN_HIDDEN : columnWidths[0] === COLUMN_AUTO ? COLUMN_AUTO : defaultWidth - COLUMN_LEFT_RIGHT_PADDING; + this.saveColumnWidths(columnWidths); + this.render(); + }; + + const commitOrdering = getCommitOrdering(this.gitRepos[this.currentRepo].commitOrdering); + const changeCommitOrdering = (repoCommitOrdering: GG.RepoCommitOrdering) => { + this.saveRepoStateValue(this.currentRepo, 'commitOrdering', repoCommitOrdering); + this.refresh(true); + }; + + contextMenu.show([ + [ + { + title: 'Date', + visible: true, + checked: columnWidths[2] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(2, 128) + }, + { + title: 'Author', + visible: true, + checked: columnWidths[3] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(3, 128) + }, + { + title: 'Commit', + visible: true, + checked: columnWidths[4] !== COLUMN_HIDDEN, + onClick: () => toggleColumnState(4, 80) + } + ], + [ + { + title: 'Commit Timestamp Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.Date, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Date) + }, + { + title: 'Author Timestamp Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.AuthorDate, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.AuthorDate) + }, + { + title: 'Topological Order', + visible: true, + checked: commitOrdering === GG.CommitOrdering.Topological, + onClick: () => changeCommitOrdering(GG.RepoCommitOrdering.Topological) + } + ] + ], true, null, e, this.viewElem); + }); + } + + public getColumnVisibility() { + let colWidths = this.gitRepos[this.currentRepo].columnWidths; + if (colWidths !== null) { + return { date: colWidths[1] !== COLUMN_HIDDEN, author: colWidths[2] !== COLUMN_HIDDEN, commit: colWidths[3] !== COLUMN_HIDDEN }; + } else { + let defaults = this.config.defaultColumnVisibility; + return { date: defaults.date, author: defaults.author, commit: defaults.commit }; + } + } + + private getNumColumns() { + let colVisibility = this.getColumnVisibility(); + return 2 + (colVisibility.date ? 1 : 0) + (colVisibility.author ? 1 : 0) + (colVisibility.commit ? 1 : 0); + } + + /** + * Scroll the view to the previous or next stash. + * @param next TRUE => Jump to the next stash, FALSE => Jump to the previous stash. + */ + private scrollToStash(next: boolean) { + const stashCommits = this.commits.filter((commit) => commit.stash !== null); + if (stashCommits.length > 0) { + const curTime = (new Date()).getTime(); + if (this.lastScrollToStash.time < curTime - 5000) { + // Reset the lastScrollToStash hash if it was more than 5 seconds ago + this.lastScrollToStash.hash = null; + } + + const lastScrollToStashCommitIndex = this.lastScrollToStash.hash !== null + ? stashCommits.findIndex((commit) => commit.hash === this.lastScrollToStash.hash) + : -1; + let scrollToStashCommitIndex = lastScrollToStashCommitIndex + (next ? 1 : -1); + if (scrollToStashCommitIndex >= stashCommits.length) { + scrollToStashCommitIndex = 0; + } else if (scrollToStashCommitIndex < 0) { + scrollToStashCommitIndex = stashCommits.length - 1; + } + this.scrollToCommit(stashCommits[scrollToStashCommitIndex].hash, true, true); + this.lastScrollToStash.time = curTime; + this.lastScrollToStash.hash = stashCommits[scrollToStashCommitIndex].hash; + } + } + + /** + * Scroll the view to a commit (if it exists). + * @param hash The hash of the commit to scroll to. + * @param alwaysCenterCommit TRUE => Always scroll the view to be centered on the commit. FALSE => Don't scroll the view if the commit is already within the visible portion of commits. + * @param flash Should the commit flash after it has been scrolled to. + * @param openDetails Open details of the specified commit. + * @param persistently Persistently find the commit even if it is not exists. + */ + public scrollToCommit(hash: string, alwaysCenterCommit: boolean, flash: boolean = false, openDetails: boolean = false, persistently: boolean = false) { + this.scrollToCommitArgs.persistently = false; + + const elem = findCommitElemWithId(getCommitElems(), this.getCommitId(hash)); + if (elem === null) { + if (persistently) { + // Scroll to the last loaded commit for trigger loadMoreCommits() + const commits = document.getElementsByClassName('commit'); + if (commits.length === 0) { + return; + } + const lastCommit = commits[commits.length - 1]; + lastCommit.scrollIntoView(); + + this.scrollToCommitArgs = { + hash: hash, + alwaysCenterCommit: alwaysCenterCommit, + flash: flash, + openDetails: openDetails, + persistently: persistently + }; + } + // Do nothing + return; + } + let elemTop = this.controlsElem.clientHeight + elem.offsetTop; + if (alwaysCenterCommit || elemTop - 8 < this.viewElem.scrollTop || elemTop + 32 - this.viewElem.clientHeight > this.viewElem.scrollTop) { + this.viewElem.scroll(0, this.controlsElem.clientHeight + elem.offsetTop + 12 - this.viewElem.clientHeight / 2); + } + + if (flash && !elem.classList.contains('flash')) { + elem.classList.add('flash'); + setTimeout(() => { + elem.classList.remove('flash'); + }, 850); + } + + if (openDetails) { + this.loadCommitDetails(elem); + } + } + + private loadMoreCommits() { + this.footerElem.innerHTML = '

    ' + SVG_ICONS.loading + 'Loading ...

    '; + this.maxCommits += this.config.loadMoreCommits; + this.saveState(); + this.requestLoadRepoInfoAndCommits(false, true); + } + + private alignTableHeaderToControls() { + if (!this.tableColHeadersElem) { + return; + } + } + + + /* Observers */ + + private observeWindowSizeChanges() { + let windowWidth = window.outerWidth, windowHeight = window.outerHeight; + window.addEventListener('resize', () => { + if (windowWidth === window.outerWidth && windowHeight === window.outerHeight) { + this.renderGraph(); + } else { + windowWidth = window.outerWidth; + windowHeight = window.outerHeight; + } + + if (this.config.stickyHeader) { + this.alignTableHeaderToControls(); + } + }); + } + + private observeWebviewStyleChanges() { + let fontFamily = getVSCodeStyle(CSS_PROP_FONT_FAMILY), + editorFontFamily = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), + findMatchColour = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), + selectionBackgroundColor = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); + + const setFlashColour = (colour: string) => { + document.body.style.setProperty('--git-graph-flashPrimary', modifyColourOpacity(colour, 0.7)); + document.body.style.setProperty('--git-graph-flashSecondary', modifyColourOpacity(colour, 0.5)); + }; + const setSelectionBackgroundColorExists = () => { + alterClass(document.body, 'selection-background-color-exists', selectionBackgroundColor); + }; + + this.findWidget.setColour(findMatchColour); + setFlashColour(findMatchColour); + setSelectionBackgroundColorExists(); + + (new MutationObserver(() => { + let ff = getVSCodeStyle(CSS_PROP_FONT_FAMILY), + eff = getVSCodeStyle(CSS_PROP_EDITOR_FONT_FAMILY), + fmc = getVSCodeStyle(CSS_PROP_FIND_MATCH_HIGHLIGHT_BACKGROUND), + sbc = !!getVSCodeStyle(CSS_PROP_SELECTION_BACKGROUND); + + if (ff !== fontFamily || eff !== editorFontFamily) { + fontFamily = ff; + editorFontFamily = eff; + this.repoDropdown.refresh(); + this.branchDropdown.refresh(); + this.authorDropdown.refresh(); + } + if (fmc !== findMatchColour) { + findMatchColour = fmc; + this.findWidget.setColour(findMatchColour); + setFlashColour(findMatchColour); + } + if (selectionBackgroundColor !== sbc) { + selectionBackgroundColor = sbc; + setSelectionBackgroundColorExists(); + } + })).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); + } + + private observeViewScroll() { + let active = this.viewElem.scrollTop > 0, timeout: NodeJS.Timer | null = null; + this.viewElem.addEventListener('scroll', () => { + const scrollTop = this.viewElem.scrollTop; + if (active !== scrollTop > 0) { + active = scrollTop > 0; + } + + if (this.config.loadMoreCommitsAutomatically && this.moreCommitsAvailable && !this.currentRepoRefreshState.inProgress) { + const viewHeight = this.viewElem.clientHeight, contentHeight = this.viewElem.scrollHeight; + if (scrollTop > 0 && viewHeight > 0 && contentHeight > 0 && (scrollTop + viewHeight) >= contentHeight - 25) { + // If the user has scrolled such that the bottom of the visible view is within 25px of the end of the content, load more commits. + this.loadMoreCommits(); + } + } + + if (timeout !== null) clearTimeout(timeout); + timeout = setTimeout(() => { + this.scrollTop = scrollTop; + this.saveState(); + timeout = null; + }, 250); + }); + } + + private observeKeyboardEvents() { + document.addEventListener('keydown', (e) => { + if (contextMenu.isOpen()) { + if (e.key === 'Escape') { + contextMenu.close(); + handledEvent(e); + } + } else if (dialog.isOpen()) { + if (e.key === 'Escape') { + dialog.close(); + handledEvent(e); + } else if (e.keyCode ? e.keyCode === 13 : e.key === 'Enter') { + // Use keyCode === 13 to detect 'Enter' events if available (for compatibility with IME Keyboards used by Chinese / Japanese / Korean users) + dialog.submit(); + handledEvent(e); + } + } else if (this.expandedCommit !== null && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const curHashIndex = this.commitLookup[this.expandedCommit.commitHash]; + let newHashIndex = -1; + + if (e.ctrlKey || e.metaKey) { + // Up / Down navigates according to the order of commits on the branch + if (e.shiftKey) { + // Follow commits on alternative branches when possible + if (e.key === 'ArrowUp') { + newHashIndex = this.graph.getAlternativeChildIndex(curHashIndex); + } else if (e.key === 'ArrowDown') { + newHashIndex = this.graph.getAlternativeParentIndex(curHashIndex); + } + } else { + // Follow commits on the same branch + if (e.key === 'ArrowUp') { + newHashIndex = this.graph.getFirstChildIndex(curHashIndex); + } else if (e.key === 'ArrowDown') { + newHashIndex = this.graph.getFirstParentIndex(curHashIndex); + } + } + } else { + // Up / Down navigates according to the order of commits in the table + if (e.key === 'ArrowUp' && curHashIndex > 0) { + newHashIndex = curHashIndex - 1; + } else if (e.key === 'ArrowDown' && curHashIndex < this.commits.length - 1) { + newHashIndex = curHashIndex + 1; + } + } + + if (newHashIndex > -1) { + handledEvent(e); + const elem = findCommitElemWithId(getCommitElems(), newHashIndex); + if (elem !== null) this.loadCommitDetails(elem); + } + } else if (e.key && (e.ctrlKey || e.metaKey)) { + const key = e.key.toLowerCase(), keybindings = this.config.keybindings; + if (key === keybindings.scrollToStash) { + this.scrollToStash(!e.shiftKey); + handledEvent(e); + } else if (!e.shiftKey) { + if (key === keybindings.refresh) { + this.refresh(true, true); + handledEvent(e); + } else if (key === keybindings.find) { + this.findWidget.show(true); + handledEvent(e); + } else if (key === keybindings.scrollToHead && this.commitHead !== null) { + this.scrollToCommit(this.commitHead, true, true); + handledEvent(e); + } + } + } else if (e.key === 'Escape') { + if (this.repoDropdown.isOpen()) { + this.repoDropdown.close(); + handledEvent(e); + } else if (this.branchDropdown.isOpen()) { + this.branchDropdown.close(); + handledEvent(e); + } else if (this.authorDropdown.isOpen()) { + this.authorDropdown.close(); + handledEvent(e); + } else if (this.settingsWidget.isVisible()) { + this.settingsWidget.close(); + handledEvent(e); + } else if (this.findWidget.isVisible()) { + this.findWidget.close(); + handledEvent(e); + } else if (this.expandedCommit !== null) { + this.closeCommitDetails(true); + handledEvent(e); + } + } + }); + } + + private observeUrls() { + const followInternalLink = (e: MouseEvent) => { + if (e.target !== null && isInternalUrlElem(e.target)) { + const value = unescapeHtml((e.target).dataset.value!); + switch ((e.target).dataset.type!) { + case 'commit': + if (typeof this.commitLookup[value] === 'number' && (this.expandedCommit === null || this.expandedCommit.commitHash !== value || this.expandedCommit.compareWithHash !== null)) { + const elem = findCommitElemWithId(getCommitElems(), this.commitLookup[value]); + if (elem !== null) this.loadCommitDetails(elem); + } + break; + } + } + }; + + document.body.addEventListener('click', followInternalLink); + + document.body.addEventListener('contextmenu', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + + const isExternalUrl = isExternalUrlElem(eventTarget), isInternalUrl = isInternalUrlElem(eventTarget); + if (isExternalUrl || isInternalUrl) { + const viewElem: HTMLElement | null = eventTarget.closest('#view'); + let eventElem: HTMLElement | null; + + let target: (ContextMenuTarget & CommitTarget) | RepoTarget, isInDialog = false; + if (this.expandedCommit !== null && eventTarget.closest('#cdv') !== null) { + // URL is in the Commit Details View + target = { + type: TargetType.CommitDetailsView, + hash: this.expandedCommit.commitHash, + index: this.commitLookup[this.expandedCommit.commitHash], + elem: eventTarget + }; + GitGraphView.closeCdvContextMenuIfOpen(this.expandedCommit); + this.expandedCommit.contextMenuOpen.summary = true; + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // URL is in the Commits + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + target = { + type: TargetType.Commit, + hash: commit.hash, + index: parseInt(eventElem.dataset.id!), + elem: eventTarget + }; + } else { + // URL is in a dialog + target = { + type: TargetType.Repo + }; + isInDialog = true; + } + + handledEvent(e); + contextMenu.show([ + [ + { + title: 'Open URL', + visible: isExternalUrl, + onClick: () => { + sendMessage({ command: 'openExternalUrl', url: (eventTarget).href }); + } + }, + { + title: 'Follow Internal Link', + visible: isInternalUrl, + onClick: () => followInternalLink(e) + }, + { + title: 'Copy URL to Clipboard', + visible: isExternalUrl, + onClick: () => { + sendMessage({ command: 'copyToClipboard', type: 'External URL', data: (eventTarget).href }); + } + } + ] + ], false, target, e, viewElem || document.body, () => { + if (target.type === TargetType.CommitDetailsView && this.expandedCommit !== null) { + this.expandedCommit.contextMenuOpen.summary = false; + } + }, isInDialog ? 'dialogContextMenu' : null); + } + }); + } + + private observeTableEvents() { + + // Register Click Event Handler + this.tableElem.addEventListener('click', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was clicked + e.stopPropagation(); + if (contextMenu.isOpen()) { + contextMenu.close(); + } + + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // .commit was clicked + if (this.expandedCommit !== null) { + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + + if (this.expandedCommit.commitHash === commit.hash) { + this.closeCommitDetails(true); + } else if ((e).ctrlKey || (e).metaKey) { + if (this.expandedCommit.compareWithHash === commit.hash) { + this.closeCommitComparison(true); + } else if (this.expandedCommit.commitElem !== null) { + this.loadCommitComparison(this.expandedCommit.commitElem, eventElem); + } + } else { + this.loadCommitDetails(eventElem); + } + } else { + this.loadCommitDetails(eventElem); + } + } + }); + + // Register Double Click Event Handler + this.tableElem.addEventListener('dblclick', (e: MouseEvent) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was double clicked + e.stopPropagation(); + closeDialogAndContextMenu(); + const commitElem = eventElem.closest('.commit')!; + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + if (eventElem.classList.contains(CLASS_REF_HEAD) || eventElem.classList.contains(CLASS_REF_REMOTE)) { + let sourceElem = eventElem.children[1]; + let refName = unescapeHtml(eventElem.dataset.name!), isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); + if (isHead && isRemoteCombinedWithHead) { + refName = unescapeHtml((eventTarget).dataset.fullref!); + sourceElem = eventTarget; + isHead = false; + } + + const target: ContextMenuTarget & DialogTarget & RefTarget = { + type: TargetType.Ref, + hash: commit.hash, + index: parseInt(commitElem.dataset.id!), + ref: refName, + elem: sourceElem + }; + + this.checkoutBranchAction(refName, isHead ? null : unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!), null, target); + } + } + }); + + // Register ContextMenu Event Handler + this.tableElem.addEventListener('contextmenu', (e: Event) => { + if (e.target === null) return; + const eventTarget = e.target; + if (isUrlElem(eventTarget)) return; + let eventElem: HTMLElement | null; + + if ((eventElem = eventTarget.closest('.gitRef')) !== null) { + // .gitRef was right clicked + handledEvent(e); + const commitElem = eventElem.closest('.commit')!; + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + const target: ContextMenuTarget & DialogTarget & RefTarget = { + type: TargetType.Ref, + hash: commit.hash, + index: parseInt(commitElem.dataset.id!), + ref: unescapeHtml(eventElem.dataset.name!), + elem: eventElem.children[1] + }; + + let actions: ContextMenuActions; + if (eventElem.classList.contains(CLASS_REF_STASH)) { + actions = this.getStashContextMenuActions(target); + } else if (eventElem.classList.contains(CLASS_REF_TAG)) { + actions = this.getTagContextMenuActions(eventElem.dataset.tagtype === 'annotated', target); + } else { + let isHead = eventElem.classList.contains(CLASS_REF_HEAD), isRemoteCombinedWithHead = eventTarget.classList.contains('gitRefHeadRemote'); + if (isHead && isRemoteCombinedWithHead) { + target.ref = unescapeHtml((eventTarget).dataset.fullref!); + target.elem = eventTarget; + isHead = false; + } + if (isHead) { + actions = this.getBranchContextMenuActions(target); + } else { + const remote = unescapeHtml((isRemoteCombinedWithHead ? eventTarget : eventElem).dataset.remote!); + actions = this.getRemoteBranchContextMenuActions(remote, target); + } + } + + contextMenu.show(actions, false, target, e, this.viewElem); + + } else if ((eventElem = eventTarget.closest('.commit')) !== null) { + // .commit was right clicked + handledEvent(e); + const commit = this.getCommitOfElem(eventElem); + if (commit === null) return; + + const target: ContextMenuTarget & DialogTarget & CommitTarget = { + type: TargetType.Commit, + hash: commit.hash, + index: parseInt(eventElem.dataset.id!), + elem: eventElem + }; + + let actions: ContextMenuActions; + if (commit.hash === UNCOMMITTED) { + actions = this.getUncommittedChangesContextMenuActions(target); + } else if (commit.stash !== null) { + target.ref = commit.stash.selector; + actions = this.getStashContextMenuActions(target); + } else { + actions = this.getCommitContextMenuActions(target); + } + + contextMenu.show(actions, false, target, e, this.viewElem); + } + }); + } + + + /* Commit Details View */ + + public loadCommitDetails(commitElem: HTMLElement) { + const commit = this.getCommitOfElem(commitElem); + if (commit === null) return; + + this.closeCommitDetails(false); + this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, null, null); + commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + this.renderCommitDetailsView(false); + this.requestCommitDetails(commit.hash, false); + } + + public closeCommitDetails(saveAndRender: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + const elem = document.getElementById('cdv'), isDocked = this.isCdvDocked(); + if (elem !== null) { + elem.remove(); + } + if (isDocked) { + this.viewElem.style.bottom = '0px'; + } + if (expandedCommit.commitElem !== null) { + expandedCommit.commitElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + if (expandedCommit.compareWithElem !== null) { + expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + this.expandedCommit = null; + if (saveAndRender) { + this.saveState(); + if (!isDocked) { + this.renderGraph(); + } + } + } + + public showCommitDetails(commitDetails: GG.GitCommitDetails, fileTree: FileTreeFolder, avatar: string | null, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.commitHash !== commitDetails.hash || expandedCommit.compareWithHash !== null) return; + + if (!this.isCdvDocked()) { + const elem = document.getElementById('cdv'); + if (elem !== null) elem.remove(); + } + + expandedCommit.commitDetails = commitDetails; + if (haveFilesChanged(expandedCommit.fileChanges, commitDetails.fileChanges)) { + expandedCommit.fileChanges = commitDetails.fileChanges; + expandedCommit.fileTree = fileTree; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + } + expandedCommit.avatar = avatar; + expandedCommit.codeReview = codeReview; + if (!refresh) { + expandedCommit.lastViewedFile = lastViewedFile; + } + expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.loading = false; + this.saveState(); + + this.renderCommitDetailsView(refresh); + } + + public createFileTree(gitFiles: ReadonlyArray, codeReview: GG.CodeReview | null) { + let contents: FileTreeFolderContents = {}, i, j, path, absPath, cur: FileTreeFolder; + let files: FileTreeFolder = { type: 'folder', name: '', folderPath: '', contents: contents, open: true, reviewed: true }; + + for (i = 0; i < gitFiles.length; i++) { + cur = files; + path = gitFiles[i].newFilePath.split('/'); + absPath = this.currentRepo; + for (j = 0; j < path.length; j++) { + absPath += '/' + path[j]; + if (typeof this.gitRepos[absPath] !== 'undefined') { + if (typeof cur.contents[path[j]] === 'undefined') { + cur.contents[path[j]] = { type: 'repo', name: path[j], path: absPath }; + } + break; + } else if (j < path.length - 1) { + if (typeof cur.contents[path[j]] === 'undefined') { + contents = {}; + cur.contents[path[j]] = { type: 'folder', name: path[j], folderPath: absPath.substring(this.currentRepo.length + 1), contents: contents, open: true, reviewed: true }; + } + cur = cur.contents[path[j]]; + } else if (path[j] !== '') { + cur.contents[path[j]] = { type: 'file', name: path[j], index: i, reviewed: codeReview === null || !codeReview.remainingFiles.includes(gitFiles[i].newFilePath) }; + } + } + } + if (codeReview !== null) calcFileTreeFoldersReviewed(files); + return files; + } + + + /* Commit Comparison View */ + + private loadCommitComparison(commitElem: HTMLElement, compareWithElem: HTMLElement) { + const commit = this.getCommitOfElem(commitElem); + const compareWithCommit = this.getCommitOfElem(compareWithElem); + + if (commit !== null && compareWithCommit !== null) { + if (this.expandedCommit !== null) { + if (this.expandedCommit.commitHash !== commit.hash) { + this.closeCommitDetails(false); + } else if (this.expandedCommit.compareWithHash !== compareWithCommit.hash) { + this.closeCommitComparison(false); + } + } + + this.saveExpandedCommitLoading(parseInt(commitElem.dataset.id!), commit.hash, commitElem, compareWithCommit.hash, compareWithElem); + commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + this.renderCommitDetailsView(false); + this.requestCommitComparison(commit.hash, compareWithCommit.hash, false); + } + } + + public closeCommitComparison(saveAndRequestCommitDetails: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.compareWithHash === null) return; + + if (expandedCommit.compareWithElem !== null) { + expandedCommit.compareWithElem.classList.remove(CLASS_COMMIT_DETAILS_OPEN); + } + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + if (saveAndRequestCommitDetails) { + if (expandedCommit.commitElem !== null) { + this.saveExpandedCommitLoading(expandedCommit.index, expandedCommit.commitHash, expandedCommit.commitElem, null, null); + this.renderCommitDetailsView(false); + this.requestCommitDetails(expandedCommit.commitHash, false); + } else { + this.closeCommitDetails(true); + } + } + } + + public showCommitComparison(commitHash: string, compareWithHash: string, fileChanges: ReadonlyArray, fileTree: FileTreeFolder, codeReview: GG.CodeReview | null, lastViewedFile: string | null, refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null || expandedCommit.compareWithElem === null || expandedCommit.commitHash !== commitHash || expandedCommit.compareWithHash !== compareWithHash) return; + + if (haveFilesChanged(expandedCommit.fileChanges, fileChanges)) { + expandedCommit.fileChanges = fileChanges; + expandedCommit.fileTree = fileTree; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + } + expandedCommit.codeReview = codeReview; + if (!refresh) { + expandedCommit.lastViewedFile = lastViewedFile; + } + expandedCommit.commitElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.compareWithElem.classList.add(CLASS_COMMIT_DETAILS_OPEN); + expandedCommit.loading = false; + this.saveState(); + + this.renderCommitDetailsView(refresh); + } + + + /* Render Commit Details / Comparison View */ + + private renderCommitDetailsView(refresh: boolean) { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.commitElem === null) return; + + let elem = document.getElementById('cdv'), html = '
    ', isDocked = this.isCdvDocked(); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const codeReviewPossible = !expandedCommit.loading && commitOrder.to !== UNCOMMITTED; + const externalDiffPossible = !expandedCommit.loading && (expandedCommit.compareWithHash !== null || this.commits[this.commitLookup[expandedCommit.commitHash]].parents.length > 0); + + if (elem === null) { + elem = document.createElement(isDocked ? 'div' : 'tr'); + elem.id = 'cdv'; + elem.className = isDocked ? 'docked' : 'inline'; + this.setCdvHeight(elem, isDocked); + if (isDocked) { + document.body.appendChild(elem); + } else { + insertAfter(elem, expandedCommit.commitElem); + } + } + + if (expandedCommit.loading) { + html += '
    ' + SVG_ICONS.loading + ' Loading ' + (expandedCommit.compareWithHash === null ? expandedCommit.commitHash !== UNCOMMITTED ? 'Commit Details' : 'Uncommitted Changes' : 'Commit Comparison') + ' ...
    '; + } else { + html += '
    '; + if (expandedCommit.compareWithHash === null) { + // Commit details should be shown + if (expandedCommit.commitHash !== UNCOMMITTED) { + const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { + commits: true, + emoji: true, + issueLinking: true, + markdown: this.config.markdown, + multiline: true, + urls: true + }); + const commitDetails = expandedCommit.commitDetails!; + const parents = commitDetails.parents.length > 0 + ? commitDetails.parents.map((parent) => { + const escapedParent = escapeHtml(parent); + return typeof this.commitLookup[parent] === 'number' + ? '' + escapedParent + '' + : escapedParent; + }).join(', ') + : 'None'; + html += '' + + 'Commit: ' + escapeHtml(commitDetails.hash) + '
    ' + + 'Parents: ' + parents + '
    ' + + 'Author: ' + escapeHtml(commitDetails.author) + (commitDetails.authorEmail !== '' ? ' <' + escapeHtml(commitDetails.authorEmail) + '>' : '') + '
    ' + + (commitDetails.authorDate !== commitDetails.committerDate ? 'Author Date: ' + formatLongDate(commitDetails.authorDate) + '
    ' : '') + + 'Committer: ' + escapeHtml(commitDetails.committer) + (commitDetails.committerEmail !== '' ? ' <' + escapeHtml(commitDetails.committerEmail) + '>' : '') + (commitDetails.signature !== null ? generateSignatureHtml(commitDetails.signature) : '') + '
    ' + + '' + (commitDetails.authorDate !== commitDetails.committerDate ? 'Committer ' : '') + 'Date: ' + formatLongDate(commitDetails.committerDate) + + '
    ' + + (expandedCommit.avatar !== null ? '' : '') + + '


    ' + textFormatter.format(commitDetails.body); + } else { + html += 'Displaying all uncommitted changes.'; + } + } else { + // Commit comparison should be shown + html += 'Displaying all changes from ' + commitOrder.from + ' to ' + (commitOrder.to !== UNCOMMITTED ? commitOrder.to : 'Uncommitted Changes') + '.'; + } + html += '
    ' + (!isDocked ? '
    ' + SVG_ICONS.collapse + '
    ' : '') + '
    ' + generateFileViewHtml(expandedCommit.fileTree!, expandedCommit.fileChanges!, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, this.getFileViewType(), commitOrder.to === UNCOMMITTED) + '
    '; + } + html += '
    ' + SVG_ICONS.close + '
    ' + + (codeReviewPossible ? '
    ' + SVG_ICONS.review + '
    ' : '') + + (!expandedCommit.loading ? '
    ' + SVG_ICONS.fileList + '
    ' + SVG_ICONS.fileTree + '
    ' + SVG_ICONS.collapseAll + '
    ' + SVG_ICONS.expandAll + '
    ' : '') + + (externalDiffPossible ? '
    ' + SVG_ICONS.linkExternal + '
    ' : '') + + '
    '; + + elem.innerHTML = isDocked ? html : '
    ' + html + '
    '; + this.setCdvDivider(); + this.setCdvHeight(elem, isDocked); + if (!isDocked) this.renderGraph(); + + if (!refresh) { + if (isDocked) { + let elemTop = this.controlsElem.clientHeight + expandedCommit.commitElem.offsetTop; + if (elemTop - 8 < this.viewElem.scrollTop) { + // Commit is above what is visible on screen + this.viewElem.scroll(0, elemTop - 8); + } else if (elemTop - this.viewElem.clientHeight + 32 > this.viewElem.scrollTop) { + // Commit is below what is visible on screen + this.viewElem.scroll(0, elemTop - this.viewElem.clientHeight + 32); + } + } else { + let elemTop = this.controlsElem.clientHeight + elem.offsetTop, cdvHeight = this.gitRepos[this.currentRepo].cdvHeight; + if (this.config.commitDetailsView.autoCenter) { + // Center Commit Detail View setting is enabled + // elemTop - commit height [24px] + (commit details view height + commit height [24px]) / 2 - (view height) / 2 + this.viewElem.scroll(0, elemTop - 12 + (cdvHeight - this.viewElem.clientHeight) / 2); + } else if (elemTop - 32 < this.viewElem.scrollTop) { + // Commit Detail View is opening above what is visible on screen + // elemTop - commit height [24px] - desired gap from top [8px] < view scroll offset + this.viewElem.scroll(0, elemTop - 32); + } else if (elemTop + cdvHeight - this.viewElem.clientHeight + 8 > this.viewElem.scrollTop) { + // Commit Detail View is opening below what is visible on screen + // elemTop + commit details view height + desired gap from bottom [8px] - view height > view scroll offset + this.viewElem.scroll(0, elemTop + cdvHeight - this.viewElem.clientHeight + 8); + } + } + } + + this.makeCdvResizable(); + document.getElementById('cdvClose')!.addEventListener('click', () => { + this.closeCommitDetails(true); + }); + + if (!expandedCommit.loading) { + this.makeCdvFileViewInteractive(); + this.renderCdvFileViewTypeBtns(); + this.renderCdvExternalDiffBtn(); + this.makeCdvDividerDraggable(); + + observeElemScroll('cdvSummary', expandedCommit.scrollTop.summary, (scrollTop) => { + if (this.expandedCommit === null) return; + this.expandedCommit.scrollTop.summary = scrollTop; + if (this.expandedCommit.contextMenuOpen.summary) { + this.expandedCommit.contextMenuOpen.summary = false; + contextMenu.close(); + } + }, () => this.saveState()); + + observeElemScroll('cdvFilesView', expandedCommit.scrollTop.fileView, (scrollTop) => { + if (this.expandedCommit === null) return; + this.expandedCommit.scrollTop.fileView = scrollTop; + if (this.expandedCommit.contextMenuOpen.fileView > -1) { + this.expandedCommit.contextMenuOpen.fileView = -1; + contextMenu.close(); + } + }, () => this.saveState()); + + document.getElementById('cdvFileViewTypeTree')!.addEventListener('click', () => { + this.changeFileViewType(GG.FileViewType.Tree); + }); + + document.getElementById('cdvFileViewTypeList')!.addEventListener('click', () => { + this.changeFileViewType(GG.FileViewType.List); + }); + document.getElementById('cdvCollapse')!.addEventListener('click', () => { + this.openFolders(false); + }); + document.getElementById('cdvExpand')!.addEventListener('click', () => { + this.openFolders(true); + }); + let cdvSummaryToggleBtn = document.getElementById('cdvSummaryToggleBtn'); + if (cdvSummaryToggleBtn !== null) cdvSummaryToggleBtn.addEventListener('click', () => { + this.gitRepos[this.currentRepo].isCdvSummaryHidden = !(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + this.saveRepoState(); + this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + }); + this.hideCdvSummary(this.gitRepos[this.currentRepo].isCdvSummaryHidden); + + if (codeReviewPossible) { + this.renderCodeReviewBtn(); + document.getElementById('cdvCodeReview')!.addEventListener('click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || e.target === null) return; + let sourceElem = (e.target).closest('#cdvCodeReview')!; + if (sourceElem.classList.contains(CLASS_ACTIVE)) { + sendMessage({ command: 'endCodeReview', repo: this.currentRepo, id: expandedCommit.codeReview!.id }); + this.endCodeReview(); + } else { + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const id = expandedCommit.compareWithHash !== null ? commitOrder.from + '-' + commitOrder.to : expandedCommit.commitHash; + sendMessage({ + command: 'startCodeReview', + repo: this.currentRepo, + id: id, + commitHash: expandedCommit.commitHash, + compareWithHash: expandedCommit.compareWithHash, + files: getFilesInTree(expandedCommit.fileTree!, expandedCommit.fileChanges!), + lastViewedFile: expandedCommit.lastViewedFile + }); + } + }); + } + + if (externalDiffPossible) { + document.getElementById('cdvExternalDiff')!.addEventListener('click', () => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || this.gitConfig === null || (this.gitConfig.diffTool === null && this.gitConfig.guiDiffTool === null)) return; + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + runAction({ + command: 'openExternalDirDiff', + repo: this.currentRepo, + fromHash: commitOrder.from, + toHash: commitOrder.to, + isGui: this.gitConfig.guiDiffTool !== null + }, 'Opening External Directory Diff'); + }); + } + } + } + + private hideCdvSummary(hide: boolean) { + let btnIcon = document.getElementById('cdvSummaryToggleBtn')?.getElementsByTagName('svg')?.[0] ?? null; + let cdvSummary = document.getElementById('cdvSummary'); + if (hide && !this.isCdvDocked()) { + if (btnIcon) btnIcon.style.transform = 'rotate(90deg)'; + cdvSummary!.classList.add('hidden'); + } else { + if (btnIcon) btnIcon.style.transform = 'rotate(-90deg)'; + cdvSummary!.classList.remove('hidden'); + } + let elem = document.getElementById('cdv'); + if (elem !== null) this.setCdvHeight(elem, this.isCdvDocked()); + } + + private setCdvHeight(elem: HTMLElement, isDocked: boolean) { + let height = this.gitRepos[this.currentRepo].cdvHeight, windowHeight = window.innerHeight; + if (height > windowHeight - 40) { + height = Math.max(windowHeight - 40, 100); + if (height !== this.gitRepos[this.currentRepo].cdvHeight) { + this.gitRepos[this.currentRepo].cdvHeight = height; + this.saveRepoState(); + } + } + + let heightPx = height + 'px'; + if (isDocked) { + this.viewElem.style.bottom = heightPx; + elem.style.height = heightPx; + return; + } + let inlineElem = document.getElementById('cdvContentWrapper'); + if (!inlineElem) { + elem.style.height = heightPx; + return; + } + if (this.gitRepos[this.currentRepo].isCdvSummaryHidden) { + inlineElem.style.height = heightPx; + elem.style.height = '0px'; + } else { + inlineElem.style.removeProperty('height'); + elem.style.height = heightPx; + } + this.renderGraph(); + } + + private setCdvDivider() { + let percent = (this.gitRepos[this.currentRepo].cdvDivider * 100).toFixed(2) + '%'; + let summaryElem = document.getElementById('cdvSummary'), dividerElem = document.getElementById('cdvDivider'), filesElem = document.getElementById('cdvFiles'); + if (summaryElem !== null) summaryElem.style.width = percent; + if (dividerElem !== null) dividerElem.style.left = percent; + if (filesElem !== null) filesElem.style.left = percent; + } + + private makeCdvResizable() { + let prevY = -1; + + const processResizingCdvHeight: EventListener = (e) => { + if (prevY < 0) return; + let delta = (e).pageY - prevY, isDocked = this.isCdvDocked(), windowHeight = window.innerHeight; + prevY = (e).pageY; + let height = this.gitRepos[this.currentRepo].cdvHeight + (isDocked ? -delta : delta); + if (height < 100) height = 100; + else if (height > 600) height = 600; + if (height > windowHeight - 40) height = Math.max(windowHeight - 40, 100); + + if (this.gitRepos[this.currentRepo].cdvHeight !== height) { + this.gitRepos[this.currentRepo].cdvHeight = height; + let elem = document.getElementById('cdv'); + if (elem !== null) this.setCdvHeight(elem, isDocked); + if (!isDocked) this.renderGraph(); + } + }; + const stopResizingCdvHeight: EventListener = (e) => { + if (prevY < 0) return; + processResizingCdvHeight(e); + this.saveRepoState(); + prevY = -1; + eventOverlay.remove(); + }; + + addListenerToClass('cdvHeightResize', 'mousedown', (e) => { + prevY = (e).pageY; + eventOverlay.create('rowResize', processResizingCdvHeight, stopResizingCdvHeight); + }); + } + + private makeCdvDividerDraggable() { + let minX = -1, width = -1; + + const processDraggingCdvDivider: EventListener = (e) => { + if (minX < 0) return; + let percent = ((e).clientX - minX) / width; + if (percent < 0.2) percent = 0.2; + else if (percent > 0.8) percent = 0.8; + + if (this.gitRepos[this.currentRepo].cdvDivider !== percent) { + this.gitRepos[this.currentRepo].cdvDivider = percent; + this.setCdvDivider(); + } + }; + const stopDraggingCdvDivider: EventListener = (e) => { + if (minX < 0) return; + processDraggingCdvDivider(e); + this.saveRepoState(); + minX = -1; + eventOverlay.remove(); + }; + + document.getElementById('cdvDivider')!.addEventListener('mousedown', () => { + const contentElem = document.getElementById('cdvContent'); + if (contentElem === null) return; + + const bounds = contentElem.getBoundingClientRect(); + minX = bounds.left; + width = bounds.width; + eventOverlay.create('colResize', processDraggingCdvDivider, stopDraggingCdvDivider); + }); + } + + /** + * Updates the state of a file in the Commit Details View. + * @param file The file that was affected. + * @param fileElem The HTML Element of the file. + * @param isReviewed TRUE/FALSE => Set the files reviewed state accordingly, NULL => Don't update the files reviewed state. + * @param fileWasViewed Was the file viewed - if so, set it to be the last viewed file. + */ + private cdvUpdateFileState(file: GG.GitFileChange, fileElem: HTMLElement, isReviewed: boolean | null, fileWasViewed: boolean) { + const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'), filePath = file.newFilePath; + if (expandedCommit === null || expandedCommit.fileTree === null || filesElem === null) return; + + if (fileWasViewed) { + expandedCommit.lastViewedFile = filePath; + let lastViewedElem = document.getElementById('cdvLastFileViewed'); + if (lastViewedElem !== null) lastViewedElem.remove(); + lastViewedElem = document.createElement('span'); + lastViewedElem.id = 'cdvLastFileViewed'; + lastViewedElem.title = 'Last File Viewed'; + lastViewedElem.innerHTML = SVG_ICONS.eyeOpen; + insertBeforeFirstChildWithClass(lastViewedElem, fileElem, 'fileTreeFileAction'); + } + + if (expandedCommit.codeReview !== null) { + if (isReviewed !== null) { + if (isReviewed) { + expandedCommit.codeReview.remainingFiles = expandedCommit.codeReview.remainingFiles.filter((path: string) => path !== filePath); + } else { + expandedCommit.codeReview.remainingFiles.push(filePath); + } + + alterFileTreeFileReviewed(expandedCommit.fileTree, filePath, isReviewed); + updateFileTreeHtmlFileReviewed(filesElem, expandedCommit.fileTree, filePath); + } + + sendMessage({ + command: 'updateCodeReview', + repo: this.currentRepo, + id: expandedCommit.codeReview.id, + remainingFiles: expandedCommit.codeReview.remainingFiles, + lastViewedFile: expandedCommit.lastViewedFile + }); + + if (expandedCommit.codeReview.remainingFiles.length === 0) { + expandedCommit.codeReview = null; + this.renderCodeReviewBtn(); + } + } + + this.saveState(); + } + + private isCdvDocked() { + return this.config.commitDetailsView.location === GG.CommitDetailsViewLocation.DockedToBottom; + } + + public isCdvOpen(commitHash: string, compareWithHash: string | null) { + return this.expandedCommit !== null && this.expandedCommit.commitHash === commitHash && this.expandedCommit.compareWithHash === compareWithHash; + } + + private getCommitOrder(hash1: string, hash2: string) { + if (this.commitLookup[hash1] > this.commitLookup[hash2]) { + return { from: hash1, to: hash2 }; + } else { + return { from: hash2, to: hash1 }; + } + } + + private getFileViewType() { + return this.gitRepos[this.currentRepo].fileViewType === GG.FileViewType.Default + ? this.config.commitDetailsView.fileViewType + : this.gitRepos[this.currentRepo].fileViewType; + } + + private setFileViewType(type: GG.FileViewType) { + this.gitRepos[this.currentRepo].fileViewType = type; + this.saveRepoState(); + } + + private changeFileViewType(type: GG.FileViewType) { + const expandedCommit = this.expandedCommit, filesElem = document.getElementById('cdvFilesView'); + if (expandedCommit === null || expandedCommit.fileTree === null || expandedCommit.fileChanges === null || filesElem === null) return; + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + this.setFileViewType(type); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + filesElem.innerHTML = generateFileViewHtml(expandedCommit.fileTree, expandedCommit.fileChanges, expandedCommit.lastViewedFile, expandedCommit.contextMenuOpen.fileView, type, commitOrder.to === UNCOMMITTED); + this.makeCdvFileViewInteractive(); + this.renderCdvFileViewTypeBtns(); + } + + private openFolders(open: boolean) { + let expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileTree === null) return; + let folders = document.getElementsByClassName('fileTreeFolder'); + for (let i = 0; i < folders.length; i++) { + let sourceElem = (folders[i]); + let parent = sourceElem.parentElement!; + if (open) { + parent.classList.remove('closed'); + sourceElem.children[0].children[0].innerHTML = SVG_ICONS.openFolder; + parent.children[1].classList.remove('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), true); + + } else { + parent.classList.add('closed'); + sourceElem.children[0].children[0].innerHTML = SVG_ICONS.closedFolder; + parent.children[1].classList.add('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), false); + } + } + this.saveState(); + } + + private makeCdvFileViewInteractive() { + const getFileElemOfEventTarget = (target: EventTarget) => (target).closest('.fileTreeFileRecord'); + const getFileOfFileElem = (fileChanges: ReadonlyArray, fileElem: HTMLElement) => fileChanges[parseInt(fileElem.dataset.index!)]; + + const getCommitHashForFile = (file: GG.GitFileChange, expandedCommit: ExpandedCommit) => { + const commit = this.commits[this.commitLookup[expandedCommit.commitHash]]; + if (expandedCommit.compareWithHash !== null) { + return this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash).to; + } else if (commit.stash !== null && file.type === GG.GitFileStatus.Untracked) { + return commit.stash.untrackedFilesHash!; + } else { + return expandedCommit.commitHash; + } + }; + + const triggerViewFileDiff = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + let commit = this.commits[this.commitLookup[expandedCommit.commitHash]], fromHash: string, toHash: string, fileStatus = file.type; + if (expandedCommit.compareWithHash !== null) { + // Commit Comparison + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash); + fromHash = commitOrder.from; + toHash = commitOrder.to; + } else if (commit.stash !== null) { + // Stash Commit + if (fileStatus === GG.GitFileStatus.Untracked) { + fromHash = commit.stash.untrackedFilesHash!; + toHash = commit.stash.untrackedFilesHash!; + fileStatus = GG.GitFileStatus.Added; + } else { + fromHash = commit.stash.baseHash; + toHash = expandedCommit.commitHash; + } + } else { + // Single Commit + fromHash = expandedCommit.commitHash; + toHash = expandedCommit.commitHash; + } + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ + command: 'viewDiff', + repo: this.currentRepo, + fromHash: fromHash, + toHash: toHash, + oldFilePath: file.oldFilePath, + newFilePath: file.newFilePath, + type: fileStatus + }); + }; + + const triggerCopyFilePath = (file: GG.GitFileChange, absolute: boolean) => { + sendMessage({ command: 'copyFilePath', repo: this.currentRepo, filePath: file.newFilePath, absolute: absolute }); + }; + + const triggerResetFileToRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + const commitHash = getCommitHashForFile(file, expandedCommit); + dialog.showConfirmation('Are you sure you want to reset ' + escapeHtml(file.newFilePath) + ' to it\'s state at commit ' + abbrevCommit(commitHash) + '? Any uncommitted changes made to this file will be overwritten.', 'Yes, reset file', () => { + runAction({ command: 'resetFileToRevision', repo: this.currentRepo, commitHash: commitHash, filePath: file.newFilePath }, 'Resetting file'); + }, { + type: TargetType.CommitDetailsView, + hash: commitHash, + elem: fileElem + }); + }; + + const triggerViewFileAtRevision = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'viewFileAtRevision', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + const triggerViewFileDiffWithWorkingFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, null, true); + sendMessage({ command: 'viewDiffWithWorkingFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + const triggerOpenFile = (file: GG.GitFileChange, fileElem: HTMLElement) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null) return; + + this.cdvUpdateFileState(file, fileElem, true, true); + sendMessage({ command: 'openFile', repo: this.currentRepo, hash: getCommitHashForFile(file, expandedCommit), filePath: file.newFilePath }); + }; + + addListenerToClass('fileTreeFolder', 'click', (e) => { + let expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileTree === null || e.target === null) return; + + let sourceElem = (e.target).closest('.fileTreeFolder'); + let parent = sourceElem.parentElement!; + parent.classList.toggle('closed'); + let isOpen = !parent.classList.contains('closed'); + parent.children[0].children[0].innerHTML = isOpen ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder; + parent.children[1].classList.toggle('hidden'); + alterFileTreeFolderOpen(expandedCommit.fileTree, decodeURIComponent(sourceElem.dataset.folderpath!), isOpen); + this.saveState(); + }); + + addListenerToClass('fileTreeRepo', 'click', (e) => { + if (e.target === null) return; + this.loadRepos(this.gitRepos, null, { + repo: decodeURIComponent(((e.target).closest('.fileTreeRepo')).dataset.path!) + }); + }); + + addListenerToClass('fileTreeFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const sourceElem = (e.target).closest('.fileTreeFile'), fileElem = getFileElemOfEventTarget(e.target); + if (!sourceElem.classList.contains('gitDiffPossible')) return; + triggerViewFileDiff(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('copyGitFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerCopyFilePath(getFileOfFileElem(expandedCommit.fileChanges, fileElem), true); + }); + + addListenerToClass('viewGitFileAtRevision', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerViewFileAtRevision(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('openGitFile', 'click', (e) => { + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + + const fileElem = getFileElemOfEventTarget(e.target); + triggerOpenFile(getFileOfFileElem(expandedCommit.fileChanges, fileElem), fileElem); + }); + + addListenerToClass('fileTreeFileRecord', 'contextmenu', (e: Event) => { + handledEvent(e); + const expandedCommit = this.expandedCommit; + if (expandedCommit === null || expandedCommit.fileChanges === null || e.target === null) return; + const fileElem = getFileElemOfEventTarget(e.target); + const file = getFileOfFileElem(expandedCommit.fileChanges, fileElem); + const commitOrder = this.getCommitOrder(expandedCommit.commitHash, expandedCommit.compareWithHash === null ? expandedCommit.commitHash : expandedCommit.compareWithHash); + const isUncommitted = commitOrder.to === UNCOMMITTED; + + GitGraphView.closeCdvContextMenuIfOpen(expandedCommit); + expandedCommit.contextMenuOpen.fileView = parseInt(fileElem.dataset.index!); + + const target: ContextMenuTarget & CommitTarget = { + type: TargetType.CommitDetailsView, + hash: expandedCommit.commitHash, + index: this.commitLookup[expandedCommit.commitHash], + elem: fileElem + }; + const diffPossible = file.type === GG.GitFileStatus.Untracked || (file.additions !== null && file.deletions !== null); + const fileExistsAtThisRevision = file.type !== GG.GitFileStatus.Deleted && !isUncommitted; + const fileExistsAtThisRevisionAndDiffPossible = fileExistsAtThisRevision && diffPossible; + const codeReviewInProgressAndNotReviewed = expandedCommit.codeReview !== null && expandedCommit.codeReview.remainingFiles.includes(file.newFilePath); + const visibility = this.config.contextMenuActionsVisibility.commitDetailsViewFile; + + contextMenu.show([ + [ + { + title: 'View Diff', + visible: visibility.viewDiff && diffPossible, + onClick: () => triggerViewFileDiff(file, fileElem) + }, + { + title: 'View File at this Revision', + visible: visibility.viewFileAtThisRevision && fileExistsAtThisRevisionAndDiffPossible, + onClick: () => triggerViewFileAtRevision(file, fileElem) + }, + { + title: 'View Diff with Working File', + visible: visibility.viewDiffWithWorkingFile && fileExistsAtThisRevisionAndDiffPossible, + onClick: () => triggerViewFileDiffWithWorkingFile(file, fileElem) + }, + { + title: 'Open File', + visible: visibility.openFile && file.type !== GG.GitFileStatus.Deleted, + onClick: () => triggerOpenFile(file, fileElem) + } + ], + [ + { + title: 'Mark as Reviewed', + visible: visibility.markAsReviewed && codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, true, false) + }, + { + title: 'Mark as Not Reviewed', + visible: visibility.markAsNotReviewed && expandedCommit.codeReview !== null && !codeReviewInProgressAndNotReviewed, + onClick: () => this.cdvUpdateFileState(file, fileElem, false, false) + } + ], + [ + { + title: 'Reset File to this Revision' + ELLIPSIS, + visible: visibility.resetFileToThisRevision && fileExistsAtThisRevision && expandedCommit.compareWithHash === null, + onClick: () => triggerResetFileToRevision(file, fileElem) + } + ], + [ + { + title: 'Copy Absolute File Path to Clipboard', + visible: visibility.copyAbsoluteFilePath, + onClick: () => triggerCopyFilePath(file, true) + }, + { + title: 'Copy Relative File Path to Clipboard', + visible: visibility.copyRelativeFilePath, + onClick: () => triggerCopyFilePath(file, false) + } + ] + ], false, target, e, this.isCdvDocked() ? document.body : this.viewElem, () => { + expandedCommit.contextMenuOpen.fileView = -1; + }); + }); + } + + private renderCdvFileViewTypeBtns() { + if (this.expandedCommit === null) return; + let treeBtnElem = document.getElementById('cdvFileViewTypeTree'), listBtnElem = document.getElementById('cdvFileViewTypeList'); + if (treeBtnElem === null || listBtnElem === null) return; + + let listView = this.getFileViewType() === GG.FileViewType.List; + alterClass(treeBtnElem, CLASS_ACTIVE, !listView); + alterClass(listBtnElem, CLASS_ACTIVE, listView); + setFolderBtns(); + function setFolderBtns() { + let btns = document.getElementsByClassName('cdvFolderBtn'); + for (let i = 0; i < btns.length; i++) { + if (listView) + btns[i].classList.add('hidden'); + else + btns[i].classList.remove('hidden'); + } + } + } + + private renderCdvExternalDiffBtn() { + if (this.expandedCommit === null) return; + const externalDiffBtnElem = document.getElementById('cdvExternalDiff'); + if (externalDiffBtnElem === null) return; + + alterClass(externalDiffBtnElem, CLASS_ENABLED, this.gitConfig !== null && (this.gitConfig.diffTool !== null || this.gitConfig.guiDiffTool !== null)); + const toolName = this.gitConfig !== null + ? this.gitConfig.guiDiffTool !== null + ? this.gitConfig.guiDiffTool + : this.gitConfig.diffTool + : null; + externalDiffBtnElem.title = 'Open External Directory Diff' + (toolName !== null ? ' with "' + toolName + '"' : ''); + } + + private static closeCdvContextMenuIfOpen(expandedCommit: ExpandedCommit) { + if (expandedCommit.contextMenuOpen.summary || expandedCommit.contextMenuOpen.fileView > -1) { + expandedCommit.contextMenuOpen.summary = false; + expandedCommit.contextMenuOpen.fileView = -1; + contextMenu.close(); + } + } + + + /* Code Review */ + + public startCodeReview(commitHash: string, compareWithHash: string | null, codeReview: GG.CodeReview) { + if (this.expandedCommit === null || this.expandedCommit.commitHash !== commitHash || this.expandedCommit.compareWithHash !== compareWithHash) return; + this.saveAndRenderCodeReview(codeReview); + } + + public endCodeReview() { + if (this.expandedCommit === null || this.expandedCommit.codeReview === null) return; + this.saveAndRenderCodeReview(null); + } + + private saveAndRenderCodeReview(codeReview: GG.CodeReview | null) { + let filesElem = document.getElementById('cdvFilesView'); + if (this.expandedCommit === null || this.expandedCommit.fileTree === null || filesElem === null) return; + + this.expandedCommit.codeReview = codeReview; + setFileTreeReviewed(this.expandedCommit.fileTree, codeReview === null); + this.saveState(); + this.renderCodeReviewBtn(); + updateFileTreeHtml(filesElem, this.expandedCommit.fileTree); + } + + private renderCodeReviewBtn() { + if (this.expandedCommit === null) return; + let btnElem = document.getElementById('cdvCodeReview'); + if (btnElem === null) return; + + let active = this.expandedCommit.codeReview !== null; + alterClass(btnElem, CLASS_ACTIVE, active); + btnElem.title = (active ? 'End' : 'Start') + ' Code Review'; + } +} + + +/* Main */ + +const contextMenu = new ContextMenu(), dialog = new Dialog(), eventOverlay = new EventOverlay(); +let loaded = false; + +window.addEventListener('load', () => { + if (loaded) return; + loaded = true; + + TextFormatter.registerCustomEmojiMappings(initialState.config.customEmojiShortcodeMappings); + + const viewElem = document.getElementById('view'); + if (viewElem === null) return; + + const gitGraph = new GitGraphView(viewElem, VSCODE_API.getState()); + const imageResizer = new ImageResizer(); + + /* Command Processing */ + window.addEventListener('message', event => { + const msg: GG.ResponseMessage = event.data; + switch (msg.command) { + case 'addRemote': + refreshOrDisplayError(msg.error, 'Unable to Add Remote', true); + break; + case 'addTag': + if (msg.pushToRemote !== null && msg.errors.length === 2 && msg.errors[0] === null && isExtensionErrorInfo(msg.errors[1], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { + gitGraph.refresh(false); + handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, [msg.pushToRemote], msg.commitHash, msg.errors[1]!); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Add Tag'); + } + break; + case 'applyStash': + refreshOrDisplayError(msg.error, 'Unable to Apply Stash'); + break; + case 'branchFromStash': + refreshOrDisplayError(msg.error, 'Unable to Create Branch from Stash'); + break; + case 'checkoutBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Checkout Branch' + (msg.pullAfterwards !== null ? ' & Pull Changes' : '')); + break; + case 'checkoutCommit': + refreshOrDisplayError(msg.error, 'Unable to Checkout Commit'); + break; + case 'cherrypickCommit': + refreshAndDisplayErrors(msg.errors, 'Unable to Cherry Pick Commit'); + break; + case 'cleanUntrackedFiles': + refreshOrDisplayError(msg.error, 'Unable to Clean Untracked Files'); + break; + case 'commitDetails': + if (msg.commitDetails !== null) { + gitGraph.showCommitDetails(msg.commitDetails, gitGraph.createFileTree(msg.commitDetails.fileChanges, msg.codeReview), msg.avatar, msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); + } else { + gitGraph.closeCommitDetails(true); + dialog.showError('Unable to load Commit Details', msg.error, null, null); + } + break; + case 'compareCommits': + if (msg.error === null) { + gitGraph.showCommitComparison(msg.commitHash, msg.compareWithHash, msg.fileChanges, gitGraph.createFileTree(msg.fileChanges, msg.codeReview), msg.codeReview, msg.codeReview !== null ? msg.codeReview.lastViewedFile : null, msg.refresh); + } else { + gitGraph.closeCommitComparison(true); + dialog.showError('Unable to load Commit Comparison', msg.error, null, null); + } + break; + case 'copyFilePath': + finishOrDisplayError(msg.error, 'Unable to Copy File Path to Clipboard'); + break; + case 'copyToClipboard': + finishOrDisplayError(msg.error, 'Unable to Copy ' + msg.type + ' to Clipboard'); + break; + case 'createArchive': + finishOrDisplayError(msg.error, 'Unable to Create Archive', true); + break; + case 'createBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Create Branch'); + break; + case 'createPullRequest': + finishOrDisplayErrors(msg.errors, 'Unable to Create Pull Request', () => { + if (msg.push) { + gitGraph.refresh(false); + } + }, true); + break; + case 'deleteBranch': + handleResponseDeleteBranch(msg); + break; + case 'deleteRemote': + refreshOrDisplayError(msg.error, 'Unable to Delete Remote', true); + break; + case 'deleteRemoteBranch': + refreshOrDisplayError(msg.error, 'Unable to Delete Remote Branch'); + break; + case 'deleteTag': + refreshOrDisplayError(msg.error, 'Unable to Delete Tag'); + break; + case 'deleteUserDetails': + finishOrDisplayErrors(msg.errors, 'Unable to Remove Git User Details', () => gitGraph.requestLoadConfig(), true); + break; + case 'dropCommit': + refreshOrDisplayError(msg.error, 'Unable to Drop Commit'); + break; + case 'dropStash': + refreshOrDisplayError(msg.error, 'Unable to Drop Stash'); + break; + case 'editRemote': + refreshOrDisplayError(msg.error, 'Unable to Save Changes to Remote', true); + break; + case 'editUserDetails': + finishOrDisplayErrors(msg.errors, 'Unable to Save Git User Details', () => gitGraph.requestLoadConfig(), true); + break; + case 'exportRepoConfig': + refreshOrDisplayError(msg.error, 'Unable to Export Repository Configuration'); + break; + case 'fetch': + refreshOrDisplayError(msg.error, 'Unable to Fetch from Remote(s)'); + break; + case 'fetchAvatar': + imageResizer.resize(msg.image, (resizedImage) => { + gitGraph.loadAvatar(msg.email, resizedImage); + }); + break; + case 'fetchIntoLocalBranch': + refreshOrDisplayError(msg.error, 'Unable to Fetch into Local Branch'); + break; + case 'loadCommits': + gitGraph.processLoadCommitsResponse(msg); + break; + case 'loadConfig': + gitGraph.processLoadConfig(msg); + break; + case 'loadRepoInfo': + gitGraph.processLoadRepoInfoResponse(msg); + break; + case 'loadRepos': + gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); + break; + case 'scrollToCommit': + gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); // if graph exist + gitGraph.scrollToCommitArgs = { // if graph is creating + hash: msg.hash, + alwaysCenterCommit: msg.alwaysCenterCommit, + flash: msg.flash, + openDetails: msg.openDetails, + persistently: msg.persistently + }; + break; + case 'merge': + refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); + break; + case 'openExtensionSettings': + finishOrDisplayError(msg.error, 'Unable to Open Extension Settings'); + break; + case 'openExternalDirDiff': + finishOrDisplayError(msg.error, 'Unable to Open External Directory Diff', true); + break; + case 'openExternalUrl': + finishOrDisplayError(msg.error, 'Unable to Open External URL'); + break; + case 'openFile': + finishOrDisplayError(msg.error, 'Unable to Open File'); + break; + case 'openTerminal': + finishOrDisplayError(msg.error, 'Unable to Open Terminal', true); + break; + case 'popStash': + refreshOrDisplayError(msg.error, 'Unable to Pop Stash'); + break; + case 'pruneRemote': + refreshOrDisplayError(msg.error, 'Unable to Prune Remote'); + break; + case 'pullBranch': + refreshOrDisplayError(msg.error, 'Unable to Pull Branch'); + break; + case 'pushBranch': + refreshAndDisplayErrors(msg.errors, 'Unable to Push Branch', msg.willUpdateBranchConfig); + break; + case 'pushStash': + refreshOrDisplayError(msg.error, 'Unable to Stash Uncommitted Changes'); + break; + case 'pushTag': + if (msg.errors.length === 1 && isExtensionErrorInfo(msg.errors[0], GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote)) { + handleResponsePushTagCommitNotOnRemote(msg.repo, msg.tagName, msg.remotes, msg.commitHash, msg.errors[0]!); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Push Tag'); + } + break; + case 'rebase': + if (msg.error === null) { + if (msg.interactive) { + dialog.closeActionRunning(); + } else { + gitGraph.refresh(false); + } + } else { + dialog.showError('Unable to Rebase current branch on ' + msg.actionOn, msg.error, null, null); + } + break; + case 'refresh': + gitGraph.refresh(false); + break; + case 'renameBranch': + refreshOrDisplayError(msg.error, 'Unable to Rename Branch'); + break; + case 'resetFileToRevision': + refreshOrDisplayError(msg.error, 'Unable to Reset File to Revision'); + break; + case 'resetToCommit': + refreshOrDisplayError(msg.error, 'Unable to Reset to Commit'); + break; + case 'revertCommit': + refreshOrDisplayError(msg.error, 'Unable to Revert Commit'); + break; + case 'setGlobalViewState': + finishOrDisplayError(msg.error, 'Unable to save the Global View State'); + break; + case 'setWorkspaceViewState': + finishOrDisplayError(msg.error, 'Unable to save the Workspace View State'); + break; + case 'startCodeReview': + if (msg.error === null) { + gitGraph.startCodeReview(msg.commitHash, msg.compareWithHash, msg.codeReview); + } else { + dialog.showError('Unable to Start Code Review', msg.error, null, null); + } + break; + case 'tagDetails': + if (msg.details !== null) { + gitGraph.renderTagDetails(msg.tagName, msg.commitHash, msg.details); + } else { + dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); + } + break; + case 'updateCodeReview': + if (msg.error !== null) { + dialog.showError('Unable to update Code Review', msg.error, null, null); + } + break; + case 'viewDiff': + finishOrDisplayError(msg.error, 'Unable to View Diff'); + break; + case 'viewDiffWithWorkingFile': + finishOrDisplayError(msg.error, 'Unable to View Diff with Working File'); + break; + case 'viewFileAtRevision': + finishOrDisplayError(msg.error, 'Unable to View File at Revision'); + break; + case 'viewScm': + finishOrDisplayError(msg.error, 'Unable to open the Source Control View'); + break; + } + }); + + function handleResponseDeleteBranch(msg: GG.ResponseDeleteBranch) { + if (msg.errors.length > 0 && msg.errors[0] !== null && msg.errors[0].includes('git branch -D')) { + dialog.showConfirmation('The branch ' + escapeHtml(msg.branchName) + ' is not fully merged. Would you like to force delete it?', 'Yes, force delete branch', () => { + runAction({ command: 'deleteBranch', repo: msg.repo, branchName: msg.branchName, forceDelete: true, deleteOnRemotes: msg.deleteOnRemotes }, 'Deleting Branch'); + }, { type: TargetType.Repo }); + } else { + refreshAndDisplayErrors(msg.errors, 'Unable to Delete Branch'); + } + } + + function handleResponsePushTagCommitNotOnRemote(repo: string, tagName: string, remotes: string[], commitHash: string, error: string) { + const remotesNotContainingCommit: string[] = parseExtensionErrorInfo(error, GG.ErrorInfoExtensionPrefix.PushTagCommitNotOnRemote); + + const html = '' + SVG_ICONS.alert + 'Warning: Commit is not on Remote' + (remotesNotContainingCommit.length > 1 ? 's ' : ' ') + '
    ' + + '' + + '

    The tag ' + escapeHtml(tagName) + ' is on a commit that isn\'t on any known branch on the remote' + (remotesNotContainingCommit.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotesNotContainingCommit.map((remote) => '' + escapeHtml(remote) + '')) + '.

    ' + + '

    Would you like to proceed to push the tag to the remote' + (remotes.length > 1 ? 's' : '') + ' ' + formatCommaSeparatedList(remotes.map((remote) => '' + escapeHtml(remote) + '')) + ' anyway?

    ' + + '
    '; + + dialog.showForm(html, [{ type: DialogInputType.Checkbox, name: 'Always Proceed', value: false }], 'Proceed to Push', (values) => { + if (values[0]) { + updateGlobalViewState('pushTagSkipRemoteCheck', true); + } + runAction({ + command: 'pushTag', + repo: repo, + tagName: tagName, + remotes: remotes, + commitHash: commitHash, + skipRemoteCheck: true + }, 'Pushing Tag'); + }, { type: TargetType.Repo }, 'Cancel', null, true); + } + + function refreshOrDisplayError(error: GG.ErrorInfo, errorMessage: string, configChanges: boolean = false) { + if (error === null) { + gitGraph.refresh(false, configChanges); + } else { + dialog.showError(errorMessage, error, null, null); + } + } + + function refreshAndDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, configChanges: boolean = false) { + const reducedErrors = reduceErrorInfos(errors); + if (reducedErrors.error !== null) { + dialog.showError(errorMessage, reducedErrors.error, null, null); + } + if (reducedErrors.partialOrCompleteSuccess) { + gitGraph.refresh(false, configChanges); + } else if (configChanges) { + gitGraph.requestLoadConfig(); + } + } + + function finishOrDisplayError(error: GG.ErrorInfo, errorMessage: string, dismissActionRunning: boolean = false) { + if (error !== null) { + dialog.showError(errorMessage, error, null, null); + } else if (dismissActionRunning) { + dialog.closeActionRunning(); + } + } + + function finishOrDisplayErrors(errors: GG.ErrorInfo[], errorMessage: string, partialOrCompleteSuccessCallback: () => void, dismissActionRunning: boolean = false) { + const reducedErrors = reduceErrorInfos(errors); + finishOrDisplayError(reducedErrors.error, errorMessage, dismissActionRunning); + if (reducedErrors.partialOrCompleteSuccess) { + partialOrCompleteSuccessCallback(); + } + } + + function reduceErrorInfos(errors: GG.ErrorInfo[]) { + let error: GG.ErrorInfo = null, partialOrCompleteSuccess = false; + for (let i = 0; i < errors.length; i++) { + if (errors[i] !== null) { + error = error !== null ? error + '\n\n' + errors[i] : errors[i]; + } else { + partialOrCompleteSuccess = true; + } + } + + return { + error: error, + partialOrCompleteSuccess: partialOrCompleteSuccess + }; + } + + /** + * Checks whether the given ErrorInfo has an ErrorInfoExtensionPrefix. + * @param error The ErrorInfo to check. + * @param prefix The ErrorInfoExtensionPrefix to test. + * @returns TRUE => ErrorInfo has the ErrorInfoExtensionPrefix, FALSE => ErrorInfo doesn\'t have the ErrorInfoExtensionPrefix + */ + function isExtensionErrorInfo(error: GG.ErrorInfo, prefix: GG.ErrorInfoExtensionPrefix) { + return error !== null && error.startsWith(prefix); + } + + /** + * Parses the JSON data from an ErrorInfo prefixed by the provided ErrorInfoExtensionPrefix. + * @param error The ErrorInfo to parse. + * @param prefix The ErrorInfoExtensionPrefix used by `error`. + * @returns The parsed JSON data. + */ + function parseExtensionErrorInfo(error: string, prefix: GG.ErrorInfoExtensionPrefix) { + return JSON.parse(error.substring(prefix.length)); + } +}); + + +/* File Tree Methods (for the Commit Details & Comparison Views) */ + +function generateFileViewHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, type: GG.FileViewType, isUncommitted: boolean) { + return type === GG.FileViewType.List + ? generateFileListHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted) + : generateFileTreeHtml(folder, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, true); +} + +function generateFileTreeHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean, topLevelFolder: boolean): string { + const curFolderInfo = topLevelFolder || !initialState.config.commitDetailsView.fileTreeCompactFolders + ? { folder: folder, name: folder.name, pathSeg: folder.name } + : getCurrentFolderInfo(folder, folder.name, folder.name); + + const children = sortFolderKeys(curFolderInfo.folder).map((key) => { + const cur = curFolderInfo.folder.contents[key]; + return cur.type === 'folder' + ? generateFileTreeHtml(cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted, false) + : generateFileTreeLeafHtml(cur.name, cur, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); + }); + + return (topLevelFolder ? '' : '' + (curFolderInfo.folder.open ? SVG_ICONS.openFolder : SVG_ICONS.closedFolder) + '' + escapeHtml(curFolderInfo.name) + '') + + '
      ' + children.join('') + '
    ' + + (topLevelFolder ? '' : ''); +} + +function getCurrentFolderInfo(folder: FileTreeFolder, name: string, pathSeg: string): { folder: FileTreeFolder, name: string, pathSeg: string } { + const keys = Object.keys(folder.contents); + let child: FileTreeNode; + return keys.length === 1 && (child = folder.contents[keys[0]]).type === 'folder' + ? getCurrentFolderInfo(child, name + ' / ' + child.name, pathSeg + '/' + child.name) + : { folder: folder, name: name, pathSeg: pathSeg }; +} + +function generateFileListHtml(folder: FileTreeFolder, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { + const sortLeaves = (folder: FileTreeFolder, folderPath: string) => { + let keys = sortFolderKeys(folder); + let items: { relPath: string, leaf: FileTreeLeaf }[] = []; + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + let relPath = (folderPath !== '' ? folderPath + '/' : '') + cur.name; + if (cur.type === 'folder') { + items = items.concat(sortLeaves(cur, relPath)); + } else { + items.push({ relPath: relPath, leaf: cur }); + } + } + return items; + }; + let sortedLeaves = sortLeaves(folder, ''); + let html = ''; + for (let i = 0; i < sortedLeaves.length; i++) { + html += generateFileTreeLeafHtml(sortedLeaves[i].relPath, sortedLeaves[i].leaf, gitFiles, lastViewedFile, fileContextMenuOpen, isUncommitted); + } + return '
      ' + html + '
    '; +} + +function generateFileTreeLeafHtml(name: string, leaf: FileTreeLeaf, gitFiles: ReadonlyArray, lastViewedFile: string | null, fileContextMenuOpen: number, isUncommitted: boolean) { + let encodedName = encodeURIComponent(name), escapedName = escapeHtml(name); + if (leaf.type === 'file') { + const fileTreeFile = gitFiles[leaf.index]; + const textFile = fileTreeFile.additions !== null && fileTreeFile.deletions !== null; + const diffPossible = fileTreeFile.type === GG.GitFileStatus.Untracked || textFile; + const changeTypeMessage = GIT_FILE_CHANGE_TYPES[fileTreeFile.type] + (fileTreeFile.type === GG.GitFileStatus.Renamed ? ' (' + escapeHtml(fileTreeFile.oldFilePath) + ' → ' + escapeHtml(fileTreeFile.newFilePath) + ')' : ''); + return '
  • ' + SVG_ICONS.file + '' + escapedName + '' + + (initialState.config.enhancedAccessibility ? '' + fileTreeFile.type + '' : '') + + (fileTreeFile.type !== GG.GitFileStatus.Added && fileTreeFile.type !== GG.GitFileStatus.Untracked && fileTreeFile.type !== GG.GitFileStatus.Deleted && textFile ? '(+' + fileTreeFile.additions + '|-' + fileTreeFile.deletions + ')' : '') + + (fileTreeFile.newFilePath === lastViewedFile ? '' + SVG_ICONS.eyeOpen + '' : '') + + '' + SVG_ICONS.copy + '' + + (fileTreeFile.type !== GG.GitFileStatus.Deleted + ? (diffPossible && !isUncommitted ? '' + SVG_ICONS.commit + '' : '') + + '' + SVG_ICONS.openFile + '' + : '' + ) + '
  • '; + } else { + return '
  • ' + SVG_ICONS.closedFolder + '' + escapedName + '
  • '; + } +} + +function alterFileTreeFolderOpen(folder: FileTreeFolder, folderPath: string, open: boolean) { + let path = folderPath.split('/'), i, cur = folder; + for (i = 0; i < path.length; i++) { + if (typeof cur.contents[path[i]] !== 'undefined') { + cur = cur.contents[path[i]]; + if (i === path.length - 1) cur.open = open; + } else { + return; + } + } +} + +function alterFileTreeFileReviewed(folder: FileTreeFolder, filePath: string, reviewed: boolean) { + let path = filePath.split('/'), i, cur = folder, folders = [folder]; + for (i = 0; i < path.length; i++) { + if (typeof cur.contents[path[i]] !== 'undefined') { + if (i < path.length - 1) { + cur = cur.contents[path[i]]; + folders.push(cur); + } else { + (cur.contents[path[i]]).reviewed = reviewed; + } + } else { + break; + } + } + + // Recalculate whether each of the folders leading to the file are now reviewed (deepest first). + for (i = folders.length - 1; i >= 0; i--) { + let keys = Object.keys(folders[i].contents), entireFolderReviewed = true; + for (let j = 0; j < keys.length; j++) { + let cur = folders[i].contents[keys[j]]; + if ((cur.type === 'folder' || cur.type === 'file') && !cur.reviewed) { + entireFolderReviewed = false; + break; + } + } + folders[i].reviewed = entireFolderReviewed; + } +} + +function setFileTreeReviewed(folder: FileTreeFolder, reviewed: boolean) { + folder.reviewed = reviewed; + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if (cur.type === 'folder') { + setFileTreeReviewed(cur, reviewed); + } else if (cur.type === 'file') { + cur.reviewed = reviewed; + } + } +} + +function calcFileTreeFoldersReviewed(folder: FileTreeFolder) { + const calc = (folder: FileTreeFolder) => { + let reviewed = true; + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if ((cur.type === 'folder' && !calc(cur)) || (cur.type === 'file' && !cur.reviewed)) reviewed = false; + } + folder.reviewed = reviewed; + return reviewed; + }; + calc(folder); +} + +function updateFileTreeHtml(elem: HTMLElement, folder: FileTreeFolder) { + let ul = getChildUl(elem); + if (ul === null) return; + + for (let i = 0; i < ul.children.length; i++) { + let li = ul.children[i]; + let pathSeg = decodeURIComponent(li.dataset.pathseg!); + let child = getChildByPathSegment(folder, pathSeg); + if (child.type === 'folder') { + alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); + updateFileTreeHtml(li, child); + } else if (child.type === 'file') { + alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); + } + } +} + +function updateFileTreeHtmlFileReviewed(elem: HTMLElement, folder: FileTreeFolder, filePath: string) { + let path = filePath; + const update = (elem: HTMLElement, folder: FileTreeFolder) => { + let ul = getChildUl(elem); + if (ul === null) return; + + for (let i = 0; i < ul.children.length; i++) { + let li = ul.children[i]; + let pathSeg = decodeURIComponent(li.dataset.pathseg!); + if (path === pathSeg || path.startsWith(pathSeg + '/')) { + let child = getChildByPathSegment(folder, pathSeg); + if (child.type === 'folder') { + alterClass(li.children[0], CLASS_PENDING_REVIEW, !child.reviewed); + path = path.substring(pathSeg.length + 1); + update(li, child); + } else if (child.type === 'file') { + alterClass(li.children[0].children[0], CLASS_PENDING_REVIEW, !child.reviewed); + } + break; + } + } + }; + update(elem, folder); +} + +function getFilesInTree(folder: FileTreeFolder, gitFiles: ReadonlyArray) { + let files: string[] = []; + const scanFolder = (folder: FileTreeFolder) => { + let keys = Object.keys(folder.contents); + for (let i = 0; i < keys.length; i++) { + let cur = folder.contents[keys[i]]; + if (cur.type === 'folder') { + scanFolder(cur); + } else if (cur.type === 'file') { + files.push(gitFiles[cur.index].newFilePath); + } + } + }; + scanFolder(folder); + return files; +} + +function sortFolderKeys(folder: FileTreeFolder) { + let keys = Object.keys(folder.contents); + keys.sort((a, b) => folder.contents[a].type !== 'file' && folder.contents[b].type === 'file' ? -1 : folder.contents[a].type === 'file' && folder.contents[b].type !== 'file' ? 1 : folder.contents[a].name.localeCompare(folder.contents[b].name)); + return keys; +} + +function getChildByPathSegment(folder: FileTreeFolder, pathSeg: string) { + let cur: FileTreeNode = folder, comps = pathSeg.split('/'); + for (let i = 0; i < comps.length; i++) { + cur = (cur).contents[comps[i]]; + } + return cur; +} + + +/* Repository State Helpers */ + +function getCommitOrdering(repoValue: GG.RepoCommitOrdering): GG.CommitOrdering { + switch (repoValue) { + case GG.RepoCommitOrdering.Default: + return initialState.config.commitOrdering; + case GG.RepoCommitOrdering.Date: + return GG.CommitOrdering.Date; + case GG.RepoCommitOrdering.AuthorDate: + return GG.CommitOrdering.AuthorDate; + case GG.RepoCommitOrdering.Topological: + return GG.CommitOrdering.Topological; + } +} + +function getShowRemoteBranches(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showRemoteBranches + : repoValue === GG.BooleanOverride.Enabled; +} + +function getSimplifyByDecoration(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.simplifyByDecoration + : repoValue === GG.BooleanOverride.Enabled; +} + +function getShowStashes(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showStashes + : repoValue === GG.BooleanOverride.Enabled; +} + +function getShowTags(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.showTags + : repoValue === GG.BooleanOverride.Enabled; +} + +function getIncludeCommitsMentionedByReflogs(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.includeCommitsMentionedByReflogs + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnlyFollowFirstParent(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.onlyFollowFirstParent + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnRepoLoadShowCheckedOutBranch(repoValue: GG.BooleanOverride) { + return repoValue === GG.BooleanOverride.Default + ? initialState.config.onRepoLoad.showCheckedOutBranch + : repoValue === GG.BooleanOverride.Enabled; +} + +function getOnRepoLoadShowSpecificBranches(repoValue: string[] | null) { + return repoValue === null + ? initialState.config.onRepoLoad.showSpecificBranches + : repoValue; +} + + +/* Miscellaneous Helper Methods */ + +function haveFilesChanged(oldFiles: ReadonlyArray | null, newFiles: ReadonlyArray | null) { + if ((oldFiles === null) !== (newFiles === null)) { + return true; + } else if (oldFiles === null && newFiles === null) { + return false; + } else { + return !arraysEqual(oldFiles!, newFiles!, (a, b) => a.additions === b.additions && a.deletions === b.deletions && a.newFilePath === b.newFilePath && a.oldFilePath === b.oldFilePath && a.type === b.type); + } +} + +function abbrevCommit(commitHash: string) { + return commitHash.substring(0, 8); +} + +function getRepoDropdownOptions(repos: Readonly) { + const repoPaths = getSortedRepositoryPaths(repos, initialState.config.repoDropdownOrder); + const paths: string[] = [], names: string[] = [], distinctNames: string[] = [], firstSep: number[] = []; + const resolveAmbiguous = (indexes: number[]) => { + // Find ambiguous names within indexes + let firstOccurrence: { [name: string]: number } = {}, ambiguous: { [name: string]: number[] } = {}; + for (let i = 0; i < indexes.length; i++) { + let name = distinctNames[indexes[i]]; + if (typeof firstOccurrence[name] === 'number') { + // name is ambiguous + if (typeof ambiguous[name] === 'undefined') { + // initialise ambiguous array with the first occurrence + ambiguous[name] = [firstOccurrence[name]]; + } + ambiguous[name].push(indexes[i]); // append current ambiguous index + } else { + firstOccurrence[name] = indexes[i]; // set the first occurrence of the name + } + } + + let ambiguousNames = Object.keys(ambiguous); + for (let i = 0; i < ambiguousNames.length; i++) { + // For each ambiguous name, resolve the ambiguous indexes + let ambiguousIndexes = ambiguous[ambiguousNames[i]], retestIndexes = []; + for (let j = 0; j < ambiguousIndexes.length; j++) { + let ambiguousIndex = ambiguousIndexes[j]; + let nextSep = paths[ambiguousIndex].lastIndexOf('/', paths[ambiguousIndex].length - distinctNames[ambiguousIndex].length - 2); + if (firstSep[ambiguousIndex] < nextSep) { + // prepend the addition path and retest + distinctNames[ambiguousIndex] = paths[ambiguousIndex].substring(nextSep + 1); + retestIndexes.push(ambiguousIndex); + } else { + distinctNames[ambiguousIndex] = paths[ambiguousIndex]; + } + } + if (retestIndexes.length > 1) { + // If there are 2 or more indexes that may be ambiguous + resolveAmbiguous(retestIndexes); + } + } + }; + + // Initialise recursion + const indexes = []; + for (let i = 0; i < repoPaths.length; i++) { + firstSep.push(repoPaths[i].indexOf('/')); + const repo = repos[repoPaths[i]]; + if (repo.name) { + // A name has been set for the repository + paths.push(repoPaths[i]); + names.push(repo.name); + distinctNames.push(repo.name); + } else if (firstSep[i] === repoPaths[i].length - 1 || firstSep[i] === -1) { + // Path has no slashes, or a single trailing slash ==> use the path as the name + paths.push(repoPaths[i]); + names.push(repoPaths[i]); + distinctNames.push(repoPaths[i]); + } else { + paths.push(repoPaths[i].endsWith('/') ? repoPaths[i].substring(0, repoPaths[i].length - 1) : repoPaths[i]); // Remove trailing slash if it exists + let name = paths[i].substring(paths[i].lastIndexOf('/') + 1); + names.push(name); + distinctNames.push(name); + indexes.push(i); + } + } + resolveAmbiguous(indexes); + + const options: DropdownOption[] = []; + for (let i = 0; i < repoPaths.length; i++) { + let hint; + if (names[i] === distinctNames[i]) { + // Name is distinct, no hint needed + hint = ''; + } else { + // Hint path is the prefix of the distinctName before the common suffix with name + let hintPath = distinctNames[i].substring(0, distinctNames[i].length - names[i].length - 1); + + // Keep two informative directories + let hintComps = hintPath.split('/'); + let keepDirs = hintComps[0] !== '' ? 2 : 3; + if (hintComps.length > keepDirs) hintComps.splice(keepDirs, hintComps.length - keepDirs, '...'); + + // Construct the hint + hint = (distinctNames[i] !== paths[i] ? '.../' : '') + hintComps.join('/'); + } + options.push({ name: names[i], value: repoPaths[i], hint: hint }); + } + return options; +} + +function runAction(msg: GG.RequestMessage, action: string) { + dialog.showActionRunning(action); + sendMessage(msg); +} + +function getBranchLabels(heads: ReadonlyArray, remotes: ReadonlyArray) { + let headLabels: { name: string; remotes: string[] }[] = [], headLookup: { [name: string]: number } = {}, remoteLabels: ReadonlyArray; + for (let i = 0; i < heads.length; i++) { + headLabels.push({ name: heads[i], remotes: [] }); + headLookup[heads[i]] = i; + } + if (initialState.config.referenceLabels.combineLocalAndRemoteBranchLabels) { + let remainingRemoteLabels = []; + for (let i = 0; i < remotes.length; i++) { + if (remotes[i].remote !== null) { // If the remote of the remote branch ref is known + let branchName = remotes[i].name.substring(remotes[i].remote!.length + 1); + if (typeof headLookup[branchName] === 'number') { + headLabels[headLookup[branchName]].remotes.push(remotes[i].remote!); + continue; + } + } + remainingRemoteLabels.push(remotes[i]); + } + remoteLabels = remainingRemoteLabels; + } else { + remoteLabels = remotes; + } + return { heads: headLabels, remotes: remoteLabels }; +} + +function findCommitElemWithId(elems: HTMLCollectionOf, id: number | null) { + if (id === null) return null; + let findIdStr = id.toString(); + for (let i = 0; i < elems.length; i++) { + if (findIdStr === elems[i].dataset.id) return elems[i]; + } + return null; +} + +function generateSignatureHtml(signature: GG.GitSignature) { + return '' + + (signature.status === GG.GitSignatureStatus.GoodAndValid + ? SVG_ICONS.passed + : signature.status === GG.GitSignatureStatus.Bad + ? SVG_ICONS.failed + : SVG_ICONS.inconclusive) + + ''; +} + +function closeDialogAndContextMenu() { + if (dialog.isOpen()) dialog.close(); + if (contextMenu.isOpen()) contextMenu.close(); +} From 565f0498ce75907079037a33fa6a8bb050c539aa Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Thu, 24 Apr 2025 18:52:28 +0200 Subject: [PATCH 7/9] fix: identifying the correct repository for Go To Commit (cherry picked from commit c9fa645a574b8f8b94cd278d3e11e5b65044b4ba) --- src/commands.ts | 26 +++++++++++++++++++++----- src/utils.ts | 8 ++++++++ web/main.ts | 19 +++++++++++-------- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 55f085cd..96c59a56 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,7 +8,7 @@ import { CodeReviewData, CodeReviews, ExtensionState } from './extensionState'; import { GitGraphView } from './gitGraphView'; import { Logger } from './logger'; import { RepoManager } from './repoManager'; -import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage } from './utils'; +import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, VsCodeVersionRequirement, abbrevCommit, abbrevText, copyToClipboard, doesVersionMeetRequirement, getExtensionVersion, getPathFromUri, getRelativeTimeDiff, getRepoName, getSortedRepositoryPaths, isPathInWorkspace, openFile, resolveToSymbolicPath, showErrorMessage, showInformationMessage, showWarningMessage } from './utils'; import { Disposable } from './utils/disposable'; import { Event } from './utils/event'; @@ -351,28 +351,44 @@ export class CommandManager extends Disposable { * The method run when the `git-graph.goToCommit` command is invoked. * @param arg The Git Graph URI. */ - private goToCommit(arg?: vscode.Uri) { + private async goToCommit(arg?: vscode.Uri) { const uri = arg || vscode.window.activeTextEditor?.document.uri; + const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports; + const api = gitExtension.getAPI(1); + if (!gitExtension) { + showErrorMessage('Unable to load Git extension.'); + return; + } + if (typeof uri === 'object' && uri) { let commitHash = ''; + let repository = undefined; if (uri.scheme === 'git-graph') { - commitHash = decodeDiffDocUri(uri).commit; + let diffDocUri = decodeDiffDocUri(uri); + commitHash = diffDocUri.commit; + repository = api.getRepository(diffDocUri.repo); } if (uri.scheme === 'git' || uri.scheme === 'gitlens') { commitHash = JSON.parse(uri.query).ref; + repository = api.getRepository(uri); } if (uri.scheme === 'scm-history-item') { commitHash = uri.path.split('..')[1]; + repository = api.getRepository(uri); } if (commitHash !== '') { + if (!repository) { + showWarningMessage('Warning: no matching Git repository found for this file.'); + } + if (GitGraphView.currentPanel) { // graph exist GitGraphView.currentPanel.isPanelVisible = true; - this.view(undefined); + await this.view(repository); GitGraphView.scrollToCommit(commitHash, true, false, true, true); } else { // graph is creating - this.view(undefined); + await this.view(repository); GitGraphView.scrollToCommit(commitHash, true, false, true, true); } return; diff --git a/src/utils.ts b/src/utils.ts index 566f0c1f..6ccf7bdf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -543,6 +543,14 @@ export function showInformationMessage(message: string) { return vscode.window.showInformationMessage(message).then(() => { }, () => { }); } +/** + * Show a Visual Studio Code Warning Message Dialog with the specified message. + * @param message The message to show. + */ +export function showWarningMessage(message: string) { + return vscode.window.showWarningMessage(message).then(() => { }, () => { }); +} + /** * Show a Visual Studio Code Error Message Dialog with the specified message. * @param message The message to show. diff --git a/web/main.ts b/web/main.ts index 8a8b0a44..5c66a5d7 100644 --- a/web/main.ts +++ b/web/main.ts @@ -3513,14 +3513,17 @@ window.addEventListener('load', () => { gitGraph.loadRepos(msg.repos, msg.lastActiveRepo, msg.loadViewTo); break; case 'scrollToCommit': - gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); // if graph exist - gitGraph.scrollToCommitArgs = { // if graph is creating - hash: msg.hash, - alwaysCenterCommit: msg.alwaysCenterCommit, - flash: msg.flash, - openDetails: msg.openDetails, - persistently: msg.persistently - }; + if (VSCODE_API.getState()?.currentRepoLoading) { // if graph is creating + gitGraph.scrollToCommitArgs = { + hash: msg.hash, + alwaysCenterCommit: msg.alwaysCenterCommit, + flash: msg.flash, + openDetails: msg.openDetails, + persistently: msg.persistently + }; + } else { // if graph exist + gitGraph.scrollToCommit(msg.hash, msg.alwaysCenterCommit, msg.flash, msg.openDetails, msg.persistently); + } break; case 'merge': refreshOrDisplayError(msg.error, 'Unable to Merge ' + msg.actionOn); From 041e4a628925591820063ee4c9d1bd78b7b980df Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Thu, 24 Apr 2025 23:13:09 +0200 Subject: [PATCH 8/9] feat: add Go To Commit in editor context menu (cherry picked from commit 001dc34eee20ad0ccf7589fce12bf1b9a949827b) --- package.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f6bae69..18f77877 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ { "category": "Git Graph", "command": "git-graph.goToCommit", - "title": "Go To Commit", + "title": "Go to Commit", "icon": "$(git-commit)", "enablement": "isInDiffEditor || resourceScheme == scm-history-item" }, @@ -1557,6 +1557,13 @@ "command": "git-graph.view", "group": "inline" } + ], + "editor/context": [ + { + "command": "git-graph.goToCommit", + "group": "navigation@1", + "when": "isInDiffEditor || resourceScheme == scm-history-item" + } ] } }, From 38ee9bd5d7c6c4f549340a6e9e4d0f9eff294bbf Mon Sep 17 00:00:00 2001 From: Danylo Matsahor Date: Thu, 24 Apr 2025 23:13:42 +0200 Subject: [PATCH 9/9] fix: avoid endless search parent commit (cherry picked from commit f708fffe75b0907b717fc5469dc85a1bb0828975) --- src/commands.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands.ts b/src/commands.ts index 96c59a56..e4af4031 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -383,6 +383,10 @@ export class CommandManager extends Disposable { showWarningMessage('Warning: no matching Git repository found for this file.'); } + if (commitHash.endsWith('^')) { + commitHash = commitHash.slice(0, -1); // it is difficult to find parents, especially when there may be more than one + } + if (GitGraphView.currentPanel) { // graph exist GitGraphView.currentPanel.isPanelVisible = true; await this.view(repository);