diff --git a/icons/dark/download.svg b/icons/dark/download.svg new file mode 100644 index 00000000..12ad689a --- /dev/null +++ b/icons/dark/download.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/icons/dark/repo.svg b/icons/dark/repo.svg new file mode 100644 index 00000000..b1f87469 --- /dev/null +++ b/icons/dark/repo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/icons/light/download.svg b/icons/light/download.svg new file mode 100644 index 00000000..f271a008 --- /dev/null +++ b/icons/light/download.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/icons/light/repo.svg b/icons/light/repo.svg new file mode 100644 index 00000000..4e2368ef --- /dev/null +++ b/icons/light/repo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 953e09a4..14710d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -390,7 +390,7 @@ "arr-flatten": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "integrity": "sha1-NgSLv/TntH4TZkQxbJlmnqWukfE=", "dev": true }, "arr-union": { @@ -3073,7 +3073,7 @@ "queue": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/queue/-/queue-4.4.2.tgz", - "integrity": "sha512-fSMRXbwhMwipcDZ08enW2vl+YDmAmhcNcr43sCJL8DIg+CFOsoRLG23ctxA+fwNk1w55SePSiS7oqQQSgQoVJQ==", + "integrity": "sha1-Wpcz2ai4vRs26TS8nFWribKOKcc=", "dev": true, "requires": { "inherits": "2.0.3" @@ -9143,10 +9143,10 @@ "kind-of": "3.2.2" } }, - "source-map": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", - "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha1-db3FiioUls7EihKDW8VMjVYjNt0=", "dev": true, "optional": true, "requires": { diff --git a/package.json b/package.json index 56024cdf..afa8c332 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "tools:genReadme": "node ./out/tools/generateConfigSectionForReadme.js", "precommit": "pretty-quick --staged && npm run lint", "lint": "tslint -p ./", + "lint:fix": "tslint --fix -p ./", "semantic-release": "semantic-release", "build": "tsc -p ." }, @@ -70,6 +71,14 @@ "semantic-release-vsce": "^2.1.2" }, "contributes": { + "views": { + "explorer": [ + { + "id": "svn", + "name": "SVN" + } + ] + }, "commands": [ { "command": "svn.add", @@ -240,6 +249,19 @@ "command": "svn.renameExplorer", "title": "Rename with SVN", "category": "SVN" + }, + { + "command": "svn.treeview.refreshProvider", + "title": "Refresh", + "icon": { + "light": "icons/light/refresh.svg", + "dark": "icons/dark/refresh.svg" + } + }, + { + "command": "svn.treeview.pullIncomingChange", + "title": "Pull incoming change", + "category": "SVN" } ], "menus": { @@ -341,6 +363,31 @@ "when": "false" } ], + "view/title": [ + { + "command": "svn.treeview.refreshProvider", + "when": "view == svn", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "svn.openHEADFile", + "when": "viewItem =~ /incomingChange:(added|modified)/" + }, + { + "command": "svn.openFile", + "when": "viewItem =~ /incomingChange:(modified|deleted)/" + }, + { + "command": "svn.treeview.pullIncomingChange", + "when": "viewItem =~ /incomingChange:*/" + }, + { + "command": "svn.openChangeHead", + "when": "viewItem == incomingChange:modified" + } + ], "scm/title": [ { "command": "svn.commitWithMessage", diff --git a/src/commands.ts b/src/commands.ts index 7f0fcb65..dfa0d950 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -35,6 +35,8 @@ import { inputCommitMessage } from "./messages"; import { Model } from "./model"; import { Repository } from "./repository"; import { Resource } from "./resource"; +import IncommingChangeNode from "./treeView/nodes/incomingChangeNode"; +import IncomingChangeNode from "./treeView/nodes/incomingChangeNode"; import { fromSvnUri, toSvnUri } from "./uri"; import { fixPathSeparator, @@ -367,7 +369,7 @@ export class SvnCommands implements IDisposable { @command("svn.openFile") public async openFile( - arg?: Resource | Uri, + arg?: Resource | Uri | IncommingChangeNode, ...resourceStates: SourceControlResourceState[] ): Promise { const preserveFocus = arg instanceof Resource; @@ -380,6 +382,16 @@ export class SvnCommands implements IDisposable { } else if (arg.scheme === "file") { uris = [arg]; } + } else if (arg instanceof IncommingChangeNode) { + const resource = new Resource( + arg.uri, + arg.type, + undefined, + arg.props, + true + ); + + uris = [resource.resourceUri]; } else { let resource = arg; @@ -426,13 +438,17 @@ export class SvnCommands implements IDisposable { } @command("svn.openHEADFile") - public async openHEADFile(arg?: Resource | Uri): Promise { + public async openHEADFile( + arg?: Resource | Uri | IncommingChangeNode + ): Promise { let resource: Resource | undefined; if (arg instanceof Resource) { resource = arg; } else if (arg instanceof Uri) { resource = this.getSCMResource(arg); + } else if (arg instanceof IncommingChangeNode) { + resource = new Resource(arg.uri, arg.type, undefined, arg.props, true); } else { resource = this.getSCMResource(); } @@ -472,14 +488,14 @@ export class SvnCommands implements IDisposable { @command("svn.openChangeHead") public async openChangeHead( - arg?: Resource | Uri, + arg?: Resource | Uri | IncommingChangeNode, ...resourceStates: SourceControlResourceState[] ): Promise { return this.openChange(arg, "HEAD", resourceStates); } public async openChange( - arg?: Resource | Uri, + arg?: Resource | Uri | IncommingChangeNode, against?: string, resourceStates?: SourceControlResourceState[] ): Promise { @@ -492,6 +508,16 @@ export class SvnCommands implements IDisposable { if (resource !== undefined) { resources = [resource]; } + } else if (arg instanceof IncommingChangeNode) { + const resource = new Resource( + arg.uri, + arg.type, + undefined, + arg.props, + true + ); + + resources = [resource]; } else { let resource: Resource | undefined; @@ -757,6 +783,29 @@ export class SvnCommands implements IDisposable { } } + @command("svn.treeview.pullIncomingChange") + public async pullIncomingChange( + incomingChange: IncomingChangeNode + ): Promise { + try { + const showUpdateMessage = configuration.get( + "showUpdateMessage", + true + ); + + const result = await incomingChange.repository.pullIncomingChange( + incomingChange.uri.fsPath + ); + + if (showUpdateMessage) { + window.showInformationMessage(result); + } + } catch (error) { + console.error(error); + window.showErrorMessage("Unable to update"); + } + } + private async showDiffPath(repository: Repository, content: string) { try { const tempFile = path.join(repository.root, ".svn", "tmp", "svn.patch"); diff --git a/src/extension.ts b/src/extension.ts index 028208fd..4bd07a1c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,8 @@ import { ExtensionContext, OutputChannel, Uri, - window + window, + workspace } from "vscode"; import { SvnCommands } from "./commands"; import SvnDecorations from "./decorations/svnDecorations"; @@ -13,6 +14,7 @@ import { Model } from "./model"; import { Svn } from "./svn"; import { SvnContentProvider } from "./svnContentProvider"; import { SvnFinder } from "./svnFinder"; +import SvnProvider from "./treeView/dataProviders/svnProvider"; import { hasSupportToDecorationProvider, hasSupportToRegisterDiffCommand, @@ -36,6 +38,10 @@ async function init( const svnCommands = new SvnCommands(model); disposables.push(model, contentProvider, svnCommands); + const svnProvider = new SvnProvider(model); + + window.registerTreeDataProvider("svn", svnProvider); + // First, check the vscode has support to DecorationProvider if (hasSupportToDecorationProvider()) { const decoration = new SvnDecorations(model); diff --git a/src/repository.ts b/src/repository.ts index 3b3eaff0..309f10f7 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -58,7 +58,7 @@ export class Repository { public statusBar: SvnStatusBar; public changes: ISvnResourceGroup; public unversioned: ISvnResourceGroup; - public remoteChanged?: ISvnResourceGroup; + public remoteChanges?: Resource[]; public changelists: Map = new Map(); public conflicts: ISvnResourceGroup; public statusIgnored: IFileStatus[] = []; @@ -122,8 +122,8 @@ export class Repository { group.resourceStates = []; }); - if (this.remoteChanged) { - this.remoteChanged.dispose(); + if (this.remoteChanges) { + this.remoteChanges = []; } this.isIncomplete = false; @@ -321,7 +321,7 @@ export class Repository { const external: any[] = []; const conflicts: any[] = []; const changelists: Map = new Map(); - const remoteChanged: any[] = []; + const remoteChanges: any[] = []; this.statusExternal = []; this.statusIgnored = []; @@ -406,7 +406,7 @@ export class Repository { : undefined; if (status.reposStatus) { - remoteChanged.push( + remoteChanges.push( new Resource( uri, status.reposStatus.item, @@ -515,18 +515,14 @@ export class Repository { /** * Destroy and create for keep at last position */ - if (this.remoteChanged) { - this.remoteChanged.dispose(); + if (this.remoteChanges) { + this.remoteChanges = []; } - this.remoteChanged = this.sourceControl.createResourceGroup( - "remotechanged", - "Remote Changes" - ) as ISvnResourceGroup; - this.remoteChanged.hideWhenEmpty = true; - this.remoteChanged.resourceStates = remoteChanged; - - if (remoteChanged.length !== this.remoteChangedFiles) { - this.remoteChangedFiles = remoteChanged.length; + + this.remoteChanges = remoteChanges; + + if (remoteChanges.length !== this.remoteChangedFiles) { + this.remoteChangedFiles = remoteChanges.length; this._onDidChangeRemoteChangedFiles.fire(); } } @@ -644,6 +640,14 @@ export class Repository { }); } + public async pullIncomingChange(path: string) { + return this.run(Operation.Update, async () => { + const response = await this.repository.pullIncomingChange(path); + this.updateRemoteChangedFiles(); + return response; + }); + } + public async resolve(files: string[], action: string) { return this.run(Operation.Resolve, () => this.repository.resolve(files, action) diff --git a/src/svn.ts b/src/svn.ts index 82792bd7..0f1b9265 100644 --- a/src/svn.ts +++ b/src/svn.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "events"; import * as iconv from "iconv-lite"; import isUtf8 = require("is-utf8"); import * as jschardet from "jschardet"; -import { workspace } from "vscode"; +import { Uri, workspace } from "vscode"; import { ICpOptions, IExecutionResult, ISvnOptions } from "./common/types"; import { configuration } from "./helpers/configuration"; import { parseInfoXml } from "./infoParser"; diff --git a/src/svnRepository.ts b/src/svnRepository.ts index 0af2ea6c..30077fa1 100644 --- a/src/svnRepository.ts +++ b/src/svnRepository.ts @@ -366,6 +366,24 @@ export class Repository { return result.stdout; } + public async pullIncomingChange(path: string): Promise { + const args = ["update", path]; + + const result = await this.exec(args); + + this.resetInfo(); + + const message = result.stdout + .trim() + .split(/\r?\n/) + .pop(); + + if (message) { + return message; + } + return result.stdout; + } + public async patch(files: string[]) { files = files.map(file => this.removeAbsolutePath(file)); const result = await this.exec(["diff", ...files]); diff --git a/src/treeView/dataProviders/svnProvider.ts b/src/treeView/dataProviders/svnProvider.ts new file mode 100644 index 00000000..fccb27a3 --- /dev/null +++ b/src/treeView/dataProviders/svnProvider.ts @@ -0,0 +1,54 @@ +import { + commands, + Event, + EventEmitter, + TreeDataProvider, + TreeItem, + window +} from "vscode"; +import { Model } from "../../model"; +import BaseNode from "../nodes/baseNode"; +import RepositoryNode from "../nodes/repositoryNode"; + +export default class SvnProvider implements TreeDataProvider { + private _onDidChangeTreeData: EventEmitter< + BaseNode | undefined + > = new EventEmitter(); + public onDidChangeTreeData: Event = this + ._onDidChangeTreeData.event; + + constructor(private model: Model) { + commands.registerCommand("svn.treeview.refreshProvider", () => + this.refresh() + ); + } + + public refresh(): void { + this._onDidChangeTreeData.fire(); + } + + public getTreeItem(element: RepositoryNode): TreeItem { + return element.getTreeItem(); + } + + public async getChildren(element?: BaseNode): Promise { + if (!this.model || this.model.openRepositories.length === 0) { + window.showInformationMessage("No Svn repositories open"); + return Promise.resolve([]); + } + + if (element) { + return element.getChildren(); + } + + const repositories = this.model.openRepositories.map(repository => { + return new RepositoryNode(repository.repository, this); + }); + + return repositories; + } + + public update(node: BaseNode): void { + this._onDidChangeTreeData.fire(node); + } +} diff --git a/src/treeView/nodes/baseNode.ts b/src/treeView/nodes/baseNode.ts new file mode 100644 index 00000000..cfad9190 --- /dev/null +++ b/src/treeView/nodes/baseNode.ts @@ -0,0 +1,6 @@ +import { TreeItem } from "vscode"; + +export default abstract class BaseNode { + public abstract getChildren(): BaseNode[] | Promise; + public abstract getTreeItem(): TreeItem | Promise; +} diff --git a/src/treeView/nodes/incomingChangeNode.ts b/src/treeView/nodes/incomingChangeNode.ts new file mode 100644 index 00000000..57bfb4ee --- /dev/null +++ b/src/treeView/nodes/incomingChangeNode.ts @@ -0,0 +1,41 @@ +import * as fs from "fs"; +import * as path from "path"; +import { TreeItem, TreeItemCollapsibleState, Uri } from "vscode"; +import { Repository } from "../../repository"; +import { getIconUri } from "../../uri"; +import BaseNode from "./baseNode"; + +export default class IncomingChangeNode implements BaseNode { + constructor( + public uri: Uri, + public type: string, + public repository: Repository + ) {} + + get props(): undefined { + return undefined; + } + + get label() { + return path.relative(this.repository.workspaceRoot, this.uri.fsPath); + } + + get contextValue() { + return `incomingChange:${this.type}`; + } + + public getTreeItem(): TreeItem { + const item = new TreeItem(this.label, TreeItemCollapsibleState.None); + item.iconPath = { + dark: getIconUri(`status-${this.type}`, "dark"), + light: getIconUri(`status-${this.type}`, "light") + }; + item.contextValue = this.contextValue; + + return item; + } + + public getChildren(): Promise { + return Promise.resolve([]); + } +} diff --git a/src/treeView/nodes/incomingChangesNode.ts b/src/treeView/nodes/incomingChangesNode.ts new file mode 100644 index 00000000..98871017 --- /dev/null +++ b/src/treeView/nodes/incomingChangesNode.ts @@ -0,0 +1,43 @@ +import { TreeItem, TreeItemCollapsibleState } from "vscode"; +import { Repository } from "../../repository"; +import { getIconUri } from "../../uri"; +import BaseNode from "./baseNode"; +import IncommingChangeNode from "./incomingChangeNode"; +import NoIncomingChangesNode from "./noIncomingChangesNode"; + +export default class IncomingChangesNode implements BaseNode { + constructor(private repository: Repository) {} + + public getTreeItem(): TreeItem { + const item = new TreeItem( + "Incoming Changes", + TreeItemCollapsibleState.Collapsed + ); + item.iconPath = { + dark: getIconUri("download", "dark"), + light: getIconUri("download", "light") + }; + + return item; + } + + public async getChildren(): Promise { + if (!this.repository.remoteChanges) { + return []; + } + + const changes = this.repository.remoteChanges.map(remoteChange => { + return new IncommingChangeNode( + remoteChange.resourceUri, + remoteChange.type, + this.repository + ); + }); + + if (changes.length === 0) { + return [new NoIncomingChangesNode()]; + } + + return changes; + } +} diff --git a/src/treeView/nodes/noIncomingChangesNode.ts b/src/treeView/nodes/noIncomingChangesNode.ts new file mode 100644 index 00000000..5d365033 --- /dev/null +++ b/src/treeView/nodes/noIncomingChangesNode.ts @@ -0,0 +1,17 @@ +import { TreeItem, TreeItemCollapsibleState } from "vscode"; +import BaseNode from "./baseNode"; + +export default class NoIncomingChangesNode implements BaseNode { + public getTreeItem(): TreeItem { + const item = new TreeItem( + "No Incoming Changes", + TreeItemCollapsibleState.None + ); + + return item; + } + + public async getChildren(): Promise { + return []; + } +} diff --git a/src/treeView/nodes/repositoryNode.ts b/src/treeView/nodes/repositoryNode.ts new file mode 100644 index 00000000..94a5b7ef --- /dev/null +++ b/src/treeView/nodes/repositoryNode.ts @@ -0,0 +1,36 @@ +import * as path from "path"; +import { TreeItem, TreeItemCollapsibleState, window } from "vscode"; +import { Repository } from "../../repository"; +import { getIconUri } from "../../uri"; +import SvnProvider from "../dataProviders/svnProvider"; +import BaseNode from "./baseNode"; +import IncomingChangesNode from "./incomingChangesNode"; + +export default class RepositoryNode implements BaseNode { + constructor( + private repository: Repository, + private svnProvider: SvnProvider + ) { + repository.onDidChangeStatus(() => { + svnProvider.update(this); + }); + } + + get label() { + return path.basename(this.repository.workspaceRoot); + } + + public getTreeItem(): TreeItem { + const item = new TreeItem(this.label, TreeItemCollapsibleState.Collapsed); + item.iconPath = { + dark: getIconUri("repo", "dark"), + light: getIconUri("repo", "light") + }; + + return item; + } + + public async getChildren(): Promise { + return [new IncomingChangesNode(this.repository)]; + } +} diff --git a/src/uri.ts b/src/uri.ts index 626145e2..91800723 100644 --- a/src/uri.ts +++ b/src/uri.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { Uri } from "vscode"; import { ISvnUriExtraParams, @@ -27,3 +28,8 @@ export function toSvnUri( query: JSON.stringify(params) }); } + +export function getIconUri(iconName: string, theme: string): Uri { + const iconsRootPath = path.join(__dirname, "..", "icons"); + return Uri.file(path.join(iconsRootPath, theme, `${iconName}.svg`)); +}