diff --git a/package.json b/package.json index faee0f1..b2834dc 100644 --- a/package.json +++ b/package.json @@ -382,6 +382,11 @@ "title": "Show Repository History", "category": "GitLens" }, + { + "command": "gitlens.showQuickRepoStatus", + "title": "Show Repository Status", + "category": "GitLens" + }, { "command": "gitlens.copyShaToClipboard", "title": "Copy Commit Sha to Clipboard", @@ -443,6 +448,10 @@ "command": "gitlens.showQuickRepoHistory", "when": "gitlens:enabled" }, + { + "command": "gitlens.showQuickRepoStatus", + "when": "gitlens:enabled" + }, { "command": "gitlens.copyShaToClipboard", "when": "gitlens:enabled" @@ -478,7 +487,12 @@ { "command": "gitlens.showQuickFileHistory", "when": "gitlens:enabled", - "group": "1_gitlens" + "group": "1_gitlens@1" + }, + { + "command": "gitlens.showQuickRepoStatus", + "when": "gitlens:enabled", + "group": "1_gitlens@2" }, { "command": "gitlens.diffWithPrevious", @@ -577,6 +591,12 @@ "mac": "shift+alt+h", "when": "gitlens:enabled" }, + { + "command": "gitlens.showQuickRepoStatus", + "key": "alt+s", + "mac": "alt+s", + "when": "gitlens:enabled" + }, { "command": "gitlens.showQuickCommitDetails", "key": "alt+c", diff --git a/src/commands/quickPickItems.ts b/src/commands/quickPickItems.ts index 63ec49a..749afb0 100644 --- a/src/commands/quickPickItems.ts +++ b/src/commands/quickPickItems.ts @@ -1,11 +1,19 @@ 'use strict'; import { commands, QuickPickItem, TextEditor, Uri, window, workspace } from 'vscode'; -import { BuiltInCommands, Commands } from '../constants'; -import { GitCommit, GitUri } from '../gitProvider'; +import { Commands } from '../commands'; +import { BuiltInCommands } from '../constants'; +import { GitCommit, GitFileStatusItem, GitUri } from '../gitProvider'; import * as moment from 'moment'; import * as path from 'path'; +export interface PartialQuickPickItem { + label?: string; + description?: string; + detail?: string; +} + export class CommandQuickPickItem implements QuickPickItem { + label: string; description: string; detail: string; @@ -20,53 +28,27 @@ export class CommandQuickPickItem implements QuickPickItem { } export class OpenFilesCommandQuickPickItem extends CommandQuickPickItem { - label: string; - description: string; - detail: string; - constructor(private commit: GitCommit, private fileNames?: string[]) { - super({ - label: `$(file-symlink-file) Open Files`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(file-text) ${commit.fileName}`, - detail: `Opens all the files in commit $(git-commit) ${commit.sha}` - }, undefined, undefined); + constructor(public fileNames: string[], public repoPath: string, item: QuickPickItem) { + super(item, undefined, undefined); + } - if (!this.fileNames) { - this.fileNames = commit.fileName.split(', ').filter(_ => !!_); - } + getUri(fileName: string) { + return Uri.file(path.resolve(this.repoPath, fileName)); } async execute(): Promise<{}> { - const repoPath = this.commit.repoPath; - for (const file of this.fileNames) { - try { - const uri = Uri.file(path.resolve(repoPath, file)); - const document = await workspace.openTextDocument(uri); - await window.showTextDocument(document, 1, true); - } - catch (ex) { } + for (const fileName of this.fileNames) { + this.open(fileName); } return undefined; } -} -export class OpenFileCommandQuickPickItem extends CommandQuickPickItem { - label: string; - description: string; - detail: string; - - constructor(private commit: GitCommit) { - super({ - label: `$(file-symlink-file) Open File`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(file-text) ${commit.fileName}` - }, undefined, undefined); - } - - async execute(): Promise<{}> { - const repoPath = this.commit.repoPath; + async open(fileName: string): Promise { try { - const file = path.resolve(repoPath, this.commit.fileName); - return await commands.executeCommand(BuiltInCommands.Open, Uri.file(file)); + const uri = this.getUri(fileName); + const document = await workspace.openTextDocument(uri); + return window.showTextDocument(document, 1, true); } catch (ex) { return undefined; @@ -74,6 +56,107 @@ export class OpenFileCommandQuickPickItem extends CommandQuickPickItem { } } +export class OpenCommitFilesCommandQuickPickItem extends OpenFilesCommandQuickPickItem { + + constructor(commit: GitCommit, fileNames?: string[], item?: PartialQuickPickItem) { + const repoPath = commit.repoPath; + + if (!fileNames) { + fileNames = commit.fileName.split(', ').filter(_ => !!_); + } + + item = { + ...{ + label: `$(file-symlink-file) Open Files`, + description: undefined, + detail: `Opens all of the files in commit $(git-commit) ${commit.sha}` + }, + ...item + }; + + super(fileNames, repoPath, item as QuickPickItem); + } +} + +export class OpenStatusFilesCommandQuickPickItem extends OpenFilesCommandQuickPickItem { + + constructor(statuses: GitFileStatusItem[], item?: PartialQuickPickItem) { + const repoPath = statuses[0].repoPath; + const fileNames = statuses.map(_ => _.fileName); + + item = { + ...{ + label: `$(file-symlink-file) Open Files`, + description: undefined, + detail: `Opens all of the changed files in the repository` + }, + ...item + }; + + super(fileNames, repoPath, item as QuickPickItem); + } +} + +export class OpenFileCommandQuickPickItem extends CommandQuickPickItem { + + constructor(public fileName: string, public repoPath: string, item: QuickPickItem) { + super(item, undefined, undefined); + } + + getUri() { + return Uri.file(path.resolve(this.repoPath, this.fileName)); + } + + async execute(): Promise<{}> { + try { + const uri = this.getUri(); + return await commands.executeCommand(BuiltInCommands.Open, uri); + } + catch (ex) { + return undefined; + } + } +} + +export class OpenCommitFileCommandQuickPickItem extends OpenFileCommandQuickPickItem { + + constructor(commit: GitCommit, item?: PartialQuickPickItem) { + item = { + ...{ + label: `$(file-symlink-file) Open File`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${path.basename(commit.fileName)} \u00a0\u2022\u00a0 ${path.dirname(commit.fileName)}` + }, + ...item + }; + + super(commit.fileName, commit.repoPath, item as QuickPickItem); + } +} + +const statusOcticons = [ + '\u00a0$(question)', + '\u00a0$(diff-ignored)', + '\u00a0$(diff-added)', + '\u00a0$(diff-modified)', + '\u00a0$(diff-removed)', + '\u00a0$(diff-renamed)' +]; + +export class OpenStatusFileCommandQuickPickItem extends OpenFileCommandQuickPickItem { + + constructor(status: GitFileStatusItem, item?: PartialQuickPickItem) { + item = { + ...{ + label: `${status.staged ? '$(check)' : '\u00a0\u00a0\u00a0'}\u00a0${statusOcticons[status.status]}\u00a0\u00a0\u00a0${path.basename(status.fileName)}`, + description: path.dirname(status.fileName) + }, + ...item + }; + + super(status.fileName, status.repoPath, item as QuickPickItem); + } +} + export class CommitQuickPickItem implements QuickPickItem { label: string; @@ -97,28 +180,13 @@ export class FileQuickPickItem implements QuickPickItem { uri: GitUri; constructor(commit: GitCommit, public fileName: string) { - this.label = fileName; + this.label = path.basename(fileName); + this.description = path.dirname(fileName); this.sha = commit.sha; this.uri = GitUri.fromUri(Uri.file(path.resolve(commit.repoPath, fileName))); } - async open(): Promise { - let document = workspace.textDocuments.find(_ => _.fileName === this.uri.fsPath); - const existing = !!document; - try { - if (!document) { - document = await workspace.openTextDocument(this.uri); - } - - const editor = await window.showTextDocument(document, 1, true); - return existing ? undefined : editor; - } - catch (ex) { - return undefined; - } - } - async preview(): Promise<{}> { try { return await commands.executeCommand(BuiltInCommands.Open, this.uri); diff --git a/src/commands/quickPicks.ts b/src/commands/quickPicks.ts index 632405d..010dcf9 100644 --- a/src/commands/quickPicks.ts +++ b/src/commands/quickPicks.ts @@ -2,9 +2,9 @@ import { Iterables } from '../system'; import { QuickPickOptions, Uri, window, workspace } from 'vscode'; import { IAdvancedConfig } from '../configuration'; -import { Commands } from '../constants'; -import GitProvider, { GitCommit, GitUri, IGitLog } from '../gitProvider'; -import { CommandQuickPickItem, CommitQuickPickItem, FileQuickPickItem, OpenFileCommandQuickPickItem, OpenFilesCommandQuickPickItem } from './quickPickItems'; +import { Commands } from '../commands'; +import GitProvider, { GitCommit, GitFileStatus, GitFileStatusItem, GitUri, IGitLog } from '../gitProvider'; +import { CommandQuickPickItem, CommitQuickPickItem, FileQuickPickItem, OpenCommitFileCommandQuickPickItem, OpenStatusFileCommandQuickPickItem, OpenCommitFilesCommandQuickPickItem, OpenStatusFilesCommandQuickPickItem } from './quickPickItems'; import * as moment from 'moment'; import * as path from 'path'; @@ -17,6 +17,8 @@ export class CommitQuickPick { static async show(git: GitProvider, commit: GitCommit, workingFileName: string, uri: Uri, currentCommand?: CommandQuickPickItem, goBackCommand?: CommandQuickPickItem, options: { showFileHistory?: boolean } = {}): Promise { const items: CommandQuickPickItem[] = []; + const workingName = (workingFileName && path.basename(workingFileName)) || path.basename(commit.fileName); + const isUncommitted = commit.isUncommitted; if (isUncommitted) { // Since we can't trust the previous sha on an uncommitted commit, find the last commit for this file @@ -35,10 +37,10 @@ export class CommitQuickPick { items.push(new CommandQuickPickItem({ label: `$(git-compare) Compare with Working Tree`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.sha} \u00a0 $(git-compare) \u00a0 $(file-text) ${workingFileName || commit.fileName}` + description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.sha} \u00a0 $(git-compare) \u00a0 $(file-text) ${workingName}` }, Commands.DiffWithWorking, [uri, commit])); - items.push(new OpenFileCommandQuickPickItem(commit)); + items.push(new OpenCommitFileCommandQuickPickItem(commit)); items.push(new CommandQuickPickItem({ label: `$(clippy) Copy Commit Sha to Clipboard`, @@ -52,24 +54,22 @@ export class CommitQuickPick { items.push(new CommandQuickPickItem({ label: `$(diff) Show Changed Files`, - description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.sha}`, - detail: `Shows all the changed files in commit $(git-commit) ${commit.sha}` + description: undefined, //`\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.sha}`, + detail: `Shows all of the changed files in commit $(git-commit) ${commit.sha}` }, Commands.ShowQuickCommitDetails, [new GitUri(commit.uri, commit), commit.sha, undefined, currentCommand])); if (options.showFileHistory) { - const fileName = path.basename(commit.fileName); - fileName; if (workingFileName) { items.push(new CommandQuickPickItem({ label: `$(versions) Show Commit History`, - description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.fileName}`, + description: undefined, //`\u00a0 \u2014 \u00a0\u00a0 ${path.basename(commit.fileName)}`, detail: `Shows the commit history of the file, starting at the most recent commit` }, Commands.ShowQuickFileHistory, [commit.uri, undefined, undefined, currentCommand])); } items.push(new CommandQuickPickItem({ label: `$(versions) Show Previous Commit History`, - description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.fileName}`, + description: undefined, //`\u00a0 \u2014 \u00a0\u00a0 ${path.basename(commit.fileName)}`, detail: `Shows the previous commit history of the file, starting at $(git-commit) ${commit.sha}` }, Commands.ShowQuickFileHistory, [new GitUri(commit.uri, commit), undefined, undefined, currentCommand])); } @@ -80,7 +80,7 @@ export class CommitQuickPick { return await window.showQuickPick(items, { matchOnDescription: true, - placeHolder: `${commit.fileName} \u2022 ${isUncommitted ? 'Uncommitted \u21E8 ' : '' }${commit.sha} \u2022 ${commit.author}, ${moment(commit.date).fromNow()} \u2022 ${commit.message}`, + placeHolder: `${path.basename(commit.fileName)} \u00a0\u2022\u00a0 ${path.dirname(commit.fileName)} \u2022 ${isUncommitted ? 'Uncommitted \u21E8 ' : '' }${commit.sha} \u2022 ${commit.author}, ${moment(commit.date).fromNow()} \u2022 ${commit.message}`, ignoreFocusOut: getQuickPickIgnoreFocusOut() } as QuickPickOptions); } @@ -92,7 +92,7 @@ export class CommitFilesQuickPick { const fileNames = commit.fileName.split(', ').filter(_ => !!_); const items: (FileQuickPickItem | CommandQuickPickItem)[] = fileNames.map(f => new FileQuickPickItem(commit, f)); - items.splice(0, 0, new OpenFilesCommandQuickPickItem(commit, fileNames)); + items.splice(0, 0, new OpenCommitFilesCommandQuickPickItem(commit, fileNames)); if (goBackCommand) { items.splice(0, 0, goBackCommand); @@ -143,10 +143,12 @@ export class FileCommitsQuickPick { items.splice(0, 0, goBackCommand); } + const fileName = Iterables.first(log.commits.values()).fileName; + return await window.showQuickPick(items, { matchOnDescription: true, matchOnDetail: true, - placeHolder: `${Iterables.first(log.commits.values()).fileName}`, + placeHolder: `${path.basename(fileName)} \u00a0\u2022\u00a0 ${path.dirname(fileName)}`, ignoreFocusOut: getQuickPickIgnoreFocusOut() } as QuickPickOptions); } @@ -176,4 +178,42 @@ export class RepoCommitsQuickPick { ignoreFocusOut: getQuickPickIgnoreFocusOut() } as QuickPickOptions); } +} + +export class RepoStatusesQuickPick { + + static async show(statuses: GitFileStatusItem[], goBackCommand?: CommandQuickPickItem): Promise { + // Sort the status by staged and then filename + statuses.sort((a, b) => (a.staged ? -1 : 1) - (b.staged ? -1 : 1) || a.fileName.localeCompare(b.fileName)); + + const items = Array.from(Iterables.map(statuses, s => new OpenStatusFileCommandQuickPickItem(s))) as (OpenStatusFileCommandQuickPickItem | CommandQuickPickItem)[]; + + if (statuses.some(_ => _.staged)) { + const index = statuses.findIndex(_ => !_.staged); + if (index > -1) { + items.splice(index, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== GitFileStatus.Deleted && !_.staged), { + label: `$(file-symlink-file) Open Unstaged Files`, + description: undefined, + detail: `Opens all of the unstaged files in the repository` + })); + + items.splice(0, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== GitFileStatus.Deleted && _.staged), { + label: `$(file-symlink-file) Open Staged Files`, + description: undefined, + detail: `Opens all of the staged files in the repository` + })); + } + } + items.splice(0, 0, new OpenStatusFilesCommandQuickPickItem(statuses.filter(_ => _.status !== GitFileStatus.Deleted))); + + if (goBackCommand) { + items.splice(0, 0, goBackCommand); + } + + return await window.showQuickPick(items, { + matchOnDescription: true, + placeHolder: 'Showing the repository status', + ignoreFocusOut: getQuickPickIgnoreFocusOut() + } as QuickPickOptions); + } } \ No newline at end of file diff --git a/src/commands/showQuickRepoStatus.ts b/src/commands/showQuickRepoStatus.ts new file mode 100644 index 0000000..edd15ea --- /dev/null +++ b/src/commands/showQuickRepoStatus.ts @@ -0,0 +1,61 @@ +'use strict'; +import { TextEditor, Uri, window } from 'vscode'; +import { ActiveEditorCommand, Commands } from '../commands'; +import GitProvider, { GitUri } from '../gitProvider'; +import { Logger } from '../logger'; +import { CommandQuickPickItem } from './quickPickItems'; +import { RepoStatusesQuickPick } from './quickPicks'; + +export default class ShowQuickRepoStatusCommand extends ActiveEditorCommand { + + constructor(private git: GitProvider, public repoPath: string) { + super(Commands.ShowQuickRepoStatus); + } + + async execute(editor: TextEditor, uri?: Uri, goBackCommand?: CommandQuickPickItem) { + if (!(uri instanceof Uri)) { + uri = editor && editor.document && editor.document.uri; + } + + try { + let repoPath: string; + if (uri instanceof Uri) { + const gitUri = GitUri.fromUri(uri, this.git); + repoPath = gitUri.repoPath; + + if (!repoPath) { + repoPath = await this.git.getRepoPathFromFile(gitUri.fsPath); + } + } + + if (!repoPath) { + repoPath = this.repoPath; + } + if (!repoPath) return window.showWarningMessage(`Unable to show repository status`); + + const statuses = await this.git.getStatusesForRepo(repoPath); + if (!statuses) return window.showWarningMessage(`Unable to show repository status`); + + const pick = await RepoStatusesQuickPick.show(statuses, goBackCommand); + if (!pick) return undefined; + + if (pick instanceof CommandQuickPickItem) { + return pick.execute(); + } + + // commit = pick.commit; + + // return commands.executeCommand(Commands.ShowQuickCommitDetails, + // new GitUri(commit.uri, commit), + // commit.sha, undefined, + // new CommandQuickPickItem({ + // label: `go back \u21A9`, + // description: null + // }, Commands.ShowQuickRepoHistory, [uri, maxCount, undefined, goBackCommand])); + } + catch (ex) { + Logger.error('[GitLens.ShowQuickRepoStatusCommand]', ex); + return window.showErrorMessage(`Unable to show repository status. See output channel for more details`); + } + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index d515f7e..eff91cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,7 @@ import ShowFileHistoryCommand from './commands/showFileHistory'; import ShowQuickCommitDetailsCommand from './commands/showQuickCommitDetails'; import ShowQuickFileHistoryCommand from './commands/showQuickFileHistory'; import ShowQuickRepoHistoryCommand from './commands/showQuickRepoHistory'; +import ShowQuickRepoStatusCommand from './commands/showQuickRepoStatus'; import ToggleBlameCommand from './commands/toggleBlame'; import ToggleCodeLensCommand from './commands/toggleCodeLens'; import { IAdvancedConfig, IBlameConfig } from './configuration'; @@ -91,6 +92,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(new ShowQuickCommitDetailsCommand(git)); context.subscriptions.push(new ShowQuickFileHistoryCommand(git)); context.subscriptions.push(new ShowQuickRepoHistoryCommand(git, repoPath)); + context.subscriptions.push(new ShowQuickRepoStatusCommand(git, repoPath)); context.subscriptions.push(new ToggleCodeLensCommand(git)); } diff --git a/src/git/git.ts b/src/git/git.ts index ccd570d..06625cf 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -171,13 +171,18 @@ export default class Git { return gitCommand(root, 'show', `${sha}:./${file}`); } - static statusForFile(fileName: string, repoPath: string): Promise { + static statusFile(fileName: string, repoPath: string): Promise { const [file, root]: [string, string] = Git.splitPath(Git.normalizePath(fileName), repoPath); const params = ['status', file, '--short']; return gitCommand(root, ...params); } + static statusRepo(repoPath: string): Promise { + const params = ['status', '--short']; + return gitCommand(repoPath, ...params); + } + static isUncommitted(sha: string) { return UncommittedRegex.test(sha); } diff --git a/src/git/gitEnrichment.ts b/src/git/gitEnrichment.ts index 9a61776..cb81335 100644 --- a/src/git/gitEnrichment.ts +++ b/src/git/gitEnrichment.ts @@ -106,4 +106,59 @@ export interface IGitLog { repoPath: string; authors: Map; commits: Map; +} + +export enum GitFileStatus { + Unknown, + Untracked, + Added, + Modified, + Deleted, + Renamed +} + +export class GitFileStatusItem { + + staged: boolean; + status: GitFileStatus; + fileName: string; + + constructor(public repoPath: string, status: string) { + this.fileName = status.substring(3); + this.parseStatus(status); + } + + private parseStatus(status: string) { + const indexStatus = status[0]; + const workTreeStatus = status[1]; + + this.staged = workTreeStatus === ' '; + + if (indexStatus === '?' && workTreeStatus === '?') { + this.status = GitFileStatus.Untracked; + return; + } + + if (indexStatus === 'A') { + this.status = GitFileStatus.Added; + return; + } + + if (indexStatus === 'M' || workTreeStatus === 'M') { + this.status = GitFileStatus.Modified; + return; + } + + if (indexStatus === 'D' || workTreeStatus === 'D') { + this.status = GitFileStatus.Deleted; + return; + } + + if (indexStatus === 'R') { + this.status = GitFileStatus.Renamed; + return; + } + + this.status = GitFileStatus.Unknown; + } } \ No newline at end of file diff --git a/src/gitProvider.ts b/src/gitProvider.ts index 9e8f713..6756d7c 100644 --- a/src/gitProvider.ts +++ b/src/gitProvider.ts @@ -3,7 +3,7 @@ import { Iterables, Objects } from './system'; import { Disposable, Event, EventEmitter, ExtensionContext, FileSystemWatcher, languages, Location, Position, Range, TextDocument, TextEditor, Uri, workspace } from 'vscode'; import { CodeLensVisibility, IConfig } from './configuration'; import { DocumentSchemes, WorkspaceState } from './constants'; -import Git, { GitBlameParserEnricher, GitBlameFormat, GitCommit, GitLogParserEnricher, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog } from './git/git'; +import Git, { GitBlameParserEnricher, GitBlameFormat, GitCommit, GitFileStatusItem, GitLogParserEnricher, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog } from './git/git'; import { IGitUriData, GitUri } from './git/gitUri'; import GitCodeLensProvider from './gitCodeLensProvider'; import { Logger } from './logger'; @@ -559,15 +559,22 @@ export default class GitProvider extends Disposable { return locations; } - async getStatusForFile(fileName: string, repoPath: string) { - Logger.log(`getStatusForFile('${fileName}', ${repoPath})`); - return (await Git.statusForFile(fileName, repoPath)).trim(); + async getStatusForFile(fileName: string, repoPath: string): Promise { + Logger.log(`getStatusForFile('${fileName}', '${repoPath}')`); + const status = await Git.statusFile(fileName, repoPath); + return status && status.trim().length && new GitFileStatusItem(repoPath, status); } - async isFileUncommitted(fileName: string, repoPath: string) { + async getStatusesForRepo(repoPath: string): Promise { + Logger.log(`getStatusForRepo('${repoPath}')`); + const statuses = (await Git.statusRepo(repoPath)).split('\n').filter(_ => !!_); + return statuses.map(_ => new GitFileStatusItem(repoPath, _)); + } + + async isFileUncommitted(fileName: string, repoPath: string): Promise { Logger.log(`isFileUncommitted('${fileName}', ${repoPath})`); const status = await this.getStatusForFile(fileName, repoPath); - return status && status.length; + return !!status; } async getVersionedFile(fileName: string, repoPath: string, sha: string) {