diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e3465..389f1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Added + +- Adds pinning of comparisons in the _Compare_ view — pinned comparisons will persist across reloads + ## [9.3.0] - 2019-01-02 ### Added diff --git a/package.json b/package.json index c5cf02b..4f705b4 100644 --- a/package.json +++ b/package.json @@ -2703,6 +2703,24 @@ } }, { + "command": "gitlens.views.compare.pinComparision", + "title": "Pin Comparision", + "category": "GitLens", + "icon": { + "dark": "images/dark/icon-pin.svg", + "light": "images/light/icon-pin.svg" + } + }, + { + "command": "gitlens.views.compare.unpinComparision", + "title": "Unpin Comparision", + "category": "GitLens", + "icon": { + "dark": "images/dark/icon-pinned.svg", + "light": "images/light/icon-pinned.svg" + } + }, + { "command": "gitlens.views.compare.swapComparision", "title": "Swap Comparision", "category": "GitLens", @@ -2788,7 +2806,11 @@ { "command": "gitlens.views.refreshNode", "title": "Refresh", - "category": "GitLens" + "category": "GitLens", + "icon": { + "dark": "images/dark/icon-refresh.svg", + "light": "images/light/icon-refresh.svg" + } } ], "menus": { @@ -3346,6 +3368,14 @@ "when": "false" }, { + "command": "gitlens.views.compare.pinComparision", + "when": "false" + }, + { + "command": "gitlens.views.compare.unpinComparision", + "when": "false" + }, + { "command": "gitlens.views.compare.swapComparision", "when": "false" }, @@ -4364,27 +4394,52 @@ }, { "command": "gitlens.views.dismissNode", - "when": "viewItem =~ /gitlens:(compare:picker:ref|compare|search)\\b(?!:(commits|files))/", - "group": "inline@2" + "when": "viewItem =~ /gitlens:(compare:picker:ref|compare:results\\b(?!.*?\\+pinned\\b.*?)|search)\\b(?!:(commits|files))/", + "group": "inline@99" }, { "command": "gitlens.views.dismissNode", - "when": "viewItem =~ /gitlens:(compare:picker:ref|compare|search)\\b(?!:(commits|files))/", + "when": "viewItem =~ /gitlens:(compare:picker:ref|compare:results\\b(?!.*?\\+pinned\\b.*?)|search)\\b(?!:(commits|files))/", "group": "1_gitlens@1" }, { "command": "gitlens.views.compare.swapComparision", - "when": "viewItem == gitlens:compare:results", + "when": "viewItem =~ /gitlens:compare:results\\b/", "group": "inline@1" }, { + "command": "gitlens.views.compare.pinComparision", + "when": "viewItem =~ /gitlens:compare:results\\b(?!.*?\\+pinned\\b.*?)/", + "group": "inline@2" + }, + { + "command": "gitlens.views.compare.unpinComparision", + "when": "viewItem =~ /gitlens:compare:results\\b.*?\\+pinned\\b.*?/", + "group": "inline@2" + }, + { + "command": "gitlens.views.refreshNode", + "when": "viewItem =~ /gitlens:compare:results\\b/", + "group": "inline@3" + }, + { "command": "gitlens.views.compare.swapComparision", - "when": "viewItem == gitlens:compare:results", + "when": "viewItem =~ /gitlens:compare:results\\b/", "group": "2_gitlens@1" }, { + "command": "gitlens.views.compare.pinComparision", + "when": "viewItem =~ /gitlens:compare:results\\b(?!.*?\\+pinned\\b.*?)/", + "group": "3_gitlens@1" + }, + { + "command": "gitlens.views.compare.unpinComparision", + "when": "viewItem =~ /gitlens:compare:results\\b.*?\\+pinned\\b.*?/", + "group": "3_gitlens@1" + }, + { "command": "gitlens.views.openDirectoryDiff", - "when": "viewItem == gitlens:compare:results", + "when": "viewItem =~ /gitlens:compare:results\\b/", "group": "7_gitlens@1" }, { diff --git a/src/constants.ts b/src/constants.ts index 6dd4f05..f0ef068 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -128,6 +128,21 @@ export const ImageMimetypes: { [key: string]: string } = { '.bmp': 'image/bmp' }; +export interface NamedRef { + label?: string; + ref: string; +} + +export interface PinnedComparison { + path: string; + ref1: NamedRef; + ref2: NamedRef; +} + +export interface PinnedComparisons { + [id: string]: PinnedComparison; +} + export interface StarredBranches { [id: string]: boolean; } @@ -138,6 +153,7 @@ export interface StarredRepositories { export enum WorkspaceState { DefaultRemote = 'gitlens:remote:default', + PinnedComparisons = 'gitlens:pinned:comparisons', StarredBranches = 'gitlens:starred:branches', StarredRepositories = 'gitlens:starred:repositories', ViewsCompareKeepResults = 'gitlens:views:compare:keepResults', diff --git a/src/views/compareView.ts b/src/views/compareView.ts index 3dedfe6..ad363d1 100644 --- a/src/views/compareView.ts +++ b/src/views/compareView.ts @@ -1,9 +1,16 @@ 'use strict'; import { commands, ConfigurationChangeEvent } from 'vscode'; import { CompareViewConfig, configuration, ViewFilesLayout, ViewsConfig } from '../configuration'; -import { CommandContext, setCommandContext, WorkspaceState } from '../constants'; +import { + CommandContext, + NamedRef, + PinnedComparison, + PinnedComparisons, + setCommandContext, + WorkspaceState +} from '../constants'; import { Container } from '../container'; -import { CompareNode, CompareResultsNode, NamedRef, ViewNode } from './nodes'; +import { CompareNode, CompareResultsNode, nodeSupportsConditionalDismissal, ViewNode } from './nodes'; import { ViewBase } from './viewBase'; export class CompareView extends ViewBase { @@ -46,6 +53,9 @@ export class CompareView extends ViewBase { () => this.setKeepResults(false), this ); + + commands.registerCommand(this.getQualifiedCommand('pinComparision'), this.pinComparision, this); + commands.registerCommand(this.getQualifiedCommand('unpinComparision'), this.unpinComparision, this); commands.registerCommand(this.getQualifiedCommand('swapComparision'), this.swapComparision, this); commands.registerCommand(this.getQualifiedCommand('selectForCompare'), this.selectForCompare, this); @@ -86,6 +96,7 @@ export class CompareView extends ViewBase { dismissNode(node: ViewNode) { if (this._root === undefined) return; + if (nodeSupportsConditionalDismissal(node) && node.canDismiss() === false) return; this._root.dismiss(node); } @@ -111,6 +122,34 @@ export class CompareView extends ViewBase { void root.selectForCompare(repoPath, ref); } + getPinnedComparisons() { + const pinned = Container.context.workspaceState.get(WorkspaceState.PinnedComparisons); + if (pinned == null) return []; + + return Object.values(pinned).map(p => new CompareResultsNode(this, p.path, p.ref1, p.ref2, true)); + } + + async updatePinnedComparison(id: string, pin?: PinnedComparison) { + let pinned = Container.context.workspaceState.get(WorkspaceState.PinnedComparisons); + if (pinned == null) { + pinned = Object.create(null); + } + + if (pin !== undefined) { + pinned![id] = { + path: pin.path, + ref1: pin.ref1, + ref2: pin.ref2 + }; + } + else { + const { [id]: _, ...rest } = pinned!; + pinned = rest; + } + + await Container.context.workspaceState.update(WorkspaceState.PinnedComparisons, pinned); + } + private async addResults(results: ViewNode) { if (!this.visible) { void (await this.show()); @@ -131,9 +170,21 @@ export class CompareView extends ViewBase { setCommandContext(CommandContext.ViewsCompareKeepResults, enabled); } + private async pinComparision(node: ViewNode) { + if (!(node instanceof CompareResultsNode)) return; + + return node.pin(); + } + private swapComparision(node: ViewNode) { if (!(node instanceof CompareResultsNode)) return; - node.swap(); + return node.swap(); + } + + private async unpinComparision(node: ViewNode) { + if (!(node instanceof CompareResultsNode)) return; + + return node.unpin(); } } diff --git a/src/views/nodes/compareNode.ts b/src/views/nodes/compareNode.ts index 1b9edd9..0be16d5 100644 --- a/src/views/nodes/compareNode.ts +++ b/src/views/nodes/compareNode.ts @@ -1,14 +1,14 @@ 'use strict'; import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import { getRepoPathOrPrompt } from '../../commands'; -import { CommandContext, GlyphChars, setCommandContext } from '../../constants'; +import { CommandContext, GlyphChars, NamedRef, setCommandContext } from '../../constants'; import { GitService } from '../../git/gitService'; import { BranchesAndTagsQuickPick, CommandQuickPickItem } from '../../quickpicks'; import { debug, Functions, gate, log } from '../../system'; import { CompareView } from '../compareView'; import { MessageNode } from './common'; import { ComparePickerNode } from './comparePickerNode'; -import { NamedRef, ResourceType, unknownGitUri, ViewNode } from './viewNode'; +import { ResourceType, unknownGitUri, ViewNode } from './viewNode'; interface RepoRef { label: string; @@ -34,17 +34,21 @@ export class CompareNode extends ViewNode { // Not really sure why I can't reuse this node -- but if I do the Tree errors out with an id already exists error this._comparePickerNode = new ComparePickerNode(this.view, this); this._children = [this._comparePickerNode]; + + const pinned = this.view.getPinnedComparisons(); + if (pinned.length !== 0) { + this._children.push(...pinned); + } } - else if ( - this._selectedRef !== undefined && - (this._comparePickerNode === undefined || !this._children.includes(this._comparePickerNode)) - ) { + else if (this._comparePickerNode === undefined || !this._children.includes(this._comparePickerNode)) { // Not really sure why I can't reuse this node -- but if I do the Tree errors out with an id already exists error this._comparePickerNode = new ComparePickerNode(this.view, this); this._children.splice(0, 0, this._comparePickerNode); - const node = this._comparePickerNode; - setImmediate(() => this.view.reveal(node, { focus: false, select: true })); + if (this._selectedRef !== undefined) { + const node = this._comparePickerNode; + setImmediate(() => this.view.reveal(node, { focus: false, select: true })); + } } return this._children; @@ -62,6 +66,12 @@ export class CompareNode extends ViewNode { if (this._children.length !== 0 && replace) { this._children.length = 0; this._children.push(results); + + // Re-add the pinned comparisons + const pinned = this.view.getPinnedComparisons(); + if (pinned.length !== 0) { + this._children.push(...pinned); + } } else { if (this._comparePickerNode !== undefined) { diff --git a/src/views/nodes/compareResultsNode.ts b/src/views/nodes/compareResultsNode.ts index 9d060d1..cfe5a5e 100644 --- a/src/views/nodes/compareResultsNode.ts +++ b/src/views/nodes/compareResultsNode.ts @@ -1,32 +1,39 @@ 'use strict'; import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { NamedRef, PinnedComparisons, WorkspaceState } from '../../constants'; import { Container } from '../../container'; import { GitService, GitUri } from '../../git/gitService'; -import { Strings } from '../../system'; -import { ViewWithFiles } from '../viewBase'; +import { log, Strings } from '../../system'; +import { CompareView } from '../compareView'; import { CommitsQueryResults, ResultsCommitsNode } from './resultsCommitsNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import { NamedRef, ResourceType, ViewNode } from './viewNode'; +import { ResourceType, ViewNode } from './viewNode'; -export class CompareResultsNode extends ViewNode { +export class CompareResultsNode extends ViewNode { constructor( - view: ViewWithFiles, + view: CompareView, public readonly repoPath: string, - ref1: NamedRef, - ref2: NamedRef + private _ref1: NamedRef, + private _ref2: NamedRef, + private _pinned: boolean = false ) { super(GitUri.fromRepoPath(repoPath), view); + } + + get label() { + return `Comparing ${this._ref1.label || + GitService.shortenSha(this._ref1.ref, { working: 'Working Tree' })} to ${this._ref2.label || + GitService.shortenSha(this._ref2.ref, { working: 'Working Tree' })}`; + } - this._ref1 = ref1; - this._ref2 = ref2; + get pinned(): boolean { + return this._pinned; } - private _ref1: NamedRef; get ref1(): NamedRef { return this._ref1; } - private _ref2: NamedRef; get ref2(): NamedRef { return this._ref2; } @@ -45,23 +52,69 @@ export class CompareResultsNode extends ViewNode { description = (repo && repo.formattedName) || this.uri.repoPath; } - const item = new TreeItem( - `Comparing ${this._ref1.label || - GitService.shortenSha(this._ref1.ref, { working: 'Working Tree' })} to ${this._ref2.label || - GitService.shortenSha(this._ref2.ref, { working: 'Working Tree' })}`, - TreeItemCollapsibleState.Collapsed - ); + const item = new TreeItem(this.label, TreeItemCollapsibleState.Collapsed); item.contextValue = ResourceType.CompareResults; + if (this._pinned) { + item.contextValue += '+pinned'; + } item.description = description; + if (this._pinned) { + item.iconPath = { + dark: Container.context.asAbsolutePath(`images/dark/icon-pinned.svg`), + light: Container.context.asAbsolutePath(`images/light/icon-pinned.svg`) + }; + } return item; } - swap() { + canDismiss(): boolean { + return !this._pinned; + } + + @log() + async pin() { + if (this._pinned) return; + + await this.view.updatePinnedComparison(this.getPinnableId(), { + path: this.repoPath, + ref1: this.ref1, + ref2: this.ref2 + }); + + this._pinned = true; + void this.triggerChange(); + } + + @log() + async unpin() { + if (!this._pinned) return; + + await this.view.updatePinnedComparison(this.getPinnableId()); + + this._pinned = false; + void this.triggerChange(); + } + + @log() + async swap() { + // Save the current id so we can update it later + const currentId = this.getPinnableId(); + const ref1 = this._ref1; this._ref1 = this._ref2; this._ref2 = ref1; + // If we were pinned, remove the existing pin and save a new one + if (this._pinned) { + await this.view.updatePinnedComparison(currentId); + await this.view.updatePinnedComparison(this.getPinnableId(), { + path: this.repoPath, + ref1: this.ref1, + ref2: this.ref2 + }); + } + this.view.triggerNodeChange(this); } @@ -81,4 +134,8 @@ export class CompareResultsNode extends ViewNode { log: log }; } + + private getPinnableId() { + return Strings.sha1(`${this.repoPath}|${this.ref1.ref}|${this.ref2.ref}`); + } } diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index cd8b21a..c8b1102 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -45,11 +45,6 @@ export enum ResourceType { Tags = 'gitlens:tags' } -export interface NamedRef { - label?: string; - ref: string; -} - export const unknownGitUri = new GitUri(); export interface ViewNode { @@ -226,6 +221,10 @@ export abstract class SubscribeableViewNode extends V } } -export function canDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { +export function nodeSupportsConditionalDismissal(node: ViewNode): node is ViewNode & { canDismiss(): boolean } { + return typeof (node as any).canDismiss === 'function'; +} + +export function viewSupportsNodeDismissal(view: View): view is View & { dismissNode(node: ViewNode): void } { return typeof (view as any).dismissNode === 'function'; } diff --git a/src/views/searchView.ts b/src/views/searchView.ts index 913ea3c..47d6407 100644 --- a/src/views/searchView.ts +++ b/src/views/searchView.ts @@ -5,7 +5,7 @@ import { CommandContext, setCommandContext, WorkspaceState } from '../constants' import { Container } from '../container'; import { GitLog, GitRepoSearchBy } from '../git/gitService'; import { Functions, Strings } from '../system'; -import { SearchNode, SearchResultsCommitsNode, ViewNode } from './nodes'; +import { nodeSupportsConditionalDismissal, SearchNode, SearchResultsCommitsNode, ViewNode } from './nodes'; import { ViewBase } from './viewBase'; interface SearchQueryResult { @@ -89,6 +89,7 @@ export class SearchView extends ViewBase { dismissNode(node: ViewNode) { if (this._root === undefined) return; + if (nodeSupportsConditionalDismissal(node) && node.canDismiss() === false) return; this._root.dismiss(node); } diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 8db2988..0da6130 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -20,7 +20,6 @@ import { Arrays } from '../system'; import { BranchNode, BranchTrackingStatusNode, - canDismissNode, CommitFileNode, CommitNode, FolderNode, @@ -32,7 +31,8 @@ import { StatusFileNode, TagNode, ViewNode, - ViewRefNode + ViewRefNode, + viewSupportsNodeDismissal } from './nodes'; export interface RefreshNodeCommandArgs { @@ -64,7 +64,7 @@ export class ViewCommands implements Disposable { ); commands.registerCommand( 'gitlens.views.dismissNode', - (node: ViewNode) => canDismissNode(node.view) && node.view.dismissNode(node), + (node: ViewNode) => viewSupportsNodeDismissal(node.view) && node.view.dismissNode(node), this );