diff --git a/package.json b/package.json index b24ee75..4603016 100644 --- a/package.json +++ b/package.json @@ -459,6 +459,11 @@ "category": "GitLens" }, { + "command": "gitlens.showQuickStashList", + "title": "Show Stashed Changes", + "category": "GitLens" + }, + { "command": "gitlens.copyShaToClipboard", "title": "Copy Commit Sha to Clipboard", "category": "GitLens" @@ -487,6 +492,11 @@ "command": "gitlens.openFileInRemote", "title": "Open File in Remote", "category": "GitLens" + }, + { + "command": "gitlens.stashApply", + "title": "Apply Stashed Changes", + "category": "GitLens" } ], "menus": { @@ -568,6 +578,10 @@ "when": "gitlens:enabled" }, { + "command": "gitlens.showQuickStashList", + "when": "gitlens:enabled" + }, + { "command": "gitlens.copyShaToClipboard", "when": "editorTextFocus && gitlens:enabled && gitlens:isBlameable" }, @@ -590,6 +604,10 @@ { "command": "gitlens.openFileInRemote", "when": "editorTextFocus && gitlens:enabled && gitlens:hasRemotes" + }, + { + "command": "gitlens.stashApply", + "when": "gitlens:enabled && config.gitlens.insiders" } ], "explorer/context": [ diff --git a/src/commands.ts b/src/commands.ts index ed6b026..2300cd8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -27,5 +27,8 @@ export * from './commands/showQuickFileHistory'; export * from './commands/showQuickBranchHistory'; export * from './commands/showQuickCurrentBranchHistory'; export * from './commands/showQuickRepoStatus'; +export * from './commands/showQuickStashList'; +export * from './commands/stashApply'; +export * from './commands/stashDelete'; export * from './commands/toggleBlame'; export * from './commands/toggleCodeLens'; \ No newline at end of file diff --git a/src/commands/common.ts b/src/commands/common.ts index eb46d81..2fd6469 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -10,7 +10,8 @@ export type Commands = 'gitlens.closeUnchangedFiles' | 'gitlens.copyMessageToCli 'gitlens.showLastQuickPick' | 'gitlens.showQuickBranchHistory' | 'gitlens.showQuickCommitDetails' | 'gitlens.showQuickCommitFileDetails' | 'gitlens.showQuickFileHistory' | 'gitlens.showQuickRepoHistory' | - 'gitlens.showQuickRepoStatus' | + 'gitlens.showQuickRepoStatus' | 'gitlens.showQuickStashList' | + 'gitlens.stashApply' | 'gitlens.stashDelete' | 'gitlens.stashSave' | 'gitlens.toggleBlame' | 'gitlens.toggleCodeLens'; export const Commands = { CloseUnchangedFiles: 'gitlens.closeUnchangedFiles' as Commands, @@ -37,6 +38,9 @@ export const Commands = { ShowQuickBranchHistory: 'gitlens.showQuickBranchHistory' as Commands, ShowQuickCurrentBranchHistory: 'gitlens.showQuickRepoHistory' as Commands, ShowQuickRepoStatus: 'gitlens.showQuickRepoStatus' as Commands, + ShowQuickStashList: 'gitlens.showQuickStashList' as Commands, + StashApply: 'gitlens.stashApply' as Commands, + StashDelete: 'gitlens.stashDelete' as Commands, ToggleBlame: 'gitlens.toggleBlame' as Commands, ToggleCodeLens: 'gitlens.toggleCodeLens' as Commands }; diff --git a/src/commands/showQuickCommitDetails.ts b/src/commands/showQuickCommitDetails.ts index c41c395..9697d3d 100644 --- a/src/commands/showQuickCommitDetails.ts +++ b/src/commands/showQuickCommitDetails.ts @@ -46,7 +46,7 @@ export class ShowQuickCommitDetailsCommand extends ActiveEditorCachedCommand { } try { - if (!commit || !(commit instanceof GitLogCommit) || commit.type !== 'repo') { + if (!commit || (commit.type !== 'repo' && commit.type !== 'stash')) { if (repoLog) { commit = repoLog.commits.get(sha); // If we can't find the commit, kill the repoLog @@ -88,7 +88,7 @@ export class ShowQuickCommitDetailsCommand extends ActiveEditorCachedCommand { return pick.execute(); } - return commands.executeCommand(Commands.ShowQuickCommitFileDetails, pick.gitUri, pick.sha, undefined, currentCommand); + return commands.executeCommand(Commands.ShowQuickCommitFileDetails, pick.gitUri, pick.sha, commit, currentCommand); } catch (ex) { Logger.error(ex, 'ShowQuickCommitDetailsCommand'); diff --git a/src/commands/showQuickCommitFileDetails.ts b/src/commands/showQuickCommitFileDetails.ts index 8c12db2..931b049 100644 --- a/src/commands/showQuickCommitFileDetails.ts +++ b/src/commands/showQuickCommitFileDetails.ts @@ -44,7 +44,7 @@ export class ShowQuickCommitFileDetailsCommand extends ActiveEditorCachedCommand } try { - if (!commit || !(commit instanceof GitLogCommit) || commit.type !== 'file') { + if (!commit || (commit.type !== 'file' && commit.type !== 'stash')) { if (fileLog) { commit = fileLog.commits.get(sha); // If we can't find the commit, kill the fileLog diff --git a/src/commands/showQuickStashList.ts b/src/commands/showQuickStashList.ts new file mode 100644 index 0000000..e320768 --- /dev/null +++ b/src/commands/showQuickStashList.ts @@ -0,0 +1,42 @@ +'use strict'; +import { commands, TextEditor, Uri, window } from 'vscode'; +import { ActiveEditorCachedCommand, Commands } from './common'; +import { GitService, GitUri } from '../gitService'; +import { Logger } from '../logger'; +import { CommandQuickPickItem, StashListQuickPick } from '../quickPicks'; + +export class ShowQuickStashListCommand extends ActiveEditorCachedCommand { + + constructor(private git: GitService) { + super(Commands.ShowQuickStashList); + } + + async execute(editor: TextEditor, uri?: Uri, goBackCommand?: CommandQuickPickItem) { + if (!(uri instanceof Uri)) { + uri = editor && editor.document && editor.document.uri; + } + + try { + const repoPath = await this.git.getRepoPathFromUri(uri, this.git.repoPath); + if (!repoPath) return window.showWarningMessage(`Unable to show stash list`); + + const stash = await this.git.getStashList(repoPath); + const pick = await StashListQuickPick.show(stash, undefined, goBackCommand); + if (!pick) return undefined; + + if (pick instanceof CommandQuickPickItem) { + return pick.execute(); + } + + return commands.executeCommand(Commands.ShowQuickCommitDetails, new GitUri(pick.commit.uri, pick.commit), pick.commit.sha, pick.commit, + new CommandQuickPickItem({ + label: `go back \u21A9`, + description: `\u00a0 \u2014 \u00a0\u00a0 to the stash list` + }, Commands.ShowQuickStashList, [uri, goBackCommand])); + } + catch (ex) { + Logger.error(ex, 'ShowQuickStashListCommand'); + return window.showErrorMessage(`Unable to show stash list. See output channel for more details`); + } + } +} \ No newline at end of file diff --git a/src/commands/stashApply.ts b/src/commands/stashApply.ts new file mode 100644 index 0000000..a2ff957 --- /dev/null +++ b/src/commands/stashApply.ts @@ -0,0 +1,48 @@ +'use strict'; +import { MessageItem, window } from 'vscode'; +import { GitService, GitStashCommit } from '../gitService'; +import { Command, Commands } from './common'; +import { CommitQuickPickItem, StashListQuickPick } from '../quickPicks'; +import { Logger } from '../logger'; + +export class StashApplyCommand extends Command { + + constructor(private git: GitService) { + super(Commands.StashApply); + } + + async execute(stashItem: { stashName: string, message: string }, confirm: boolean = true, deleteAfter: boolean = false) { + if (!this.git.config.insiders) return undefined; + + if (!stashItem || !stashItem.stashName) { + const stash = await this.git.getStashList(this.git.repoPath); + if (!stash) return window.showInformationMessage(`There are no stashed changes`); + + const pick = await StashListQuickPick.show(stash, 'Apply stashed changes to your working tree\u2026'); + if (!pick || !(pick instanceof CommitQuickPickItem)) return undefined; + + stashItem = pick.commit as GitStashCommit; + } + + try { + if (confirm) { + const message = stashItem.message.length > 80 ? `${stashItem.message.substring(0, 80)}\u2026` : stashItem.message; + const result = await window.showWarningMessage(`Apply stashed changes '${message}' to your working tree?`, { title: 'Yes, delete after applying' } as MessageItem, { title: 'Yes' } as MessageItem, { title: 'No', isCloseAffordance: true } as MessageItem); + if (!result || result.title === 'No') return undefined; + + deleteAfter = result.title !== 'Yes'; + } + + return await this.git.stashApply(this.git.repoPath, stashItem.stashName, deleteAfter); + } + catch (ex) { + Logger.error(ex, 'StashApplyCommand'); + if (ex.message.includes('Your local changes to the following files would be overwritten by merge')) { + return window.showErrorMessage(`Unable to apply stash. Your working tree changes would be overwritten.`); + } + else { + return window.showErrorMessage(`Unable to apply stash. See output channel for more details`); + } + } + } +} \ No newline at end of file diff --git a/src/commands/stashDelete.ts b/src/commands/stashDelete.ts new file mode 100644 index 0000000..a6e9a5f --- /dev/null +++ b/src/commands/stashDelete.ts @@ -0,0 +1,31 @@ +'use strict'; +import { MessageItem, window } from 'vscode'; +import { GitService } from '../gitService'; +import { Command, Commands } from './common'; +import { Logger } from '../logger'; + +export class StashDeleteCommand extends Command { + + constructor(private git: GitService) { + super(Commands.StashDelete); + } + + async execute(stashItem: { stashName: string, message: string }, confirm: boolean = true) { + if (!this.git.config.insiders) return undefined; + if (!stashItem || !stashItem.stashName) return undefined; + + try { + if (confirm) { + const message = stashItem.message.length > 80 ? `${stashItem.message.substring(0, 80)}\u2026` : stashItem.message; + const result = await window.showWarningMessage(`Delete stashed changes '${message}'?`, { title: 'Yes' } as MessageItem, { title: 'No', isCloseAffordance: true } as MessageItem); + if (!result || result.title !== 'Yes') return undefined; + } + + return await this.git.stashDelete(this.git.repoPath, stashItem.stashName); + } + catch (ex) { + Logger.error(ex, 'StashDeleteCommand'); + return window.showErrorMessage(`Unable to delete stash. See output channel for more details`); + } + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 3477263..530354c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,9 @@ import { CopyMessageToClipboardCommand, CopyShaToClipboardCommand } from './comm import { DiffDirectoryCommand, DiffLineWithPreviousCommand, DiffLineWithWorkingCommand, DiffWithBranchCommand, DiffWithNextCommand, DiffWithPreviousCommand, DiffWithWorkingCommand} from './commands'; import { ShowBlameCommand, ToggleBlameCommand } from './commands'; import { ShowBlameHistoryCommand, ShowFileHistoryCommand } from './commands'; -import { ShowLastQuickPickCommand, ShowQuickBranchHistoryCommand, ShowQuickCurrentBranchHistoryCommand, ShowQuickCommitDetailsCommand, ShowQuickCommitFileDetailsCommand, ShowQuickFileHistoryCommand, ShowQuickRepoStatusCommand} from './commands'; +import { ShowLastQuickPickCommand, ShowQuickBranchHistoryCommand, ShowQuickCurrentBranchHistoryCommand, ShowQuickCommitDetailsCommand, ShowQuickCommitFileDetailsCommand, ShowQuickFileHistoryCommand } from './commands'; +import { ShowQuickRepoStatusCommand, ShowQuickStashListCommand } from './commands'; +import { StashApplyCommand, StashDeleteCommand } from './commands'; import { ToggleCodeLensCommand } from './commands'; import { Keyboard } from './commands'; import { IConfig } from './configuration'; @@ -115,6 +117,9 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(new ShowQuickCommitFileDetailsCommand(git)); context.subscriptions.push(new ShowQuickFileHistoryCommand(git)); context.subscriptions.push(new ShowQuickRepoStatusCommand(git)); + context.subscriptions.push(new ShowQuickStashListCommand(git)); + context.subscriptions.push(new StashApplyCommand(git)); + context.subscriptions.push(new StashDeleteCommand(git)); context.subscriptions.push(new ToggleCodeLensCommand(git)); Telemetry.trackEvent('initialized', Objects.flatten(config, 'config', true)); diff --git a/src/git/git.ts b/src/git/git.ts index bd92a23..4473060 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -9,6 +9,7 @@ import * as tmp from 'tmp'; export * from './models/models'; export * from './parsers/blameParser'; export * from './parsers/logParser'; +export * from './parsers/stashParser'; export * from './parsers/statusParser'; export * from './remotes/provider'; @@ -16,6 +17,7 @@ let git: IGit; // `--format=%H -%nauthor %an%nauthor-date %ai%ncommitter %cn%ncommitter-date %ci%nparents %P%nsummary %B%nfilename ?` const defaultLogParams = [`log`, `--name-status`, `--full-history`, `-M`, `--date=iso8601`, `--format=%H -%nauthor %an%nauthor-date %ai%nparents %P%nsummary %B%nfilename ?`]; +const defaultStashParams = [`stash`, `list`, `--name-status`, `--full-history`, `-M`, `--format=%H -%nauthor-date %ai%nreflog-selector %gd%nsummary %B%nfilename ?`]; async function gitCommand(cwd: string, ...args: any[]) { try { @@ -163,14 +165,6 @@ export class Git { return gitCommand(repoPath, ...params); } - static show(repoPath: string, fileName: string, branchOrSha: string) { - const [file, root] = Git.splitPath(fileName, repoPath); - branchOrSha = branchOrSha.replace('^', ''); - - if (Git.isUncommitted(branchOrSha)) return Promise.reject(new Error(`sha=${branchOrSha} is uncommitted`)); - return gitCommand(root, 'show', `${branchOrSha}:./${file}`); - } - static log(repoPath: string, sha?: string, maxCount?: number, reverse: boolean = false) { const params = [...defaultLogParams, `-m`]; if (maxCount && !reverse) { @@ -227,26 +221,44 @@ export class Git { } static remote(repoPath: string): Promise { - const params = ['remote', '-v']; - return gitCommand(repoPath, ...params); + return gitCommand(repoPath, 'remote', '-v'); } static remote_url(repoPath: string, remote: string): Promise { - const params = ['remote', 'get-url', remote]; - return gitCommand(repoPath, ...params); + return gitCommand(repoPath, 'remote', 'get-url', remote); + } + + static show(repoPath: string, fileName: string, branchOrSha: string) { + const [file, root] = Git.splitPath(fileName, repoPath); + branchOrSha = branchOrSha.replace('^', ''); + + if (Git.isUncommitted(branchOrSha)) return Promise.reject(new Error(`sha=${branchOrSha} is uncommitted`)); + return gitCommand(root, 'show', `${branchOrSha}:./${file}`); + } + + static stash_apply(repoPath: string, stashName: string, deleteAfter: boolean) { + if (!stashName) return undefined; + return gitCommand(repoPath, 'stash', deleteAfter ? 'pop' : 'apply', stashName); + } + + static stash_delete(repoPath: string, stashName: string) { + if (!stashName) return undefined; + return gitCommand(repoPath, 'stash', 'drop', stashName); + } + + static stash_list(repoPath: string) { + return gitCommand(repoPath, ...defaultStashParams); } static status(repoPath: string, porcelainVersion: number = 1): Promise { const porcelain = porcelainVersion >= 2 ? `--porcelain=v${porcelainVersion}` : '--porcelain'; - const params = ['status', porcelain, '--branch']; - return gitCommand(repoPath, ...params); + return gitCommand(repoPath, 'status', porcelain, '--branch'); } static status_file(repoPath: string, fileName: string, porcelainVersion: number = 1): Promise { const [file, root] = Git.splitPath(fileName, repoPath); const porcelain = porcelainVersion >= 2 ? `--porcelain=v${porcelainVersion}` : '--porcelain'; - const params = ['status', porcelain, file]; - return gitCommand(root, ...params); + return gitCommand(root, 'status', porcelain, file); } } \ No newline at end of file diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 7067e45..8cf9562 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -34,7 +34,7 @@ export interface IGitCommitLine { code?: string; } -export type GitCommitType = 'blame' | 'file' | 'repo'; +export type GitCommitType = 'blame' | 'file' | 'repo' | 'stash'; export class GitCommit implements IGitCommit { diff --git a/src/git/models/models.ts b/src/git/models/models.ts index 847d2c7..1cda1a7 100644 --- a/src/git/models/models.ts +++ b/src/git/models/models.ts @@ -5,4 +5,6 @@ export * from './commit'; export * from './log'; export * from './logCommit'; export * from './remote'; +export * from './stash'; +export * from './stashCommit'; export * from './status'; \ No newline at end of file diff --git a/src/git/models/stash.ts b/src/git/models/stash.ts new file mode 100644 index 0000000..297a498 --- /dev/null +++ b/src/git/models/stash.ts @@ -0,0 +1,7 @@ +'use strict'; +import { GitStashCommit } from './stashCommit'; + +export interface IGitStash { + repoPath: string; + commits: Map; +} \ No newline at end of file diff --git a/src/git/models/stashCommit.ts b/src/git/models/stashCommit.ts new file mode 100644 index 0000000..3c3582a --- /dev/null +++ b/src/git/models/stashCommit.ts @@ -0,0 +1,24 @@ +'use strict'; +import { IGitCommitLine } from './commit'; +import { GitLogCommit } from './logCommit'; +import { IGitStatusFile, GitStatusFileStatus } from './status'; + +export class GitStashCommit extends GitLogCommit { + + constructor( + public stashName: string, + repoPath: string, + sha: string, + fileName: string, + date: Date, + message: string, + status?: GitStatusFileStatus, + fileStatuses?: IGitStatusFile[], + lines?: IGitCommitLine[], + originalFileName?: string, + previousSha?: string, + previousFileName?: string + ) { + super('stash', repoPath, sha, fileName, undefined, date, message, status, fileStatuses, lines, originalFileName, previousSha, previousFileName); + } +} \ No newline at end of file diff --git a/src/git/parsers/stashParser.ts b/src/git/parsers/stashParser.ts new file mode 100644 index 0000000..e0ea0f0 --- /dev/null +++ b/src/git/parsers/stashParser.ts @@ -0,0 +1,147 @@ +'use strict'; +import { Git, GitStashCommit, GitStatusFileStatus, IGitStash, IGitStatusFile } from './../git'; +// import { Logger } from '../../logger'; +import * as moment from 'moment'; + +interface IStashEntry { + sha: string; + date?: string; + fileNames?: string; + fileStatuses?: IGitStatusFile[]; + summary?: string; + stashName?: string; +} + +export class GitStashParser { + + private static _parseEntries(data: string): IStashEntry[] { + if (!data) return undefined; + + const lines = data.split('\n'); + if (!lines.length) return undefined; + + const entries: IStashEntry[] = []; + + let entry: IStashEntry; + let position = -1; + while (++position < lines.length) { + let lineParts = lines[position].split(' '); + if (lineParts.length < 2) { + continue; + } + + if (!entry) { + if (!Git.shaRegex.test(lineParts[0])) continue; + + entry = { + sha: lineParts[0] + }; + + continue; + } + + switch (lineParts[0]) { + case 'author-date': + entry.date = `${lineParts[1]}T${lineParts[2]}${lineParts[3]}`; + break; + + case 'summary': + entry.summary = lineParts.slice(1).join(' ').trim(); + while (++position < lines.length) { + const next = lines[position]; + if (!next) break; + if (next === 'filename ?') { + position--; + break; + } + + entry.summary += `\n${lines[position]}`; + } + break; + + case 'reflog-selector': + entry.stashName = lineParts.slice(1).join(' ').trim(); + break; + + case 'filename': + const nextLine = lines[position + 1]; + // If the next line isn't blank, make sure it isn't starting a new commit + if (nextLine && Git.shaRegex.test(nextLine)) continue; + + position++; + + while (++position < lines.length) { + const line = lines[position]; + lineParts = line.split(' '); + + if (Git.shaRegex.test(lineParts[0])) { + position--; + break; + } + + if (entry.fileStatuses == null) { + entry.fileStatuses = []; + } + + const status = { + status: line[0] as GitStatusFileStatus, + fileName: line.substring(1), + originalFileName: undefined as string + } as IGitStatusFile; + this._parseFileName(status); + + entry.fileStatuses.push(status); + } + + if (entry.fileStatuses) { + entry.fileNames = entry.fileStatuses.filter(_ => !!_.fileName).map(_ => _.fileName).join(', '); + } + + entries.push(entry); + entry = undefined; + break; + + default: + break; + } + } + + return entries; + } + + static parse(data: string, repoPath: string): IGitStash { + const entries = this._parseEntries(data); + if (!entries) return undefined; + + const commits: Map = new Map(); + + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + + let commit = commits.get(entry.sha); + if (!commit) { + commit = new GitStashCommit(entry.stashName, repoPath, entry.sha, entry.fileNames, moment(entry.date).toDate(), entry.summary, undefined, entry.fileStatuses); + commits.set(entry.sha, commit); + } + } + + return { + repoPath: repoPath, + commits: commits + } as IGitStash; + } + + private static _parseFileName(entry: { fileName?: string, originalFileName?: string }) { + const index = entry.fileName.indexOf('\t') + 1; + if (index) { + const next = entry.fileName.indexOf('\t', index) + 1; + if (next) { + entry.originalFileName = entry.fileName.substring(index, next - 1); + entry.fileName = entry.fileName.substring(next); + } + else { + entry.fileName = entry.fileName.substring(index); + } + } + } +} \ No newline at end of file diff --git a/src/gitService.ts b/src/gitService.ts index 75447cf..a8ab8e7 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -4,7 +4,7 @@ import { Disposable, Event, EventEmitter, ExtensionContext, FileSystemWatcher, l import { CommandContext, setCommandContext } from './commands'; import { CodeLensVisibility, IConfig } from './configuration'; import { DocumentSchemes } from './constants'; -import { Git, GitBlameParser, GitBranch, GitCommit, GitLogCommit, GitLogParser, GitRemote, GitStatusFile, GitStatusParser, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog, IGitStatus } from './git/git'; +import { Git, GitBlameParser, GitBranch, GitCommit, GitLogCommit, GitLogParser, GitRemote, GitStashParser, GitStatusFile, GitStatusParser, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog, IGitStash, IGitStatus } from './git/git'; import { IGitUriData, GitUri } from './git/gitUri'; import { GitCodeLensProvider } from './gitCodeLensProvider'; import { Logger } from './logger'; @@ -667,6 +667,13 @@ export class GitService extends Disposable { return (await this.getRepoPathFromFile(gitUri.fsPath)) || fallbackRepoPath; } + async getStashList(repoPath: string): Promise { + Logger.log(`getStash('${repoPath}')`); + + const data = await Git.stash_list(repoPath); + return GitStashParser.parse(data, repoPath); + } + async getStatusForFile(repoPath: string, fileName: string): Promise { Logger.log(`getStatusForFile('${repoPath}', '${fileName}')`); @@ -738,6 +745,18 @@ export class GitService extends Disposable { return Git.difftool_dirDiff(repoPath, sha1, sha2); } + stashApply(repoPath: string, stashName: string, deleteAfter: boolean = false) { + Logger.log(`stashApply('${repoPath}', ${stashName}, ${deleteAfter})`); + + return Git.stash_apply(repoPath, stashName, deleteAfter); + } + + stashDelete(repoPath: string, stashName: string) { + Logger.log(`stashDelete('${repoPath}', ${stashName}})`); + + return Git.stash_delete(repoPath, stashName); + } + toggleCodeLens(editor: TextEditor) { if (this.config.codeLens.visibility !== CodeLensVisibility.OnDemand || (!this.config.codeLens.recentChange.enabled && !this.config.codeLens.authors.enabled)) return; diff --git a/src/quickPicks.ts b/src/quickPicks.ts index 99afc01..255356f 100644 --- a/src/quickPicks.ts +++ b/src/quickPicks.ts @@ -7,4 +7,5 @@ export * from './quickPicks/commitFileDetails'; export * from './quickPicks/branchHistory'; export * from './quickPicks/fileHistory'; export * from './quickPicks/remotes'; -export * from './quickPicks/repoStatus'; \ No newline at end of file +export * from './quickPicks/repoStatus'; +export * from './quickPicks/stashList'; \ No newline at end of file diff --git a/src/quickPicks/commitDetails.ts b/src/quickPicks/commitDetails.ts index 077aa9a..e8b3747 100644 --- a/src/quickPicks/commitDetails.ts +++ b/src/quickPicks/commitDetails.ts @@ -3,7 +3,7 @@ import { Arrays, Iterables } from '../system'; import { QuickPickItem, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard, KeyNoopCommand } from '../commands'; import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFileCommandQuickPickItem, OpenFilesCommandQuickPickItem } from './common'; -import { getGitStatusIcon, Git, GitCommit, GitLogCommit, GitService, GitStatusFileStatus, GitUri, IGitLog, IGitStatusFile } from '../gitService'; +import { getGitStatusIcon, Git, GitCommit, GitLogCommit, GitService, GitStashCommit, GitStatusFileStatus, GitUri, IGitLog, IGitStatusFile } from '../gitService'; import { OpenRemotesCommandQuickPickItem } from './remotes'; import * as moment from 'moment'; import * as path from 'path'; @@ -72,27 +72,44 @@ export class CommitDetailsQuickPick { static async show(git: GitService, commit: GitLogCommit, uri: Uri, goBackCommand?: CommandQuickPickItem, currentCommand?: CommandQuickPickItem, repoLog?: IGitLog): Promise { const items: (CommitWithFileStatusQuickPickItem | CommandQuickPickItem)[] = commit.fileStatuses.map(fs => new CommitWithFileStatusQuickPickItem(commit, fs)); + const stash = commit.type === 'stash'; + const type = stash ? 'Stash' : 'Commit'; + let index = 0; + if (stash && git.config.insiders) { + items.splice(index++, 0, new CommandQuickPickItem({ + label: `$(repo-forked) Apply Stash`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` + }, Commands.StashApply, [commit as GitStashCommit, true, false])); + + items.splice(index++, 0, new CommandQuickPickItem({ + label: `$(x) Delete Stash`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` + }, Commands.StashDelete, [commit as GitStashCommit, true])); + } + items.splice(index++, 0, new CommandQuickPickItem({ - label: `$(clippy) Copy Commit Sha to Clipboard`, + label: `$(clippy) Copy ${type} Sha to Clipboard`, description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.shortSha}` }, Commands.CopyShaToClipboard, [uri, commit.sha])); items.splice(index++, 0, new CommandQuickPickItem({ - label: `$(clippy) Copy Commit Message to Clipboard`, + label: `$(clippy) Copy ${type} Message to Clipboard`, description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` }, Commands.CopyMessageToClipboard, [uri, commit.sha, commit.message])); - const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); - if (remotes.length) { - items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, 'commit', commit.sha, currentCommand)); - } + if (!stash) { + const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); + if (remotes.length) { + items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, 'commit', commit.sha, currentCommand)); + } - items.splice(index++, 0, new CommandQuickPickItem({ - label: `$(git-compare) Directory Compare with Previous Commit`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.previousShortSha || `${commit.shortSha}^`} \u00a0 $(git-compare) \u00a0 $(git-commit) ${commit.shortSha}` - }, Commands.DiffDirectory, [commit.uri, commit.previousSha || `${commit.sha}^`, commit.sha])); + items.splice(index++, 0, new CommandQuickPickItem({ + label: `$(git-compare) Directory Compare with Previous Commit`, + description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.previousShortSha || `${commit.shortSha}^`} \u00a0 $(git-compare) \u00a0 $(git-commit) ${commit.shortSha}` + }, Commands.DiffDirectory, [commit.uri, commit.previousSha || `${commit.sha}^`, commit.sha])); + } items.splice(index++, 0, new CommandQuickPickItem({ label: `$(git-compare) Directory Compare with Working Tree`, @@ -117,50 +134,52 @@ export class CommitDetailsQuickPick { let previousCommand: CommandQuickPickItem | (() => Promise); let nextCommand: CommandQuickPickItem | (() => Promise); - // If we have the full history, we are good - if (repoLog && !repoLog.truncated && !repoLog.sha) { - previousCommand = commit.previousSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [commit.previousUri, commit.previousSha, undefined, goBackCommand, repoLog]); - nextCommand = commit.nextSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [commit.nextUri, commit.nextSha, undefined, goBackCommand, repoLog]); - } - else { - previousCommand = async () => { - let log = repoLog; - let c = log && log.commits.get(commit.sha); - - // If we can't find the commit or the previous commit isn't available (since it isn't trustworthy) - if (!c || !c.previousSha) { - log = await git.getLogForRepo(commit.repoPath, commit.sha, git.config.advanced.maxQuickHistory); - c = log && log.commits.get(commit.sha); - - if (c) { - // Copy over next info, since it is trustworthy at this point - c.nextSha = commit.nextSha; + if (!stash) { + // If we have the full history, we are good + if (repoLog && !repoLog.truncated && !repoLog.sha) { + previousCommand = commit.previousSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [commit.previousUri, commit.previousSha, undefined, goBackCommand, repoLog]); + nextCommand = commit.nextSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [commit.nextUri, commit.nextSha, undefined, goBackCommand, repoLog]); + } + else { + previousCommand = async () => { + let log = repoLog; + let c = log && log.commits.get(commit.sha); + + // If we can't find the commit or the previous commit isn't available (since it isn't trustworthy) + if (!c || !c.previousSha) { + log = await git.getLogForRepo(commit.repoPath, commit.sha, git.config.advanced.maxQuickHistory); + c = log && log.commits.get(commit.sha); + + if (c) { + // Copy over next info, since it is trustworthy at this point + c.nextSha = commit.nextSha; + } } - } - if (!c || !c.previousSha) return KeyNoopCommand; - return new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [c.previousUri, c.previousSha, undefined, goBackCommand, log]); - }; - - nextCommand = async () => { - let log = repoLog; - let c = log && log.commits.get(commit.sha); - - // If we can't find the commit or the next commit isn't available (since it isn't trustworthy) - if (!c || !c.nextSha) { - log = undefined; - c = undefined; - - // Try to find the next commit - const nextLog = await git.getLogForRepo(commit.repoPath, commit.sha, 1, true); - const next = nextLog && Iterables.first(nextLog.commits.values()); - if (next && next.sha !== commit.sha) { - c = commit; - c.nextSha = next.sha; + if (!c || !c.previousSha) return KeyNoopCommand; + return new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [c.previousUri, c.previousSha, undefined, goBackCommand, log]); + }; + + nextCommand = async () => { + let log = repoLog; + let c = log && log.commits.get(commit.sha); + + // If we can't find the commit or the next commit isn't available (since it isn't trustworthy) + if (!c || !c.nextSha) { + log = undefined; + c = undefined; + + // Try to find the next commit + const nextLog = await git.getLogForRepo(commit.repoPath, commit.sha, 1, true); + const next = nextLog && Iterables.first(nextLog.commits.values()); + if (next && next.sha !== commit.sha) { + c = commit; + c.nextSha = next.sha; + } } - } - if (!c || !c.nextSha) return KeyNoopCommand; - return new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [c.nextUri, c.nextSha, undefined, goBackCommand, log]); - }; + if (!c || !c.nextSha) return KeyNoopCommand; + return new KeyCommandQuickPickItem(Commands.ShowQuickCommitDetails, [c.nextUri, c.nextSha, undefined, goBackCommand, log]); + }; + } } const scope = await Keyboard.instance.beginScope({ @@ -172,7 +191,7 @@ export class CommitDetailsQuickPick { const pick = await window.showQuickPick(items, { matchOnDescription: true, matchOnDetail: true, - placeHolder: `${commit.shortSha} \u00a0\u2022\u00a0 ${commit.author}, ${moment(commit.date).fromNow()} \u00a0\u2022\u00a0 ${commit.message}`, + placeHolder: `${commit.shortSha} \u00a0\u2022\u00a0 ${commit.author ? `${commit.author}, ` : ''}${moment(commit.date).fromNow()} \u00a0\u2022\u00a0 ${commit.message}`, ignoreFocusOut: getQuickPickIgnoreFocusOut(), onDidSelectItem: (item: QuickPickItem) => { scope.setKeyCommand('right', item); diff --git a/src/quickPicks/commitFileDetails.ts b/src/quickPicks/commitFileDetails.ts index 6dde680..e9bfd82 100644 --- a/src/quickPicks/commitFileDetails.ts +++ b/src/quickPicks/commitFileDetails.ts @@ -35,6 +35,8 @@ export class CommitFileDetailsQuickPick { static async show(git: GitService, commit: GitLogCommit, uri: Uri, goBackCommand?: CommandQuickPickItem, currentCommand?: CommandQuickPickItem, fileLog?: IGitLog): Promise { const items: CommandQuickPickItem[] = []; + const stash = commit.type === 'stash'; + const workingName = (commit.workingFileName && path.basename(commit.workingFileName)) || path.basename(commit.fileName); const isUncommitted = commit.isUncommitted; @@ -44,16 +46,18 @@ export class CommitFileDetailsQuickPick { if (!commit) return undefined; } - items.push(new CommandQuickPickItem({ - label: `$(git-commit) Show Commit Details`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.shortSha}` - }, Commands.ShowQuickCommitDetails, [new GitUri(commit.uri, commit), commit.sha, commit, currentCommand])); - - if (commit.previousSha) { + if (!stash) { items.push(new CommandQuickPickItem({ - label: `$(git-compare) Compare with Previous Commit`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.previousShortSha} \u00a0 $(git-compare) \u00a0 $(git-commit) ${commit.shortSha}` - }, Commands.DiffWithPrevious, [commit.uri, commit])); + label: `$(git-commit) Show Commit Details`, + description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.shortSha}` + }, Commands.ShowQuickCommitDetails, [new GitUri(commit.uri, commit), commit.sha, commit, currentCommand])); + + if (commit.previousSha) { + items.push(new CommandQuickPickItem({ + label: `$(git-compare) Compare with Previous Commit`, + description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.previousShortSha} \u00a0 $(git-compare) \u00a0 $(git-commit) ${commit.shortSha}` + }, Commands.DiffWithPrevious, [commit.uri, commit])); + } } if (commit.workingFileName) { @@ -63,15 +67,17 @@ export class CommitFileDetailsQuickPick { }, Commands.DiffWithWorking, [Uri.file(path.resolve(commit.repoPath, commit.workingFileName)), commit])); } - items.push(new CommandQuickPickItem({ - label: `$(clippy) Copy Commit Sha to Clipboard`, - description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.shortSha}` - }, Commands.CopyShaToClipboard, [uri, commit.sha])); + if (!stash) { + items.push(new CommandQuickPickItem({ + label: `$(clippy) Copy Commit Sha to Clipboard`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.shortSha}` + }, Commands.CopyShaToClipboard, [uri, commit.sha])); - items.push(new CommandQuickPickItem({ - label: `$(clippy) Copy Commit Message to Clipboard`, - description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` - }, Commands.CopyMessageToClipboard, [uri, commit.sha, commit.message])); + items.push(new CommandQuickPickItem({ + label: `$(clippy) Copy Commit Message to Clipboard`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` + }, Commands.CopyMessageToClipboard, [uri, commit.sha, commit.message])); + } items.push(new OpenCommitFileCommandQuickPickItem(commit)); if (commit.workingFileName) { @@ -80,7 +86,9 @@ export class CommitFileDetailsQuickPick { const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); if (remotes.length) { - items.push(new OpenRemotesCommandQuickPickItem(remotes, 'file', commit.fileName, undefined, commit.sha, currentCommand)); + if (!stash) { + items.push(new OpenRemotesCommandQuickPickItem(remotes, 'file', commit.fileName, undefined, commit.sha, currentCommand)); + } if (commit.workingFileName) { const branch = await git.getBranch(commit.repoPath || git.repoPath); items.push(new OpenRemotesCommandQuickPickItem(remotes, 'working-file', commit.workingFileName, branch.name, undefined, currentCommand)); @@ -105,55 +113,57 @@ export class CommitFileDetailsQuickPick { let previousCommand: CommandQuickPickItem | (() => Promise); let nextCommand: CommandQuickPickItem | (() => Promise); - // If we have the full history, we are good - if (fileLog && !fileLog.truncated && !fileLog.sha) { - previousCommand = commit.previousSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [commit.previousUri, commit.previousSha, undefined, goBackCommand, fileLog]); - nextCommand = commit.nextSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [commit.nextUri, commit.nextSha, undefined, goBackCommand, fileLog]); - } - else { - previousCommand = async () => { - let log = fileLog; - let c = log && log.commits.get(commit.sha); - - // If we can't find the commit or the previous commit isn't available (since it isn't trustworthy) - if (!c || !c.previousSha) { - log = await git.getLogForFile(commit.repoPath, uri.fsPath, commit.sha, git.config.advanced.maxQuickHistory); - c = log && log.commits.get(commit.sha); - // Since we exclude merge commits in file log, just grab the first returned commit - if (!c && commit.isMerge) { - c = Iterables.first(log.commits.values()); - } - - if (c) { - // Copy over next info, since it is trustworthy at this point - c.nextSha = commit.nextSha; - c.nextFileName = commit.nextFileName; + if (!stash) { + // If we have the full history, we are good + if (fileLog && !fileLog.truncated && !fileLog.sha) { + previousCommand = commit.previousSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [commit.previousUri, commit.previousSha, undefined, goBackCommand, fileLog]); + nextCommand = commit.nextSha && new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [commit.nextUri, commit.nextSha, undefined, goBackCommand, fileLog]); + } + else { + previousCommand = async () => { + let log = fileLog; + let c = log && log.commits.get(commit.sha); + + // If we can't find the commit or the previous commit isn't available (since it isn't trustworthy) + if (!c || !c.previousSha) { + log = await git.getLogForFile(commit.repoPath, uri.fsPath, commit.sha, git.config.advanced.maxQuickHistory); + c = log && log.commits.get(commit.sha); + // Since we exclude merge commits in file log, just grab the first returned commit + if (!c && commit.isMerge) { + c = Iterables.first(log.commits.values()); + } + + if (c) { + // Copy over next info, since it is trustworthy at this point + c.nextSha = commit.nextSha; + c.nextFileName = commit.nextFileName; + } } - } - if (!c || !c.previousSha) return KeyNoopCommand; - return new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [c.previousUri, c.previousSha, undefined, goBackCommand, log]); - }; - - nextCommand = async () => { - let log = fileLog; - let c = log && log.commits.get(commit.sha); - - // If we can't find the commit or the next commit isn't available (since it isn't trustworthy) - if (!c || !c.nextSha) { - log = undefined; - c = undefined; - - // Try to find the next commit - const next = await git.findNextCommit(commit.repoPath, uri.fsPath, commit.sha); - if (next && next.sha !== commit.sha) { - c = commit; - c.nextSha = next.sha; - c.nextFileName = next.originalFileName || next.fileName; + if (!c || !c.previousSha) return KeyNoopCommand; + return new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [c.previousUri, c.previousSha, undefined, goBackCommand, log]); + }; + + nextCommand = async () => { + let log = fileLog; + let c = log && log.commits.get(commit.sha); + + // If we can't find the commit or the next commit isn't available (since it isn't trustworthy) + if (!c || !c.nextSha) { + log = undefined; + c = undefined; + + // Try to find the next commit + const next = await git.findNextCommit(commit.repoPath, uri.fsPath, commit.sha); + if (next && next.sha !== commit.sha) { + c = commit; + c.nextSha = next.sha; + c.nextFileName = next.originalFileName || next.fileName; + } } - } - if (!c || !c.nextSha) return KeyNoopCommand; - return new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [c.nextUri, c.nextSha, undefined, goBackCommand, log]); - }; + if (!c || !c.nextSha) return KeyNoopCommand; + return new KeyCommandQuickPickItem(Commands.ShowQuickCommitFileDetails, [c.nextUri, c.nextSha, undefined, goBackCommand, log]); + }; + } } const scope = await Keyboard.instance.beginScope({ diff --git a/src/quickPicks/common.ts b/src/quickPicks/common.ts index 8247591..bfe0410 100644 --- a/src/quickPicks/common.ts +++ b/src/quickPicks/common.ts @@ -117,7 +117,12 @@ export class CommitQuickPickItem implements QuickPickItem { detail: string; constructor(public commit: GitCommit, descriptionSuffix: string = '') { - this.label = `${commit.author}, ${moment(commit.date).fromNow()}`; + if (commit.author) { + this.label = `${commit.author}, ${moment(commit.date).fromNow()}`; + } + else { + this.label = `${moment(commit.date).fromNow()}`; + } this.description = `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.shortSha}${descriptionSuffix}`; this.detail = commit.message; } diff --git a/src/quickPicks/stashList.ts b/src/quickPicks/stashList.ts new file mode 100644 index 0000000..374f0cf --- /dev/null +++ b/src/quickPicks/stashList.ts @@ -0,0 +1,32 @@ +'use strict'; +import { Iterables } from '../system'; +import { QuickPickOptions, window } from 'vscode'; +import { Keyboard } from '../commands'; +import { IGitStash } from '../gitService'; +import { CommandQuickPickItem, CommitQuickPickItem, getQuickPickIgnoreFocusOut } from '../quickPicks'; + +export class StashListQuickPick { + + static async show(stash: IGitStash, placeHolder?: string, goBackCommand?: CommandQuickPickItem): Promise { + const items = ((stash && Array.from(Iterables.map(stash.commits.values(), c => new CommitQuickPickItem(c, ` \u2014 ${c.fileNames}`)))) || []) as (CommitQuickPickItem | CommandQuickPickItem)[]; + + if (goBackCommand) { + items.splice(0, 0, goBackCommand); + } + + const scope = await Keyboard.instance.beginScope({ left: goBackCommand }); + + const pick = await window.showQuickPick(items, { + matchOnDescription: true, + placeHolder: placeHolder || `stash list \u2014 search by message, filename, or sha`, + ignoreFocusOut: getQuickPickIgnoreFocusOut() + // onDidSelectItem: (item: QuickPickItem) => { + // scope.setKeyCommand('right', item); + // } + } as QuickPickOptions); + + await scope.dispose(); + + return pick; + } +} \ No newline at end of file