diff --git a/packages/core/src/browser/style/variables-bright.useable.css b/packages/core/src/browser/style/variables-bright.useable.css index 122a42d54a2dc..e4590ff93cd54 100644 --- a/packages/core/src/browser/style/variables-bright.useable.css +++ b/packages/core/src/browser/style/variables-bright.useable.css @@ -169,6 +169,10 @@ all of MD as it is not optimized for dense, information rich UIs. --theia-ui-button-color-disabled: var(--theia-accent-color5); --theia-ui-button-font-color-disabled: var(--theia-ui-font-color3); + /* expand/collapse element */ + --theia-ui-expand-button-color: var(--theia-accent-color4); + --theia-ui-expand-button-font-color: var(--theia-ui-font-color1); + /* dialogs */ --theia-ui-dialog-header-color: var(--theia-brand-color1); --theia-ui-dialog-header-font-color: var(--theia-inverse-ui-font-color1); diff --git a/packages/core/src/browser/style/variables-dark.useable.css b/packages/core/src/browser/style/variables-dark.useable.css index 324ed37826d7a..12182b59fc96b 100644 --- a/packages/core/src/browser/style/variables-dark.useable.css +++ b/packages/core/src/browser/style/variables-dark.useable.css @@ -169,7 +169,11 @@ all of MD as it is not optimized for dense, information rich UIs. --theia-ui-button-color-disabled: var(--theia-accent-color5); --theia-ui-button-font-color-disabled: var(--theia-ui-font-color3); - /* dialogs */ + /* expand/collapse element */ + --theia-ui-expand-button-color: black; + --theia-ui-expand-button-font-color: var(--theia-ui-font-color1); + + /* dialogs */ --theia-ui-dialog-header-color: var(--theia-brand-color0); --theia-ui-dialog-header-font-color: var(--theia-ui-font-color1); --theia-ui-dialog-color: var(--theia-layout-color1); diff --git a/packages/editor/src/browser/diff-uris.ts b/packages/editor/src/browser/diff-uris.ts index 1367ba5674aa5..ff0b417bb738c 100644 --- a/packages/editor/src/browser/diff-uris.ts +++ b/packages/editor/src/browser/diff-uris.ts @@ -39,7 +39,7 @@ export namespace DiffUris { @injectable() export class DiffUriLabelProviderContribution implements LabelProviderContribution { - constructor( @inject(LabelProvider) protected labelProvider: LabelProvider) { } + constructor(@inject(LabelProvider) protected labelProvider: LabelProvider) { } canHandle(element: object): number { if (element instanceof URI && DiffUris.isDiffUri(element)) { @@ -60,12 +60,17 @@ export class DiffUriLabelProviderContribution implements LabelProviderContributi getName(uri: URI): string { const [left, right] = DiffUris.decode(uri); - const leftLongName = this.labelProvider.getName(left); - const rightLongName = this.labelProvider.getName(right); - if (leftLongName === rightLongName) { - return leftLongName; + + if (left.path.toString() === right.path.toString() && left.query && right.query) { + return `${left.displayName}: ${left.query} <-> ${right.query}`; + } else { + const leftLongName = this.labelProvider.getName(left); + const rightLongName = this.labelProvider.getName(right); + if (leftLongName === rightLongName) { + return leftLongName; + } + return `${leftLongName} <-> ${rightLongName}`; } - return `${leftLongName} <-> ${rightLongName}`; } getIcon(uri: URI): string { diff --git a/packages/git/src/browser/diff/git-diff-widget.ts b/packages/git/src/browser/diff/git-diff-widget.ts index d580f43b1218a..6d1757448978e 100644 --- a/packages/git/src/browser/diff/git-diff-widget.ts +++ b/packages/git/src/browser/diff/git-diff-widget.ts @@ -29,7 +29,7 @@ export class GitDiffWidget extends GitBaseWidget implements StatefulWidget { @inject(GitRepositoryProvider) protected repositoryProvider: GitRepositoryProvider, @inject(LabelProvider) protected labelProvider: LabelProvider, @inject(OpenerService) protected openerService: OpenerService) { - super(); + super(repositoryProvider, labelProvider); this.id = GIT_DIFF; this.title.label = "Diff"; @@ -71,24 +71,6 @@ export class GitDiffWidget extends GitBaseWidget implements StatefulWidget { } } - protected relativePath(uri: URI | string): string { - const parsedUri = typeof uri === 'string' ? new URI(uri) : uri; - const repo = this.repositoryProvider.selectedRepository; - if (repo) { - return this.getRepositoryRelativePath(repo, parsedUri); - } else { - return this.labelProvider.getLongName(parsedUri); - } - } - - protected computeCaption(fileChange: GitFileChange): string { - let result = `${this.relativePath(fileChange.uri)} - ${this.getStatusCaption(fileChange.status, true)}`; - if (fileChange.oldUri) { - result = `${this.relativePath(fileChange.oldUri)} -> ${result}`; - } - return result; - } - storeState(): object { const { fileChangeNodes, options } = this; return { @@ -143,7 +125,7 @@ export class GitDiffWidget extends GitBaseWidget implements StatefulWidget { const fileChangeElement: h.Child = this.renderGitItem(fileChange); files.push(fileChangeElement); } - return h.div({ className: "commitFileListContainer" }, ...files); + return h.div({ className: "listContainer" }, ...files); } protected renderGitItem(change: GitFileChangeNode): h.Child { diff --git a/packages/git/src/browser/git-base-widget.ts b/packages/git/src/browser/git-base-widget.ts index f49fe2d480542..65297b654f43a 100644 --- a/packages/git/src/browser/git-base-widget.ts +++ b/packages/git/src/browser/git-base-widget.ts @@ -6,11 +6,19 @@ */ import { VirtualWidget } from "@theia/core/lib/browser"; -import { GitFileStatus, Repository } from '../common'; +import { GitFileStatus, Repository, GitFileChange } from '../common'; import URI from "@theia/core/lib/common/uri"; +import { GitRepositoryProvider } from "./git-repository-provider"; +import { LabelProvider } from "@theia/core/lib/browser/label-provider"; export class GitBaseWidget extends VirtualWidget { + constructor( + protected readonly repositoryProvider: GitRepositoryProvider, + protected readonly labelProvider: LabelProvider) { + super(); + } + protected getStatusCaption(status: GitFileStatus, staged: boolean): string { switch (status) { case GitFileStatus.New: return staged ? 'Added' : 'Unstaged'; @@ -23,13 +31,26 @@ export class GitBaseWidget extends VirtualWidget { return ''; } - /** - * Returns the repository relative path of the given uri. - * @param repository - * @param uri - */ protected getRepositoryRelativePath(repository: Repository, uri: URI) { const repositoryUri = new URI(repository.localUri); return uri.toString().substr(repositoryUri.toString().length + 1); } + + protected relativePath(uri: URI | string): string { + const parsedUri = typeof uri === 'string' ? new URI(uri) : uri; + const repo = this.repositoryProvider.selectedRepository; + if (repo) { + return this.getRepositoryRelativePath(repo, parsedUri); + } else { + return this.labelProvider.getLongName(parsedUri); + } + } + + protected computeCaption(fileChange: GitFileChange): string { + let result = `${this.relativePath(fileChange.uri)} - ${this.getStatusCaption(fileChange.status, true)}`; + if (fileChange.oldUri) { + result = `${this.relativePath(fileChange.oldUri)} -> ${result}`; + } + return result; + } } diff --git a/packages/git/src/browser/git-frontend-module.ts b/packages/git/src/browser/git-frontend-module.ts index 148de0066aaa0..b3b0b8397fc18 100644 --- a/packages/git/src/browser/git-frontend-module.ts +++ b/packages/git/src/browser/git-frontend-module.ts @@ -8,7 +8,9 @@ import { Git, GitPath } from '../common/git'; import { ContainerModule } from 'inversify'; import { bindGitDiffModule } from './diff/git-diff-frontend-module'; -import { WebSocketConnectionProvider, FrontendApplicationContribution, WidgetFactory, KeybindingContribution } from '@theia/core/lib/browser'; +import { bindGitHistoryModule } from './history/git-history-frontend-module'; +import { WebSocketConnectionProvider, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { KeybindingContribution } from '@theia/core/lib/browser/keybinding'; import { GitCommandHandlers } from './git-command'; import { CommandContribution, MenuContribution, ResourceResolver } from "@theia/core/lib/common"; import { GitWatcher, GitWatcherPath, GitWatcherServer, GitWatcherServerProxy, ReconnectingGitWatcherServer } from '../common/git-watcher'; @@ -24,6 +26,7 @@ import '../../src/browser/style/index.css'; export default new ContainerModule(bind => { bindGitDiffModule(bind); + bindGitHistoryModule(bind); bind(GitWatcherServerProxy).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, GitWatcherPath)).inSingletonScope(); bind(GitWatcherServer).to(ReconnectingGitWatcherServer).inSingletonScope(); bind(GitWatcher).toSelf().inSingletonScope(); diff --git a/packages/git/src/browser/git-widget.ts b/packages/git/src/browser/git-widget.ts index 2614183dc3d5c..d43e379cd7c85 100644 --- a/packages/git/src/browser/git-widget.ts +++ b/packages/git/src/browser/git-widget.ts @@ -28,6 +28,14 @@ export interface GitFileChangeNode extends GitFileChange { readonly description: string; readonly caption?: string; readonly extraIconClassName?: string; + readonly commitSha?: string; + selected?: boolean; +} + +export namespace GitFileChangeNode { + export function is(node: any): node is GitFileChangeNode { + return 'uri' in node && 'status' in node && 'description' in node && 'label' in node && 'icon' in node; + } } @injectable() @@ -53,7 +61,7 @@ export class GitWidget extends GitBaseWidget { @inject(LabelProvider) protected readonly labelProvider: LabelProvider, @inject(CommandService) protected readonly commandService: CommandService, @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService) { - super(); + super(repositoryProvider, labelProvider); this.id = 'theia-gitContainer'; this.title.label = 'Git'; @@ -386,5 +394,4 @@ export class GitWidget extends GitBaseWidget { const message = error instanceof Error ? error.message : error; this.messageService.error(message); } - } diff --git a/packages/git/src/browser/history/git-history-contribution.ts b/packages/git/src/browser/history/git-history-contribution.ts new file mode 100644 index 0000000000000..b05d1dea7d327 --- /dev/null +++ b/packages/git/src/browser/history/git-history-contribution.ts @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { MenuModelRegistry, CommandRegistry, Command, SelectionService } from "@theia/core"; +import { AbstractViewContribution } from "@theia/core/lib/browser"; +import { injectable, inject } from "inversify"; +import { NAVIGATOR_CONTEXT_MENU } from "@theia/navigator/lib/browser/navigator-menu"; +import { UriCommandHandler, FileSystemCommandHandler } from "@theia/workspace/lib/browser/workspace-commands"; +import { GitHistoryWidget } from './git-history-widget'; +import { Git } from "../../common"; + +export namespace GitHistoryCommands { + export const OPEN_FILE_HISTORY: Command = { + id: 'git-history:open-file-history', + label: 'Git History' + }; + export const OPEN_BRANCH_HISTORY: Command = { + id: 'git-history:open-branch-history' + }; +} + +export const GIT_HISTORY = 'git-history'; +export const GIT_HISTORY_MAX_COUNT = 100; +@injectable() +export class GitHistoryContribution extends AbstractViewContribution { + + constructor( + @inject(SelectionService) protected readonly selectionService: SelectionService) { + super({ + widgetId: GIT_HISTORY, + widgetName: 'Git History', + defaultWidgetOptions: { + area: 'left', + rank: 400 + }, + toggleCommandId: GitHistoryCommands.OPEN_BRANCH_HISTORY.id, + toggleKeybinding: 'alt+h' + }); + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction([...NAVIGATOR_CONTEXT_MENU, '5_history'], { + commandId: GitHistoryCommands.OPEN_FILE_HISTORY.id + }); + + super.registerMenus(menus); + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(GitHistoryCommands.OPEN_FILE_HISTORY, this.newFileHandler({ + execute: async uri => { + const options: Git.Options.Log = { + uri: uri.toString(), + maxCount: GIT_HISTORY_MAX_COUNT, + shortSha: true + }; + this.showWidget(options); + } + })); + commands.registerCommand(GitHistoryCommands.OPEN_BRANCH_HISTORY, { + execute: () => { + this.showWidget({ + maxCount: GIT_HISTORY_MAX_COUNT, + shortSha: true + }); + } + }); + } + + async showWidget(options?: Git.Options.Log) { + const widget = await this.widget; + await widget.setContent(options); + this.openView({ + toggle: true, + activate: true + }); + } + + protected newFileHandler(handler: UriCommandHandler): FileSystemCommandHandler { + return new FileSystemCommandHandler(this.selectionService, handler); + } +} diff --git a/packages/git/src/browser/history/git-history-frontend-module.ts b/packages/git/src/browser/history/git-history-frontend-module.ts new file mode 100644 index 0000000000000..fd6b492f68d53 --- /dev/null +++ b/packages/git/src/browser/history/git-history-frontend-module.ts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { GitHistoryContribution, GIT_HISTORY } from "./git-history-contribution"; +import { interfaces } from "inversify"; +import { CommandContribution, MenuContribution } from "@theia/core"; +import { KeybindingContribution } from "@theia/core/lib/browser/keybinding"; +import { WidgetFactory } from "@theia/core/lib/browser"; +import { GitHistoryWidget } from "./git-history-widget"; + +import '../../../src/browser/style/history.css'; + +export function bindGitHistoryModule(bind: interfaces.Bind) { + + bind(GitHistoryWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(ctx => ({ + id: GIT_HISTORY, + createWidget: () => ctx.container.get(GitHistoryWidget) + })); + + bind(GitHistoryContribution).toSelf().inSingletonScope(); + for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution]) { + bind(identifier).toDynamicValue(ctx => + ctx.container.get(GitHistoryContribution) + ).inSingletonScope(); + } + +} diff --git a/packages/git/src/browser/history/git-history-widget.ts b/packages/git/src/browser/history/git-history-widget.ts new file mode 100644 index 0000000000000..e0d108ba10900 --- /dev/null +++ b/packages/git/src/browser/history/git-history-widget.ts @@ -0,0 +1,424 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable, inject } from "inversify"; +import { h } from "@phosphor/virtualdom"; +import { DiffUris } from '@theia/editor/lib/browser/diff-uris'; +import { OpenerService, open, StatefulWidget, SELECTED_CLASS } from "@theia/core/lib/browser"; +import { GIT_RESOURCE_SCHEME } from '../git-resource'; +import URI from "@theia/core/lib/common/uri"; +import { GIT_HISTORY, GIT_HISTORY_MAX_COUNT } from './git-history-contribution'; +import { LabelProvider } from '@theia/core/lib/browser/label-provider'; +import { GitRepositoryProvider } from '../git-repository-provider'; +import { GitFileStatus, Git, GitFileChange } from '../../common'; +import { GitBaseWidget } from "../git-base-widget"; +import { GitFileChangeNode } from "../git-widget"; +import { SelectionService } from "@theia/core"; +import { Message } from "@phosphor/messaging"; +import { ElementExt } from "@phosphor/domutils"; +import { FileSystem } from "@theia/filesystem/lib/common"; +import { Key } from "@theia/core/lib/browser/keys"; + +export interface GitCommitNode { + readonly authorName: string; + readonly authorEmail: string; + readonly authorDate: Date; + readonly authorDateRelative: string; + readonly commitMessage: string; + readonly messageBody?: string; + readonly fileChangeNodes: GitFileChangeNode[]; + readonly commitSha: string; + expanded: boolean; + selected: boolean; +} + +export namespace GitCommitNode { + export function is(node: any): node is GitCommitNode { + return 'commitSha' in node && 'commitMessage' in node && 'fileChangeNodes' in node; + } +} + +export enum SelectDirection { + NEXT, PREVIOUS +} + +export type GitHistoryListNode = (GitCommitNode | GitFileChangeNode); + +@injectable() +export class GitHistoryWidget extends GitBaseWidget implements StatefulWidget { + protected options: Git.Options.Log; + protected commits: GitCommitNode[]; + protected historyList: GitHistoryListNode[]; + protected ready: boolean; + protected singleFileMode: boolean; + + constructor( + @inject(GitRepositoryProvider) protected readonly repositoryProvider: GitRepositoryProvider, + @inject(LabelProvider) protected readonly labelProvider: LabelProvider, + @inject(OpenerService) protected readonly openerService: OpenerService, + @inject(SelectionService) protected readonly selectionService: SelectionService, + @inject(FileSystem) protected readonly fileSystem: FileSystem, + @inject(Git) protected readonly git: Git) { + super(repositoryProvider, labelProvider); + this.id = GIT_HISTORY; + this.title.label = "Git History"; + this.addClass('theia-git'); + + this.node.tabIndex = 0; + + selectionService.onSelectionChanged((c: GitHistoryListNode) => { + c.selected = true; + this.update(); + }); + } + + protected onUpdateRequest(msg: Message): void { + super.onUpdateRequest(msg); + + const selected = this.node.getElementsByClassName(SELECTED_CLASS)[0]; + const scrollArea = document.getElementById('git-history-list-container'); + if (selected && scrollArea) { + ElementExt.scrollIntoViewIfNeeded(scrollArea, selected); + } + } + + async setContent(options?: Git.Options.Log) { + this.options = options || {}; + this.commits = []; + this.ready = false; + if (options && options.uri) { + const fileStat = await this.fileSystem.getFileStat(options.uri); + this.singleFileMode = !fileStat.isDirectory; + } + this.addCommits(options); + this.update(); + } + + protected addCommits(options?: Git.Options.Log) { + const repository = this.repositoryProvider.selectedRepository; + if (repository) { + const log = this.git.log(repository, options); + log.then(async changes => { + if (this.commits.length > 0) { + changes = changes.slice(1); + } + if (changes.length > 0) { + const commits: GitCommitNode[] = []; + for (const commit of changes) { + const fileChangeNodes: GitFileChangeNode[] = []; + for (const fileChange of commit.fileChanges) { + const fileChangeUri = new URI(fileChange.uri); + const [icon, label, description] = await Promise.all([ + this.labelProvider.getIcon(fileChangeUri), + this.labelProvider.getName(fileChangeUri), + this.relativePath(fileChangeUri.parent) + ]); + const caption = this.computeCaption(fileChange); + fileChangeNodes.push({ + ...fileChange, icon, label, description, caption, commitSha: commit.sha + }); + } + commits.push({ + authorName: commit.author.name, + authorDate: commit.author.date, + authorEmail: commit.author.email, + authorDateRelative: commit.authorDateRelative, + commitSha: commit.sha, + commitMessage: commit.summary, + fileChangeNodes, + expanded: false, + selected: false + }); + } + this.commits.push(...commits); + this.ready = true; + this.update(); + } + const ll = this.node.getElementsByClassName('history-lazy-loading')[0]; + if (ll && ll.className === "history-lazy-loading show") { + ll.className = "history-lazy-loading hide"; + } + }); + } + } + + storeState(): object { + const { commits, options, singleFileMode } = this; + return { + commits, + options, + singleFileMode + }; + } + + // tslint:disable-next-line:no-any + restoreState(oldState: any): void { + this.commits = oldState['commits']; + this.options = oldState['options']; + this.singleFileMode = oldState['singleFileMode']; + this.ready = true; + this.update(); + } + + protected render(): h.Child { + this.historyList = []; + const containers = []; + if (this.ready) { + containers.push(this.renderHistoryHeader()); + containers.push(this.renderCommitList()); + containers.push(h.div({ className: 'history-lazy-loading' }, h.span({ className: "fa fa-spinner fa-pulse fa-2x fa-fw" }))); + } else { + containers.push(h.div({ className: 'spinnerContainer' }, h.span({ className: 'fa fa-spinner fa-pulse fa-3x fa-fw' }))); + } + return h.div({ className: "git-diff-container" }, ...containers); + } + + protected renderHistoryHeader(): h.Child { + const elements = []; + if (this.options.uri) { + const path = this.relativePath(this.options.uri); + if (path.length > 0) { + elements.push(h.div({ className: 'header-row' }, + h.div({ className: 'theia-header' }, 'path:'), + h.div({ className: 'header-value' }, '/' + path))); + } + } + const header = h.div({ className: 'theia-header' }, `Commits`); + + return h.div({ className: "diff-header" }, ...elements, header); + } + + protected renderCommitList(): h.Child { + const theList: h.Child[] = []; + + for (const commit of this.commits) { + const head = this.renderCommit(commit); + const body = commit.expanded ? this.renderFileChangeList(commit.fileChangeNodes, commit.commitSha) : ""; + theList.push(h.div({ className: "commitListElement" }, head, body)); + } + const commitList = h.div({ className: "commitList" }, ...theList); + return h.div({ + className: "listContainer", + id: "git-history-list-container", + onscroll: e => { + const el = (e.srcElement as HTMLElement); + if (el.scrollTop + el.clientHeight > el.scrollHeight - 5) { + const ll = this.node.getElementsByClassName('history-lazy-loading')[0]; + ll.className = "history-lazy-loading show"; + this.addCommits({ + range: { + toRevision: this.commits[this.commits.length - 1].commitSha + }, + maxCount: GIT_HISTORY_MAX_COUNT + }); + } + } + }, commitList); + } + + protected renderCommit(commit: GitCommitNode): h.Child { + this.historyList.push(commit); + let expansionToggleIcon = "caret-right"; + if (commit && commit.expanded) { + expansionToggleIcon = "caret-down"; + } + const headEl = []; + const expansionToggle = h.div( + { + className: "expansionToggle noselect" + }, + h.div({ className: "toggle" }, + h.div({ className: "number" }, commit.fileChangeNodes.length.toString()), + h.div({ className: "icon fa fa-" + expansionToggleIcon })) + ); + const label = h.div({ className: `headLabelContainer${this.singleFileMode ? ' singleFileMode' : ''}` }, + h.div( + { + className: "headLabel noWrapInfo noselect" + }, + commit.commitMessage), + h.div( + { + className: "commitTime noWrapInfo noselect" + }, + commit.authorDateRelative + ' by ' + commit.authorName + ) + ); + headEl.push(label); + if (!this.singleFileMode) { + headEl.push(expansionToggle); + } + const content = h.div({ className: "headContent" }, ...headEl); + return h.div({ + className: `containerHead${commit.selected ? ' ' + SELECTED_CLASS : ''}`, + onclick: () => { + if (commit.selected && !this.singleFileMode) { + commit.expanded = !commit.expanded; + this.update(); + } else { + this.selectNode(commit); + } + }, + ondblclick: () => { + if (this.singleFileMode) { + this.openFile(commit.fileChangeNodes[0], commit.commitSha); + } + } + }, content); + } + + protected renderFileChangeList(fileChanges: GitFileChangeNode[], commitSha: string): h.Child { + + this.historyList.push(...fileChanges); + + const files: h.Child[] = []; + + for (const fileChange of fileChanges) { + const fileChangeElement: h.Child = this.renderGitItem(fileChange, commitSha); + files.push(fileChangeElement); + } + const commitFiles = h.div({ className: "commitFileList" }, ...files); + return h.div({ className: "commitBody" }, commitFiles); + } + + protected renderGitItem(change: GitFileChangeNode, commitSha: string): h.Child { + const iconSpan = h.span({ className: change.icon + ' file-icon' }); + const nameSpan = h.span({ className: 'name' }, change.label + ' '); + const pathSpan = h.span({ className: 'path' }, change.description); + const elements = []; + elements.push(h.div({ + title: change.caption, + className: 'noWrapInfo', + ondblclick: () => { + this.openFile(change, commitSha); + }, + onclick: () => { + this.selectNode(change); + } + }, iconSpan, nameSpan, pathSpan)); + if (change.extraIconClassName) { + elements.push(h.div({ + title: change.caption, + className: change.extraIconClassName + })); + } + elements.push(h.div({ + title: change.caption, + className: 'status staged ' + GitFileStatus[change.status].toLowerCase() + }, this.getStatusCaption(change.status, true).charAt(0))); + return h.div({ className: `gitItem noselect${change.selected ? ' ' + SELECTED_CLASS : ''}` }, ...elements); + } + + protected onAfterAttach(msg: Message): void { + super.onAfterAttach(msg); + this.addKeyListener(this.node, Key.ARROW_LEFT, () => this.handleLeft()); + this.addKeyListener(this.node, Key.ARROW_RIGHT, () => this.handleRight()); + this.addKeyListener(this.node, Key.ARROW_UP, () => this.handleUp()); + this.addKeyListener(this.node, Key.ARROW_DOWN, () => this.handleDown()); + this.addKeyListener(this.node, Key.ENTER, () => this.handleEnter()); + } + + protected handleLeft(): void { + const selected = this.getSelected(); + if (selected) { + const idx = this.commits.findIndex(c => c.commitSha === selected.commitSha); + if (GitCommitNode.is(selected)) { + if (selected.expanded) { + selected.expanded = false; + } else { + if (idx > 0) { + this.selectNode(this.commits[idx - 1]); + } + } + } else if (GitFileChangeNode.is(selected)) { + this.selectNode(this.commits[idx]); + } + } + this.update(); + } + + protected handleRight(): void { + const selected = this.getSelected(); + if (selected) { + if (GitCommitNode.is(selected) && !selected.expanded && !this.singleFileMode) { + selected.expanded = true; + } else { + this.selectNodeByDirection(SelectDirection.NEXT); + } + } + this.update(); + } + + protected handleUp(): void { + this.selectNodeByDirection(SelectDirection.PREVIOUS); + } + + protected handleDown(): void { + this.selectNodeByDirection(SelectDirection.NEXT); + } + + protected handleEnter(): void { + const selected = this.getSelected(); + if (selected) { + if (GitCommitNode.is(selected)) { + if (this.singleFileMode) { + this.openFile(selected.fileChangeNodes[0], selected.commitSha); + } else { + selected.expanded = !selected.expanded; + } + } else if (GitFileChangeNode.is(selected)) { + this.openFile(selected, selected.commitSha || ""); + } + } + this.update(); + } + + protected onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.node.focus(); + } + + protected getSelected(): GitHistoryListNode | undefined { + return this.historyList ? this.historyList.find(c => c.selected || false) : undefined; + } + + protected selectNode(node: GitHistoryListNode) { + const n = this.getSelected(); + if (n) { + n.selected = false; + } + this.selectionService.selection = node; + } + + protected selectNodeByDirection(dir: SelectDirection) { + const selIdx = this.historyList.findIndex(c => c.selected || false); + let nodeIdx = selIdx; + if (dir === SelectDirection.NEXT && selIdx < this.historyList.length - 1) { + nodeIdx = selIdx + 1; + } else if (dir === SelectDirection.PREVIOUS && selIdx > 0) { + nodeIdx = selIdx - 1; + } + this.selectNode(this.historyList[nodeIdx]); + } + + protected openFile(change: GitFileChange, commitSha: string) { + const uri: URI = new URI(change.uri); + let fromURI = change.oldUri ? new URI(change.oldUri) : uri; // set oldUri on renamed and copied + fromURI = fromURI.withScheme(GIT_RESOURCE_SCHEME).withQuery(commitSha + "~1"); + const toURI = uri.withScheme(GIT_RESOURCE_SCHEME).withQuery(commitSha); + let uriToOpen = uri; + if (change.status === GitFileStatus.Deleted) { + uriToOpen = fromURI; + } else if (change.status === GitFileStatus.New) { + uriToOpen = toURI; + } else { + uriToOpen = DiffUris.encode(fromURI, toURI, uri.displayName); + } + open(this.openerService, uriToOpen); + } + +} diff --git a/packages/git/src/browser/style/diff.css b/packages/git/src/browser/style/diff.css index b1c116363dfec..1535ea6591d70 100644 --- a/packages/git/src/browser/style/diff.css +++ b/packages/git/src/browser/style/diff.css @@ -5,6 +5,8 @@ .theia-git .git-diff-container { display: flex; flex-direction: column; + position: relative; + height: 100%; } .theia-git .revision .row-title { @@ -25,7 +27,7 @@ margin: 8px 0px 5px 5px; } -.theia-git .commitFileListContainer { +.theia-git .listContainer { overflow-y: auto; } diff --git a/packages/git/src/browser/style/history.css b/packages/git/src/browser/style/history.css new file mode 100644 index 0000000000000..f625e519776aa --- /dev/null +++ b/packages/git/src/browser/style/history.css @@ -0,0 +1,126 @@ +.theia-git .commitListContainer .commitList .commitListElement { + margin: 3px 0; +} + +.theia-git .commitListElement { + border-bottom: 1px solid var(--theia-layout-color4); +} + +.theia-git .commitListElement .containerHead { + width: 100%; + height: 50px; + display: flex; + align-items: center; +} + +.theia-git .commitListElement .containerHead:hover { + cursor: pointer; +} + +.theia-git .commitListElement .containerHead .headContent { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 0 8px 0 2px; +} + +.theia-git .commitListElement .containerHead .headContent .headLabelContainer{ + width: calc(100% - 40px); + min-width: calc(100% - 40px); +} + +.theia-git .commitListElement .containerHead .headContent .headLabelContainer.singleFileMode{ + width: 100%; +} + +.theia-git .commitListElement .containerHead .headContent .expansionToggle{ + display: flex; + align-items: center; +} + +.theia-git .commitListElement .containerHead .headContent .expansionToggle > .toggle{ + display: flex; + background: var(--theia-ui-expand-button-color); + padding: 5px; + border-radius: 7px; + margin-left: 5px; + align-items: center; + justify-content: flex-end; + min-width: 30px; + color: var(--theia-ui-expand-button-font-color); +} + +.theia-git:focus .commitListElement .containerHead.theia-mod-selected, +.theia-git:focus .commitListElement .commitBody .gitItem.theia-mod-selected{ + background: var(--theia-accent-color3); +} + +.theia-git .commitListElement .containerHead.theia-mod-selected, +.theia-git .commitListElement .commitBody .gitItem.theia-mod-selected{ + background: var(--theia-accent-color5); +} + +.theia-git .commitBody { + padding-bottom: 10px; +} + +.theia-git .commitFileList .theia-header { + margin-top: 5px; +} + +.theia-git .commitTime { + color: var(--theia-ui-font-color2); + font-size: smaller; +} + +.theia-git .git-diff-container .history-lazy-loading { + position: absolute; + height: 50px; + width: 100%; + bottom: -80px; + opacity: 0; + font-weight: bold; + justify-content: center; + align-items: center; + display: flex; + background: var(--theia-layout-color1); + border: var(--theia-border-width) var(--theia-border-color2) solid; + box-sizing: border-box; +} + +.theia-git .git-diff-container .history-lazy-loading.show { + bottom: 0px; + opacity:0; + animation: showFrames ease-out 0.2s forwards; + -webkit-animation: showFrames ease-out 0.2s forwards; + } + + @keyframes showFrames{ + 0% { + opacity:0; + bottom:-80px ; + } + 100% { + opacity:1; + bottom:0px; + } + } + +.theia-git .git-diff-container .history-lazy-loading.hide { + bottom: 80px; + opacity:1; + animation: hideFrames ease-out 0.2s forwards; + -webkit-animation: hideFrames ease-out 0.2s forwards; + } + + @keyframes hideFrames{ + 0% { + opacity:1; + bottom: 0px ; + } + 100% { + opacity:0; + bottom: -80px ; + } + } + \ No newline at end of file diff --git a/packages/git/src/browser/style/index.css b/packages/git/src/browser/style/index.css index c4659d37f66ef..f47fed9ce5f19 100644 --- a/packages/git/src/browser/style/index.css +++ b/packages/git/src/browser/style/index.css @@ -32,7 +32,7 @@ .theia-git .gitItem { margin: 1px 0 1px 3px; - padding: 2px 0; + padding: 2px; font-size: var(--theia-ui-font-size1); display: flex; justify-content: space-between; diff --git a/packages/git/src/common/git-model.ts b/packages/git/src/common/git-model.ts index 29fce1dff22f7..61b5a33640de7 100644 --- a/packages/git/src/common/git-model.ts +++ b/packages/git/src/common/git-model.ts @@ -225,7 +225,7 @@ export interface Commit { /** * The commit message without the first line and CR. */ - readonly body: string; + readonly body?: string; /** * Information about the author of this commit. It includes name, email and date. @@ -235,10 +235,26 @@ export interface Commit { /** * The SHAs for the parents of the commit. */ - readonly parentSHAs: string[]; + readonly parentSHAs?: string[]; } +/** + * Representation of a Git commit, plus the changes that were performed in that particular commit. + */ +export interface CommitWithChanges extends Commit { + + /** + * The date when the commit was authored. + */ + readonly authorDateRelative: string; + + /** + * The number of file changes per commit. + */ + readonly fileChanges: GitFileChange[]; +} + /** * A tuple of name, email, and a date for the author or commit info in a commit. */ @@ -262,7 +278,7 @@ export interface CommitIdentity { /** * The time-zone offset. */ - readonly tzOffset: number; + readonly tzOffset?: number; } diff --git a/packages/git/src/common/git.ts b/packages/git/src/common/git.ts index cb9842a1d7da8..23f37793373c4 100644 --- a/packages/git/src/common/git.ts +++ b/packages/git/src/common/git.ts @@ -7,7 +7,7 @@ import { ChildProcess } from 'child_process'; import { Disposable } from '@theia/core'; -import { Repository, WorkingDirectoryStatus, Branch, GitResult, GitError, GitFileStatus, GitFileChange } from './git-model'; +import { Repository, WorkingDirectoryStatus, Branch, GitResult, GitError, GitFileStatus, GitFileChange, CommitWithChanges } from './git-model'; /** * The WS endpoint path to the Git service. @@ -439,6 +439,29 @@ export namespace Git { } + /** + * Optional configuration for the `git log` command. + */ + export interface Log extends Diff { + + /** + * The name of the branch to run the `git log` command. If not specified, then the currently active branch will be used. + */ + readonly branch?: string; + + /** + * Limits the number of commits. Also known as `-n` or `--number. If not specified, or not a positive integer, then will be ignored, and the returning list + * of commits will not be limited. + */ + readonly maxCount?: number; + + /** + * Decides whether the commit hash should be the abbreviated version. + */ + readonly shortSha?: boolean; + + } + } } @@ -608,6 +631,14 @@ export interface Git extends Disposable { */ diff(repository: Repository, options?: Git.Options.Diff): Promise; + /** + * Returns a list with commits and their respective file changes. + * + * @param repository the repository where the log has to be calculated. + * @param options optional configuration for further refining the `git log` command execution. + */ + log(repository: Repository, options?: Git.Options.Log): Promise + } /** diff --git a/packages/git/src/node/dugite-git.ts b/packages/git/src/node/dugite-git.ts index 2ff904929f6b8..d93f7dcd33e81 100644 --- a/packages/git/src/node/dugite-git.ts +++ b/packages/git/src/node/dugite-git.ts @@ -26,20 +26,41 @@ import { IStatusResult, IAheadBehind, AppFileStatus, WorkingDirectoryStatus as D import { Branch as DugiteBranch } from 'dugite-extra/lib/model/branch'; import { Commit as DugiteCommit, CommitIdentity as DugiteCommitIdentity } from 'dugite-extra/lib/model/commit'; import { ILogger } from '@theia/core'; -import { Git, GitUtils, Repository, WorkingDirectoryStatus, GitFileChange, GitFileStatus, Branch, Commit, CommitIdentity, GitResult } from '../common'; +import { Git, GitUtils, Repository, WorkingDirectoryStatus, GitFileChange, GitFileStatus, Branch, Commit, CommitIdentity, GitResult, CommitWithChanges } from '../common'; import { GitRepositoryManager } from './git-repository-manager'; import { GitLocator } from './git-locator/git-locator-protocol'; +/** + * Parsing and converting raw Git output into Git model instances. + */ +@injectable() +export abstract class OutputParser { + + /** This is the `NUL` delimiter. Equals wih `%x00`. */ + static readonly LINE_DELIMITER = '\0'; + + abstract parse(repositoryUri: string, raw: string, delimiter?: string): T[]; + abstract parse(repositoryUri: string, items: string[]): T[]; + abstract parse(repositoryUri: string, input: string | string[], delimiter?: string): T[]; + + protected toUri(repositoryUri: string, pathSegment: string): string { + return FileUri.create(Path.join(FileUri.fsPath(repositoryUri), pathSegment)).toString(); + } + + protected split(input: string | string[], delimiter: string): string[] { + return (Array.isArray(input) ? input : input.split(delimiter)).filter(item => item && item.length > 0); + } + +} + /** * Status parser for converting raw Git `--name-status` output into file change objects. */ @injectable() -export class NameStatusParser { +export class NameStatusParser extends OutputParser { - parse(repositoryUri: string, raw: string, delimiter?: string): GitFileChange[]; - parse(repositoryUri: string, items: string[]): GitFileChange[]; - parse(repositoryUri: string, input: string | string[], delimiter?: string): GitFileChange[] { - const items = Array.isArray(input) ? input : input.split(delimiter === undefined ? '\0' : delimiter); + parse(repositoryUri: string, input: string | string[], delimiter: string = OutputParser.LINE_DELIMITER): GitFileChange[] { + const items = this.split(input, delimiter); const changes: GitFileChange[] = []; let index = 0; while (index < items.length) { @@ -66,8 +87,70 @@ export class NameStatusParser { return changes; } - protected toUri(repositoryUri: string, pathSegment: string): string { - return FileUri.create(Path.join(FileUri.fsPath(repositoryUri), pathSegment)).toString(); +} + +/** + * Built-in Git placeholders for tuning the `--format` option for `git diff` or `git log`. + */ +export enum CommitPlaceholders { + HASH = '%H', + SHORT_HASH = '%h', + AUTHOR_EMAIL = '%aE', + AUTHOR_NAME = '%aN', + AUTHOR_DATE = '%ad', + AUTHOR_RELATIVE_DATE = '%ar', + SUBJECT = '%s' +} + +/** + * Parser for converting raw, Git commit details into `CommitWithChanges` instances. + */ +@injectable() +export class CommitDetailsParser extends OutputParser { + + static readonly ENTRY_DELIMITER = '\x01'; + static readonly COMMIT_CHUNK_DELIMITER = '\x02'; + static readonly DEFAULT_PLACEHOLDERS = [ + CommitPlaceholders.HASH, + CommitPlaceholders.AUTHOR_EMAIL, + CommitPlaceholders.AUTHOR_NAME, + CommitPlaceholders.AUTHOR_DATE, + CommitPlaceholders.AUTHOR_RELATIVE_DATE, + CommitPlaceholders.SUBJECT]; + + @inject(NameStatusParser) + protected readonly nameStatusParser: NameStatusParser; + + parse(repositoryUri: string, input: string | string[], delimiter: string = CommitDetailsParser.COMMIT_CHUNK_DELIMITER): CommitWithChanges[] { + const chunks = this.split(input, delimiter); + const changes: CommitWithChanges[] = []; + for (const chunk of chunks) { + const [sha, email, name, timestamp, authorDateRelative, summary, rawChanges] = chunk.trim().split(CommitDetailsParser.ENTRY_DELIMITER); + const date = this.toDate(timestamp); + const fileChanges = this.nameStatusParser.parse(repositoryUri, (rawChanges || '').trim()); + changes.push({ + sha, + author: { + date, email, name + }, + authorDateRelative, + summary, + fileChanges + }); + } + return changes; + } + + getFormat(...placeholders: CommitPlaceholders[]): string { + return '%x02' + placeholders.join('%x01') + '%x01'; + } + + protected toDate(epochSeconds: string | undefined): Date { + const date = new Date(0); + if (epochSeconds) { + date.setUTCSeconds(Number.parseInt(epochSeconds)); + } + return date; } } @@ -90,6 +173,9 @@ export class DugiteGit implements Git { @inject(NameStatusParser) protected readonly nameStatusParser: NameStatusParser; + @inject(CommitDetailsParser) + protected readonly commitDetailsParser: CommitDetailsParser; + dispose(): void { this.locator.dispose(); } @@ -272,19 +358,41 @@ export class DugiteGit implements Git { } async diff(repository: Repository, options?: Git.Options.Diff): Promise { - const args = [ - 'diff', - '--name-status', - '-C', - '-M', - '-z' - ]; + const args = ['diff', '--name-status', '-C', '-M', '-z']; args.push(this.mapRange((options || {}).range)); if (options && options.uri) { - args.push(...['--', Path.relative(FileUri.fsPath(repository.localUri), FileUri.fsPath(options.uri))]); + args.push(...['--', Path.relative(this.getFsPath(repository), this.getFsPath(options.uri))]); + } + const result = await this.exec(repository, args); + return this.nameStatusParser.parse(repository.localUri, result.stdout.trim()); + } + + async log(repository: Repository, options?: Git.Options.Log): Promise { + // If remaining commits should be calculated by the backend, then run `git rev-list --count ${fromRevision | HEAD~fromRevision}`. + // How to use `mailmap` to map authors: https://www.kernel.org/pub/software/scm/git/docs/git-shortlog.html. + const args = ['log']; + if (options && options.branch) { + args.push(options.branch); + } + const range = this.mapRange((options || {}).range); + args.push(...[range, '-C', '-M', '-m']); + const maxCount = options && options.maxCount ? options.maxCount : 0; + if (Number.isInteger(maxCount) && maxCount > 0) { + args.push(...['-n', `${maxCount}`]); + } + const placeholders: CommitPlaceholders[] = + options && options.shortSha ? + [CommitPlaceholders.SHORT_HASH, ...CommitDetailsParser.DEFAULT_PLACEHOLDERS.slice(1)] : CommitDetailsParser.DEFAULT_PLACEHOLDERS; + args.push(...['--name-status', '--date=unix', `--format=${this.commitDetailsParser.getFormat(...placeholders)}`, '-z', '--']); + if (options && options.uri) { + const file = Path.relative(this.getFsPath(repository), this.getFsPath(options.uri)); + args.push(...[file]); } const result = await this.exec(repository, args); - return this.nameStatusParser.parse(repository.localUri, result.stdout.trim().split('\0').filter(item => item && item.length > 0)); + return this.commitDetailsParser.parse( + repository.localUri, result.stdout.trim() + .split(CommitDetailsParser.COMMIT_CHUNK_DELIMITER) + .filter(item => item && item.length > 0)); } private getCommitish(options?: Git.Options.Show): string { @@ -441,6 +549,8 @@ export class DugiteGit implements Git { range = `${toRevision}~${toMap.fromRevision}..${toRevision}`; } else if (typeof toMap.fromRevision === 'string') { range = `${toMap.fromRevision}${toMap.toRevision ? '..' + toMap.toRevision : ''}`; + } else if (toMap.toRevision) { + range = toMap.toRevision; } } return range; diff --git a/packages/git/src/node/git-backend-module.ts b/packages/git/src/node/git-backend-module.ts index 199f19e351240..e8d01571a5961 100644 --- a/packages/git/src/node/git-backend-module.ts +++ b/packages/git/src/node/git-backend-module.ts @@ -9,7 +9,7 @@ import * as cluster from 'cluster'; import { ContainerModule, Container, interfaces } from 'inversify'; import { Git, GitPath } from '../common/git'; import { GitWatcherPath, GitWatcherClient, GitWatcherServer } from '../common/git-watcher'; -import { DugiteGit, NameStatusParser } from './dugite-git'; +import { DugiteGit, OutputParser, NameStatusParser, CommitDetailsParser } from './dugite-git'; import { DugiteGitWatcherServer } from './dugite-git-watcher'; import { ConnectionHandler, JsonRpcConnectionHandler, ILogger } from "@theia/core/lib/common"; import { GitRepositoryManager } from './git-repository-manager'; @@ -51,7 +51,9 @@ export function bindGit(bind: interfaces.Bind, bindingOptions: GitBindingOptions bind(GitLocator).to(GitLocatorClient); } bind(DugiteGit).toSelf(); + bind(OutputParser).toSelf(); bind(NameStatusParser).toSelf(); + bind(CommitDetailsParser).toSelf(); bind(Git).toDynamicValue(ctx => ctx.container.get(DugiteGit)); }