From 75de03ae687194a4f3a90d9a4f4f25c2596faf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Fri, 9 Apr 2021 20:13:57 +0200 Subject: [PATCH 1/4] Add fetchHistory* to ObsFetchers & registerWebviewViewProvider to vscodeWindow --- src/dependency-injection.ts | 8 ++++++++ src/test/suite/test-utils.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/dependency-injection.ts b/src/dependency-injection.ts index 9767295..bb96293 100644 --- a/src/dependency-injection.ts +++ b/src/dependency-injection.ts @@ -25,6 +25,8 @@ import { checkOutPackage, checkOutProject, fetchFileContents, + fetchHistory, + fetchHistoryAcrossLinks, fetchPackage, fetchProject, fetchServerCaCertificate, @@ -53,6 +55,8 @@ export interface VscodeWindow { activeTextEditor: typeof vscode.window.activeTextEditor; visibleTextEditors: typeof vscode.window.visibleTextEditors; + + registerWebviewViewProvider: typeof vscode.window.registerWebviewViewProvider; } export interface VscodeWorkspace { @@ -67,6 +71,8 @@ export interface ObsFetchers { readonly fetchFileContents: typeof fetchFileContents; readonly fetchPackage: typeof fetchPackage; readonly fetchProject: typeof fetchProject; + readonly fetchHistory: typeof fetchHistory; + readonly fetchHistoryAcrossLinks: typeof fetchHistoryAcrossLinks; readonly branchPackage: typeof branchPackage; readonly readInUnifiedPackage: typeof readInUnifiedPackage; readonly submitPackage: typeof submitPackage; @@ -81,6 +87,8 @@ export const DEFAULT_OBS_FETCHERS: ObsFetchers = { fetchProject, fetchFileContents, fetchPackage, + fetchHistory, + fetchHistoryAcrossLinks, branchPackage, readInUnifiedPackage, submitPackage, diff --git a/src/test/suite/test-utils.ts b/src/test/suite/test-utils.ts index 6bf693e..c3210e3 100644 --- a/src/test/suite/test-utils.ts +++ b/src/test/suite/test-utils.ts @@ -57,7 +57,8 @@ export const createStubbedVscodeWindow = (sandbox: SinonSandbox) => { onDidChangeActiveTextEditorEmiter: emiter, onDidChangeActiveTextEditor: emiter.event, activeTextEditor: undefined as vscode.TextEditor | undefined, - visibleTextEditors: [] as vscode.TextEditor[] + visibleTextEditors: [] as vscode.TextEditor[], + registerWebviewViewProvider: sandbox.stub() }; }; @@ -66,6 +67,8 @@ export const createStubbedObsFetchers = (sandbox: SinonSandbox) => ({ fetchFileContents: sandbox.stub(), fetchPackage: sandbox.stub(), fetchProject: sandbox.stub(), + fetchHistory: sandbox.stub(), + fetchHistoryAcrossLinks: sandbox.stub(), readInUnifiedPackage: sandbox.stub(), submitPackage: sandbox.stub(), checkConnection: sandbox.stub(), From 91d6010c8c3157849cd67d906df493f68647be86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Fri, 9 Apr 2021 20:14:45 +0200 Subject: [PATCH 2/4] [tests] Add helper function to generate a fake WebviewView --- src/test/suite/fakes.ts | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/test/suite/fakes.ts b/src/test/suite/fakes.ts index d6e0d21..c3457e3 100644 --- a/src/test/suite/fakes.ts +++ b/src/test/suite/fakes.ts @@ -389,3 +389,55 @@ export class FakeVscodeWorkspace { this.createFileSystemWatcherSpy = spy(this, "createFileSystemWatcher"); } } + +export const createFakeWebviewView = ( + sandox: SinonSandbox, + { + viewType = "fakeWebview", + title, + description, + visible, + cspSource, + webviewOptions + }: { + viewType?: string; + title?: string; + description?: string; + visible?: boolean; + cspSource?: string; + webviewOptions?: vscode.WebviewOptions; + } = {} +): { + webviewView: vscode.WebviewView; + emiters: { + onDidChangeVisibility: FakeEventEmitter; + onDidDispose: FakeEventEmitter; + onDidReceiveMessage: FakeEventEmitter; + }; +} => { + const emiters = { + onDidChangeVisibility: makeFakeEventEmitter(), + onDidDispose: makeFakeEventEmitter(), + onDidReceiveMessage: makeFakeEventEmitter() + }; + return { + emiters, + webviewView: { + viewType: viewType ?? "fakeWebView", + onDidChangeVisibility: emiters.onDidChangeVisibility.event, + onDidDispose: emiters.onDidDispose.event, + title, + description, + visible: visible ?? true, + show: sandox.stub(), + webview: { + cspSource: cspSource ?? "cspFooBar", + asWebviewUri: (uri: vscode.Uri): vscode.Uri => uri, + postMessage: sandox.stub(), + onDidReceiveMessage: emiters.onDidReceiveMessage.event, + html: "", + options: webviewOptions ?? {} + } + } + }; +}; From 25a754ef3ce34a90c137364fa089a1d3ef7afdf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Thu, 8 Apr 2021 17:51:05 +0200 Subject: [PATCH 3/4] Add experimental history graph across branches --- .eslintrc.js | 2 +- .gitignore | 2 + .vscodeignore | 1 + package.json | 7 +- src/extension.ts | 31 +-- src/frontend/draw-graph.ts | 240 ++++++++++++++++++++++ src/history-graph-common.ts | 182 +++++++++++++++++ src/history-graph.ts | 292 +++++++++++++++++++++++++++ src/test/suite/history-graph.test.ts | 250 +++++++++++++++++++++++ tsconfig.base.json | 20 ++ tsconfig.frontend.json | 20 ++ tsconfig.json | 10 +- webpack.config.js | 57 ++++-- yarn.lock | 12 ++ 14 files changed, 1079 insertions(+), 47 deletions(-) create mode 100644 src/frontend/draw-graph.ts create mode 100644 src/history-graph-common.ts create mode 100644 src/history-graph.ts create mode 100644 src/test/suite/history-graph.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.frontend.json diff --git a/.eslintrc.js b/.eslintrc.js index cd4f8f0..8d20ad0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,7 +3,7 @@ module.exports = { parser: "@typescript-eslint/parser", parserOptions: { tsconfigRootDir: __dirname, - project: ["./tsconfig.json"] + project: ["./tsconfig.json", "./tsconfig.frontend.json"] }, plugins: ["@typescript-eslint"], rules: { diff --git a/.gitignore b/.gitignore index 5a09481..3e1b62d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ src/ui-tests/accounts/settings.json dist/* test-home/* test-tmp/* +media/html/* +frontend_out/* media/dark/endpoints_disconnected.svg media/light/endpoints_disconnected.svg diff --git a/.vscodeignore b/.vscodeignore index 6f2856f..8ade0d7 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -40,3 +40,4 @@ test-home/** scripts/** generate_icons.sh .dir-locals.el +frontend_out/** diff --git a/package.json b/package.json index 9dce23c..9e9f71d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "keywords": ["open-build-service", "obs", "buildservice"], "dependencies": { + "@gitgraph/js": "^1.4.0", "@vscode-logging/logger": "^1.2", "config-ini-parser": "^1.3", "keytar": "^7", @@ -51,7 +52,7 @@ "webpack-dev": "webpack --mode development --watch", "test-compile": "tsc -p ./", "cleandeps": "rm -rf node_modules/", - "clean": "rm -rf ./out ./coverage *vsix ./nyc_output ./documentation ./test-resources/ ./mocklibsecret/build/ ./.vscode-test/ ./test-home/ ./.log/ ./dist ./src/ui-tests/default/fakeHome/ ./src/ui-tests/accounts/fakeHome/", + "clean": "rm -rf ./out ./coverage *vsix ./nyc_output ./documentation ./test-resources/ ./mocklibsecret/build/ ./.vscode-test/ ./test-home/ ./.log/ ./dist ./src/ui-tests/default/fakeHome/ ./src/ui-tests/accounts/fakeHome/ ./frontend_out ./media/html/", "coverage": "COVERAGE=1 ./runTests.sh && echo \"COVERAGE: $(cat coverage/coverage-summary.json | jq .total.lines.pct) %\"", "test:ui": "./runUiTests.sh", "mocklibsecret": "[ -e ./mocklibsecret/build/libsecret.so ] || (cd mocklibsecret && meson build && meson compile -C build)", @@ -60,7 +61,7 @@ "generate_icons": "./generate_icons.sh", "pretest": "yarn run compile", "watch": "tsc -watch -p ./", - "compile": "tsc -p ./", + "compile": "tsc -p ./tsconfig.json && tsc -p ./tsconfig.frontend.json", "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "format": "prettier --write \"src/**/*.ts\"", "vscode:prepublish": "webpack --mode $([ \"${EXTENSION_DEBUG}\" = 1 ] && echo \"development\" || echo \"production\")" @@ -413,6 +414,7 @@ ], "scm": [ { + "type": "webview", "name": "Package History", "id": "packageScmHistoryTree" } @@ -586,6 +588,7 @@ "onView:bookmarkedProjectsTree", "onView:currentProjectTree", "onView:repositoryTree", + "onView:packageScmHistoryTree", "workspaceContains:**/.osc", "workspaceContains:**/.osc_obs_ts" ], diff --git a/src/extension.ts b/src/extension.ts index b1e1269..a1730c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,13 +28,13 @@ import { CheckOutHandler } from "./check-out-handler"; import { CurrentPackageWatcherImpl } from "./current-package-watcher"; import { CurrentProjectTreeProvider } from "./current-project-view"; import { EmptyDocumentForDiffProvider } from "./empty-file-provider"; +import { HistoryGraph } from "./history-graph"; import { ObsServerInformation } from "./instance-info"; import { setupLogger } from "./logging"; import { OscBuildTaskProvider } from "./osc-build-task"; import { RemotePackageFileContentProvider } from "./package-file-contents"; import { ProjectBookmarkManager } from "./project-bookmarks"; import { RepositoryTreeProvider } from "./repository"; -import { PackageScmHistoryTree } from "./scm-history"; import { PackageScm } from "./vcs"; export async function activate( @@ -90,25 +90,10 @@ export async function activate( showCollapseAll, treeDataProvider: repoTreeProvider }); - const [ - packageScmHistoryTreeProvider, - oscBuildTaskProvider - ] = await Promise.all([ - PackageScmHistoryTree.createPackageScmHistoryTree( - currentPackageWatcher, - accountManager, - logger - ), - OscBuildTaskProvider.createOscBuildTaskProvider( - currentPackageWatcher, - accountManager, - logger - ) - ]); - - const packageScmHistoryTree = vscode.window.createTreeView( - "packageScmHistoryTree", - { showCollapseAll, treeDataProvider: packageScmHistoryTreeProvider } + const oscBuildTaskProvider = await OscBuildTaskProvider.createOscBuildTaskProvider( + currentPackageWatcher, + accountManager, + logger ); const pkgFileProv = new RemotePackageFileContentProvider( @@ -124,7 +109,11 @@ export async function activate( pkgFileProv, currentPackageWatcher, new PackageScm(currentPackageWatcher, accountManager, logger), - packageScmHistoryTree, + HistoryGraph.createHistoryGraph( + currentPackageWatcher, + accountManager, + logger + ), new ObsServerInformation(accountManager, logger), new EmptyDocumentForDiffProvider(), new CheckOutHandler(accountManager, logger), diff --git a/src/frontend/draw-graph.ts b/src/frontend/draw-graph.ts new file mode 100644 index 0000000..93673fd --- /dev/null +++ b/src/frontend/draw-graph.ts @@ -0,0 +1,240 @@ +/** + * Copyright (c) 2021 SUSE LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import * as GitgraphJS from "@gitgraph/js"; +import { + commitKeyToBranchName, + commitWithChildrenFromJson, + getBranchName, + MessageType, + ReceivedHistoryReceivedMsg, + SendHistoryReceivedMsg, + StartFetchingHistoryMsg +} from "../history-graph-common"; + +interface Message { + type: MessageType; + payload?: any; +} + +interface VSCode { + postMessage(message: T): void; + getState(): any; + setState(state: any): void; +} + +declare function acquireVsCodeApi(): VSCode; + +const getGraphTemplate = (): ReturnType => { + const graphContainerCss = window.getComputedStyle( + document.getElementById("graph-container")! + ); + const font = graphContainerCss.font; + const fontSize = parseInt(graphContainerCss.fontSize.replace("px", "")); + + const [foregroundColor, backgroundColor] = [ + "foreground", + "background" + ].map((color) => + graphContainerCss.getPropertyValue(`--vscode-editor-${color}`) + ); + const lineWidth = Math.ceil(fontSize / 10); + const commitSpacing = 3 * fontSize; + const dotSize = Math.ceil(fontSize * 0.75); + const branchSpacing = 2 * fontSize; + const arrowSize = Math.ceil(fontSize * 0.5); + + return GitgraphJS.templateExtend(GitgraphJS.TemplateName.Metro, { + arrow: { size: arrowSize, color: foregroundColor }, + branch: { + label: { + font, + strokeColor: foregroundColor, + bgColor: backgroundColor + }, + lineWidth, + spacing: branchSpacing, + color: foregroundColor + }, + commit: { + spacing: commitSpacing, + dot: { + size: dotSize, + strokeWidth: lineWidth, + strokeColor: foregroundColor, + font + }, + message: { + // we add the author & hash ourselves, as we cannot make gitgraph.js + // omit the email + displayHash: false, + displayAuthor: false, + font, + color: foregroundColor + } + } + }); +}; + +const drawSequentialHistoryGraph = (data: SendHistoryReceivedMsg): void => { + const branchMap = new Map(); + + const graphContainer = document.getElementById("graph-container")!; + graphContainer.innerHTML = ""; + + const template = getGraphTemplate(); + const graph = GitgraphJS.createGitgraph(graphContainer, { + template + }); + graph.clear(); + + data.parentlessCommits.forEach((c) => { + const branchName = getBranchName(c); + branchMap.set(branchName, graph.branch(branchName)); + }); + + const sortedCommits = data.commitMapInitializer + .map(([, commit]) => commit) + .sort((c1, c2) => + c1.commitTime.getTime() < c2.commitTime.getTime() + ? -1 + : c1.commitTime.getTime() > c2.commitTime.getTime() + ? 1 + : 0 + ); + + for (const commit of sortedCommits) { + const commitOpts = { + // We create the commit message ourselves here completely using the hash, + // commitMessage and userId not relying on gitgraph.js + // The issue with gitgraph.js is that it will use the hash to uniquely + // identify commits (the revisionHash is not guaranteed to be unique + // across branches though, so thereby we'd get two commits inside one) + // Furthermore, we do not have the user's email addresses at this point + // (and don't really want to fetch them), and as we cannot tell gitgraphjs + // to *not* include the email, we just append the userId as well + subject: [`r${commit.revision}`, commit.commitMessage, commit.userId] + .filter((s) => s !== undefined && s !== "") + .join(" - ") + }; + + if (commit.parentCommits.length > 1) { + const branchNames = commit.parentCommits.map((parentKey) => + commitKeyToBranchName(parentKey) + ); + + const branches = branchNames + .map((branchName) => branchMap.get(branchName)) + .filter((b) => b !== undefined) as GitgraphJS.Branch[]; + branches.slice(1).map((b) => { + branches[0].merge({ branch: b, commitOptions: commitOpts }); + }); + } else { + const branchName = getBranchName(commit); + let branch = branchMap.get(branchName); + if (branch === undefined) { + branch = graph.branch(branchName); + branchMap.set(branchName, branch); + } + + branch.commit(commitOpts); + } + } +}; + +function isReceivedHistoryReceivedMsg( + msg: any +): msg is ReceivedHistoryReceivedMsg { + return ( + msg !== undefined && + msg.commitMapInitializer !== undefined && + msg.parentlessCommits !== undefined && + msg.type === MessageType.HistoryReceived + ); +} + +function convertMessagePayload(msg: any): SendHistoryReceivedMsg | undefined { + if (!isReceivedHistoryReceivedMsg(msg)) { + return undefined; + } + + const { commitMapInitializer, parentlessCommits, ...rest } = msg; + return { + ...rest, + parentlessCommits: parentlessCommits.map((c) => + commitWithChildrenFromJson(c) + ), + commitMapInitializer: commitMapInitializer.map(([k, commit]) => [ + k, + commitWithChildrenFromJson(commit) + ]) + }; +} + +function main(): void { + const vscode = acquireVsCodeApi(); + + const redrawGraph = (): void => { + const oldState = convertMessagePayload(vscode.getState()); + if (oldState !== undefined) { + drawSequentialHistoryGraph(oldState); + } + }; + redrawGraph(); + + let oldTheme = document.body.className; + const observer = new MutationObserver(() => { + if (document.body.className !== oldTheme) { + oldTheme = document.body.className; + redrawGraph(); + } + }); + observer.observe(document.body, { attributes: true }); + + window.addEventListener( + "message", + ( + event: MessageEvent + ) => { + const graphContainer = document.getElementById("graph-container")!; + switch (event.data.type) { + case MessageType.StartFetch: + graphContainer.innerHTML = `Fetching history of ${event.data.projectName}/${event.data.name}`; + break; + + case MessageType.HistoryReceived: { + const data = convertMessagePayload(event.data); + if (data === undefined) { + break; + } + drawSequentialHistoryGraph(data); + vscode.setState(data); + break; + } + + default: + console.error("Received an invalid message: ", event.data); + } + } + ); +} + +main(); diff --git a/src/history-graph-common.ts b/src/history-graph-common.ts new file mode 100644 index 0000000..9b5dc7e --- /dev/null +++ b/src/history-graph-common.ts @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2021 SUSE LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { Commit } from "open-build-service-api"; + +/** Message types that the history graph webview will react to */ +export const enum MessageType { + /** The retrieval of the history of a package has been started */ + StartFetch = "start_fetch", + /** The history has been fetched */ + HistoryReceived = "history_received" +} + +/** A message indicating that the history of a new package is being fetched */ +export interface StartFetchingHistoryMsg { + type: MessageType.StartFetch; + /** The name of the project to which the package belongs */ + projectName: string; + /** The name of the package */ + name: string; +} + +/** + * A message containing the retrieved history. + * + * The actual commits are stored in a hash table where each commit can be found + * via its key as generated by [[getCommitKey]]. Since we cannot send a hash + * table over the wire, we dump its entries into an array of tuples and send it + * as [[commitMapInitializer]]. + * The "start" of the history is recorded in the [[parentlessCommits]] field: it + * contains all commits, that don't have a preceding commit (= parent commit). + * + * @tparam T + */ +export interface HistoryReceivedMsg< + T extends JsonDumpedCommitWithChildren | CommitWithChildren +> { + readonly type: MessageType.HistoryReceived; + + /** + * Array of tuples consisting of a key and a json dumped + * [[CommitWithChildren]] that can be used to construct a hash table + * `key => commit`. + */ + readonly commitMapInitializer: readonly (readonly [string, T])[]; + + /** + * Array of commits that have no parents (i.e. they are the first commit in a + * branch=project+package combination). + */ + readonly parentlessCommits: T[]; +} + +export type SendHistoryReceivedMsg = HistoryReceivedMsg; +export type ReceivedHistoryReceivedMsg = HistoryReceivedMsg; + +/** + * A commit which `parentCommits` attribute does not contain another Commit + * object, but only the commit's key (created by [[getCommitKey]]). This makes + * it easier to send it to the webview as it needs to be serialized to JSON. + */ +export interface CommitWithHashes extends Omit { + /** + * The keys of the parent commits. + * The values are generated via [[getCommitKey]]. + */ + readonly parentCommits: string[]; +} + +/** + * A commit which also contains the commits that come after it (called its child + * commits). + */ +export interface CommitWithChildren extends CommitWithHashes { + /** + * The keys of child commits. + * The values are generated via [[getCommitKey]]. + */ + readonly childCommits: string[]; +} + +/** Resulting type of JSON serializing a [[CommitWithChildren]] */ +export interface JsonDumpedCommitWithChildren + extends Omit { + readonly commitTime: string; +} + +/** + * Converts a JSON serialized [[CommitWithChildren]] into a + * [[CommitWithChildren]]. + */ +export function commitWithChildrenFromJson( + commit: JsonDumpedCommitWithChildren +): CommitWithChildren { + const { commitTime, ...rest } = commit; + return { ...rest, commitTime: new Date(commitTime) }; +} + +/** Type guard for the [[CommitWithChildren]] type */ +export function isCommitWithChildren( + commit: Commit | CommitWithHashes | CommitWithChildren +): commit is CommitWithChildren { + return Array.isArray((commit as CommitWithChildren).childCommits); // && ((arr.length > 0) ^ (typeof arr[0] === "string")); +} + +/** Type guard for the [[CommitWithHashes]] type */ +export function isCommitWithHashes( + commit: Commit | CommitWithHashes | CommitWithChildren +): commit is CommitWithHashes { + if (isCommitWithChildren(commit)) { + return false; + } + return ( + commit.parentCommits !== undefined && + typeof commit.parentCommits[0] === "string" + ); +} + +/** + * Converts a [[Commit]] into a [[CommitWithHashes]] and leaves a + * [[CommitWithHashes]] and [[CommitWithChildren]] untouched. + */ +export function commitToCommitWithHashes( + commit: Commit | CommitWithHashes | CommitWithChildren +): CommitWithHashes | CommitWithChildren { + if (isCommitWithHashes(commit) || isCommitWithChildren(commit)) { + return commit; + } + const { parentCommits, ...rest } = commit; + if (parentCommits === undefined) { + return { ...rest, parentCommits: [] }; + } else { + return { + ...rest, + parentCommits: parentCommits.map((c) => getCommitKey(c)) + }; + } +} + +/** + * Generates a unique key for a commit to retrieve it from a hash table. + * + * Note that we cannot use the revisionHash only, because it can be the same + * value across branches. + */ +export const getCommitKey = ( + commit: Commit | CommitWithHashes | CommitWithChildren +): string => + `${commit.projectName}/${commit.packageName}@${commit.revisionHash}`; + +/** + * Returns a "branch" name for a package on OBS. + * The branch name is used for displaying the log + */ +export const getBranchName = ( + commit: Commit | CommitWithHashes | CommitWithChildren +): string => `${commit.projectName}/${commit.packageName}`; + +/** + * Converts a commit key generated by [[getCommitKey]] to a branch name as it + * would be generated by [[getBranchName]]. + */ +export const commitKeyToBranchName = (commitKey: string): string => + commitKey.split("@")[0]; diff --git a/src/history-graph.ts b/src/history-graph.ts new file mode 100644 index 0000000..b045638 --- /dev/null +++ b/src/history-graph.ts @@ -0,0 +1,292 @@ +/** + * Copyright (c) 2021 SUSE LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { IVSCodeExtLogger } from "@vscode-logging/logger"; +import { Commit, Connection, Package, Revision } from "open-build-service-api"; +import { resolve } from "path"; +import * as vscode from "vscode"; +import { AccountManager } from "./accounts"; +import { ConnectionListenerLoggerBase } from "./base-components"; +import { + CurrentPackage, + CurrentPackageWatcher +} from "./current-package-watcher"; +import { + DEFAULT_OBS_FETCHERS, + ObsFetchers, + VscodeWindow +} from "./dependency-injection"; +import { + commitToCommitWithHashes, + CommitWithChildren, + getCommitKey, + isCommitWithChildren, + MessageType, + SendHistoryReceivedMsg, + StartFetchingHistoryMsg +} from "./history-graph-common"; + +function addChildCommits( + commit: Commit | CommitWithChildren, + childCommitKey: string | undefined, + commitMap: Map +): CommitWithChildren { + if (isCommitWithChildren(commit)) { + if (childCommitKey !== undefined) { + commit.childCommits.push(childCommitKey); + } + commitMap.set(getCommitKey(commit), commit); + return commit; + } else { + const { parentCommits } = commit; + (parentCommits ?? []).map((p) => { + commitMap.set( + getCommitKey(p), + addChildCommits(p, getCommitKey(commit), commitMap) + ); + }); + const res = { + ...commitToCommitWithHashes(commit), + childCommits: childCommitKey === undefined ? [] : [childCommitKey] + }; + commitMap.set(getCommitKey(res), res); + return res; + } +} + +function findCommitKeysWithoutParent( + commitMap: Map +): string[] { + const parentlessCommitKeys = []; + for (const [key, commit] of commitMap.entries()) { + if (commit.parentCommits.length === 0) { + parentlessCommitKeys.push(key); + } + } + + return parentlessCommitKeys; +} + +function headToHistoryMsg(head: Commit): SendHistoryReceivedMsg { + const commitMap = new Map(); + addChildCommits(head, undefined, commitMap); + + const res = { + commitMapInitializer: [...commitMap.entries()], + // we found the commit hashes from the same map from which we get() them, so + // they *must* be there => asserting this is fine + parentlessCommits: findCommitKeysWithoutParent(commitMap).map( + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + (c) => commitMap.get(c)! + ) + }; + + return { type: MessageType.HistoryReceived, ...res }; +} + +/** Directory where the frontend scripts reside */ +const SCRIPTS_DIR = vscode.Uri.file(resolve(__dirname, "..", "media", "html")); + +/** + * Class that provides a webview that renders the history of a OBS package + * across branches. + */ +export class HistoryGraph + extends ConnectionListenerLoggerBase + implements vscode.WebviewViewProvider { + private view?: vscode.WebviewView; + private lastMsg?: SendHistoryReceivedMsg; + + /** + * Sends the supplied message to the currently active webview, if it exists. + */ + private async sendMsgToWebview( + msg: StartFetchingHistoryMsg | SendHistoryReceivedMsg + ): Promise { + if (this.view === undefined) { + return; + } + await this.view.webview.postMessage(msg); + } + + /** */ + public static createHistoryGraph( + currentPackageWatcher: CurrentPackageWatcher, + accMngr: AccountManager, + logger: IVSCodeExtLogger, + obsFetchers: ObsFetchers = DEFAULT_OBS_FETCHERS, + vscodeWindow: VscodeWindow = vscode.window + ): HistoryGraph { + const histGraph = new HistoryGraph(accMngr, logger, obsFetchers); + + histGraph.disposables.push( + vscodeWindow.registerWebviewViewProvider( + "packageScmHistoryTree", + histGraph + ), + currentPackageWatcher.onDidChangeCurrentPackage(function ( + this: HistoryGraph, + newCurPkg: CurrentPackage + ): void { + if (newCurPkg.currentPackage !== undefined) { + const { name, projectName, apiUrl } = newCurPkg.currentPackage; + const con = this.activeAccounts.getConfig(apiUrl)?.connection; + if (con !== undefined) { + this.renderGraph(con, { name, projectName, apiUrl }) + .then() + .catch((err) => { + this.logger.error( + "Tried to render the history graph of the package %s/%s from %s, but got the following error: %s", + projectName, + name, + apiUrl, + (err as Error).toString() + ); + this.logger.trace( + "Stack trace of the previous error: %s", + (err as Error).stack + ); + }); + } + } + }, + histGraph) + ); + + return histGraph; + } + + protected constructor( + accountManager: AccountManager, + extLogger: IVSCodeExtLogger, + protected readonly obsFetchers: ObsFetchers + ) { + super(accountManager, extLogger); + } + + public async renderGraph(con: Connection, pkg: Package): Promise { + if (this.view === undefined) { + return; + } + await this.sendMsgToWebview({ + type: MessageType.StartFetch, + projectName: pkg.projectName, + name: pkg.name + }); + let history: Commit | undefined; + try { + history = await this.obsFetchers.fetchHistoryAcrossLinks(con, pkg); + } catch (err) { + this.logger.error( + "Tried to fetch the history across links of %s/%s from %s, but got the error: %s", + pkg.projectName, + pkg.name, + pkg.apiUrl, + (err as Error).toString() + ); + } + if (history === undefined) { + this.logger.trace( + "history is undefined, trying to fetch the history without branches" + ); + let unbranchedHistory: readonly Revision[]; + try { + unbranchedHistory = await this.obsFetchers.fetchHistory(con, pkg); + } catch (err) { + this.logger.error( + "Tried to fetch the history of %s/%s from %s, but got the error: %s", + pkg.projectName, + pkg.name, + pkg.apiUrl, + (err as Error).toString() + ); + return; + } + + let head: Commit | undefined; + + for (let i = unbranchedHistory.length - 1; i >= 0; i--) { + head = { + ...unbranchedHistory[i], + parentCommits: head === undefined ? undefined : [head], + // these are actually not the correct values, but we don't use them + // for rendering so any valid type will do + files: [] + }; + } + + history = head; + } + + if (history === undefined) { + this.logger.trace( + "Could not retrieve the history across links and also not the direct history, aborting" + ); + return; + } + this.lastMsg = headToHistoryMsg(history); + + this.view.show(true); + await this.sendMsgToWebview(this.lastMsg); + } + + public async resolveWebviewView( + webviewView: vscode.WebviewView + ): Promise { + this.view = webviewView; + + const scriptUri = webviewView.webview.asWebviewUri( + vscode.Uri.joinPath(SCRIPTS_DIR, "draw-graph.js") + ); + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [SCRIPTS_DIR] + }; + webviewView.webview.html = ` + + + + + + OBS history log + + +
+ + + + + +`; + if (this.lastMsg !== undefined) { + await this.sendMsgToWebview(this.lastMsg); + } + } +} diff --git a/src/test/suite/history-graph.test.ts b/src/test/suite/history-graph.test.ts new file mode 100644 index 0000000..7a514f1 --- /dev/null +++ b/src/test/suite/history-graph.test.ts @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2021 SUSE LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { expect } from "chai"; +import { afterEach, beforeEach, Context, describe, it } from "mocha"; +import { Commit } from "open-build-service-api"; +import { join } from "path"; +import { createSandbox } from "sinon"; +import { HistoryGraph } from "../../history-graph"; +import { + commitKeyToBranchName, + commitToCommitWithHashes, + CommitWithChildren, + commitWithChildrenFromJson, + CommitWithHashes, + getBranchName, + getCommitKey, + isCommitWithChildren, + isCommitWithHashes +} from "../../history-graph-common"; +import { + AccountMapInitializer, + createFakeWebviewView, + FakeAccountManager, + FakeCurrentPackageWatcher +} from "./fakes"; +import { + castToAsyncFunc, + castToFunc, + createStubbedObsFetchers, + createStubbedVscodeWindow, + LoggingFixture, + testLogger +} from "./test-utils"; + +class HistoryGraphFixture extends LoggingFixture { + public currentPackageWatcher = new FakeCurrentPackageWatcher(); + public accountManager?: FakeAccountManager; + + public sandbox = createSandbox(); + + public readonly obsFetchers = createStubbedObsFetchers(this.sandbox); + public readonly vscodeWindow = createStubbedVscodeWindow(this.sandbox); + public readonly fakeWebViewDisposable = { dispose: this.sandbox.stub() }; + + public createHistoryGraph( + initialAccountMap?: AccountMapInitializer + ): HistoryGraph { + this.accountManager = new FakeAccountManager(initialAccountMap); + + this.vscodeWindow.registerWebviewViewProvider + .onCall(0) + .returns(this.fakeWebViewDisposable); + + const historyGraph = HistoryGraph.createHistoryGraph( + this.currentPackageWatcher, + this.accountManager, + testLogger, + this.obsFetchers, + this.vscodeWindow + ); + this.disposables.push(historyGraph); + return historyGraph; + } + + public afterEach(ctx: Context): void { + super.afterEach(ctx); + this.sandbox.reset(); + console.log(this.disposables); + this.dispose(); + } +} + +type TestCtx = Context & { fixture: HistoryGraphFixture }; + +describe("HistoryGraph", () => { + beforeEach(function () { + const fixture = new HistoryGraphFixture(this); + this.fixture = fixture; + }); + + afterEach(function () { + this.fixture.afterEach(this); + }); + + describe("#createHistoryGraph", () => { + it( + "creates a new HistoryGraph", + castToFunc(function () { + this.fixture.createHistoryGraph(); + this.fixture.vscodeWindow.registerWebviewViewProvider.should.have.callCount( + 1 + ); + }) + ); + }); + + describe("#resolveWebviewView", () => { + it( + "creates a new webview with the settings applied", + castToAsyncFunc(async function () { + const graph = this.fixture.createHistoryGraph([]); + + const { webviewView } = createFakeWebviewView(this.fixture.sandbox); + + await graph.resolveWebviewView(webviewView); + webviewView.webview.options.should.deep.include({ + enableScripts: true + }); + expect(webviewView.webview.options.localResourceRoots).to.have.lengthOf( + 1 + ); + expect( + webviewView.webview.options.localResourceRoots![0].fsPath + ).to.match(new RegExp(join("media", "html"))); + webviewView.webview.html.should.match( + new RegExp('
') + ); + }) + ); + }); +}); + +describe("CommitWithHashes", () => { + const commitCommon = { + projectName: "bar", + packageName: "foo", + files: [], + revisionHash: "uiaeasdf", + revision: 1 + }; + const cmtWithChildren: CommitWithChildren = { + ...commitCommon, + parentCommits: ["baz"], + childCommits: ["foo"], + commitTime: new Date(1000) + }; + const cmtWithHashes: CommitWithHashes = { + ...commitCommon, + parentCommits: ["bar"], + commitTime: new Date(100) + }; + const cmt: Commit = { + ...commitCommon, + commitTime: new Date(1337), + parentCommits: undefined + }; + const cmtWithParent: Commit = { + ...commitCommon, + commitTime: new Date(2674), + parentCommits: [cmt] + }; + + describe("#commitWithChildrenFromJson", () => { + it("reproduces a json dumped commit", () => { + commitWithChildrenFromJson( + JSON.parse(JSON.stringify(cmtWithChildren)) + ).should.deep.equal(cmtWithChildren); + }); + }); + + describe("#isCommitWithHashes", () => { + it("correctly identifies a CommitWithHashes", () => { + isCommitWithHashes(cmtWithHashes).should.equal(true); + }); + + it("correctly identifies a CommitWithChildren", () => { + isCommitWithHashes(cmtWithChildren).should.equal(false); + }); + + it("correctly identifies a Commit", () => { + isCommitWithHashes(cmt).should.equal(false); + }); + }); + + describe("#isCommitWithChildren", () => { + it("correctly identifies a CommitWithHashes", () => { + isCommitWithChildren(cmtWithHashes).should.equal(false); + }); + + it("correctly identifies a CommitWithChildren", () => { + isCommitWithChildren(cmtWithChildren).should.equal(true); + }); + + it("correctly identifies a Commit", () => { + isCommitWithChildren(cmt).should.equal(false); + }); + }); + + describe("#commitToCommitWithHashes", () => { + it("leaves a CommitWithHashes untouched", () => { + commitToCommitWithHashes(cmtWithHashes).should.equal(cmtWithHashes); + }); + + it("leaves a CommitWithChildren untouched", () => { + commitToCommitWithHashes(cmtWithChildren).should.equal(cmtWithChildren); + }); + + it("converts a Commit", () => { + commitToCommitWithHashes(cmt).should.deep.equal({ + ...commitCommon, + commitTime: new Date(1337), + parentCommits: [] + }); + commitToCommitWithHashes(cmtWithParent).should.deep.equal({ + ...commitCommon, + commitTime: new Date(2674), + parentCommits: [getCommitKey(cmt)] + }); + }); + }); + + describe("#getCommitKey", () => { + it("creates a unique commit key", () => { + getCommitKey(cmt).should.deep.equal("bar/foo@uiaeasdf"); + }); + }); + + describe("#getBranchName", () => { + it("creates a unique branch name", () => { + getBranchName(cmt).should.deep.equal("bar/foo"); + }); + }); + + describe("commitKeyToBranchName", () => { + it("reconstructs the branch name from a commit key", () => { + commitKeyToBranchName(getCommitKey(cmt)).should.deep.equal( + getBranchName(cmt) + ); + }); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..7412d90 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es6", + "sourceMap": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedParameters": true, + "experimentalDecorators": true + }, + "exclude": [ + "**/*.js", + "**/*.js.map", + "node_modules", + ".vscode-test", + "test-resources", + "flycheck_*", + "open-build-service" + ] +} diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json new file mode 100644 index 0000000..a25cbf5 --- /dev/null +++ b/tsconfig.frontend.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./frontend_out", + "rootDir": "src", + "moduleResolution": "node", + "lib": ["dom", "es2015"], + "target": "es5" + }, + "include": ["src/history-graph-common.ts", "src/frontend/*.ts"], + "exclude": [ + "src/*.ts", + "node_modules", + ".vscode-test", + "test-resources", + "flycheck_*", + "open-build-service" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 348cba5..05fc401 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,14 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { "module": "commonjs", - "target": "es6", "outDir": "out", "lib": ["es6"], - "sourceMap": true, "rootDir": "src", - "strict": true /* enable all strict type-checking options */, - /* Additional Checks */ - "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, - "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, - "noUnusedParameters": true /* Report errors on unused parameters. */, - "experimentalDecorators": true, "moduleResolution": "node" }, "exclude": [ + "src/frontend/**", "node_modules", ".vscode-test", "test-resources", diff --git a/webpack.config.js b/webpack.config.js index 074cf92..81b55ca 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,20 +12,12 @@ const path = require("path"); -/**@type {import('webpack').Configuration}*/ -const config = { +const createWebpackConfig = ( + configFile, + { exclude = /node_modules/, include = undefined } = {} +) => ({ target: "node", - entry: "./src/extension.ts", - output: { - path: path.resolve(__dirname, "dist"), - filename: "extension.js", - libraryTarget: "commonjs2", - devtoolModuleFilenameTemplate: "../[resource-path]" - }, devtool: "source-map", - externals: { - vscode: "commonjs vscode" - }, resolve: { extensions: [".ts", ".js"] }, @@ -33,14 +25,17 @@ const config = { rules: [ { test: /\.ts$/, - exclude: /node_modules/, + exclude, + include, use: [ { loader: "ts-loader", options: { compilerOptions: { module: "es6" - } + }, + context: __dirname, + configFile: path.resolve(__dirname, configFile) } } ] @@ -54,6 +49,38 @@ const config = { } ] } +}); + +/**@type {import('webpack').Configuration}*/ +const extensionConfig = { + ...createWebpackConfig("./tsconfig.json"), + entry: "./src/extension.ts", + output: { + path: path.resolve(__dirname, "dist"), + filename: "extension.js", + library: { type: "commonjs2" }, + devtoolModuleFilenameTemplate: "../[resource-path]" + }, + externals: { + vscode: "commonjs vscode" + } +}; + +/**@type {import('webpack').Configuration}*/ +const frontendConfig = { + ...createWebpackConfig("./tsconfig.frontend.json", { + exclude: undefined, + include: [ + path.resolve(__dirname, "src", "history-graph-common.ts"), + path.resolve(__dirname, "src", "frontend", "draw-graph.ts") + ] + }), + entry: "./src/frontend/draw-graph.ts", + output: { + path: path.resolve(__dirname, "media", "html"), + filename: "draw-graph.js", + library: { type: "commonjs" } + } }; -module.exports = config; +module.exports = [extensionConfig, frontendConfig]; diff --git a/yarn.lock b/yarn.lock index 61ad7eb..13e5ad3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -230,6 +230,18 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@gitgraph/core@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@gitgraph/core/-/core-1.5.0.tgz#fc27924b82eabc5fa1f90480df5239e0c84dcafa" + integrity sha512-8CeeHbkKoFHM1y9vfjYiHyEpzl1mEhVrg5c/eFgDBsntOYswoDKU2yOf6DjtVINcE60wmcuynBSJqjMkQo07Ww== + +"@gitgraph/js@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@gitgraph/js/-/js-1.4.0.tgz#27d92664841af1d886e1b970742d3f0b80df2867" + integrity sha512-7wxTTCFnRVjsqj+Zt09+3F79WhH4MK46ZS20AwbcB/MJFshSMeMH4nYmNDPa9McVsiL+cFsvW1pmZoquAOoHMw== + dependencies: + "@gitgraph/core" "1.5.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" From f503d9e1e45fdce8ec87afd38510f49deaa484b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Mon, 12 Apr 2021 11:28:16 +0200 Subject: [PATCH 4/4] Remove no longer used scm-history module --- src/scm-history.ts | 282 --------------------------------------------- src/vcs.ts | 14 +-- 2 files changed, 4 insertions(+), 292 deletions(-) delete mode 100644 src/scm-history.ts diff --git a/src/scm-history.ts b/src/scm-history.ts deleted file mode 100644 index cf9ccf4..0000000 --- a/src/scm-history.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) 2020 SUSE LLC - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import { IVSCodeExtLogger } from "@vscode-logging/logger"; -import { - fetchHistory, - ModifiedPackage, - Package, - Revision -} from "open-build-service-api"; -import * as vscode from "vscode"; -import { AccountManager } from "./accounts"; -import { assert } from "./assert"; -import { ConnectionListenerLoggerBase } from "./base-components"; -import { cmdPrefix } from "./constants"; -import { - CurrentPackage, - CurrentPackageWatcher, - isModifiedPackage -} from "./current-package-watcher"; - -export class HistoryRootTreeElement extends vscode.TreeItem { - public contextValue = "historyRoot"; - - constructor(pkg: Package) { - super( - `${pkg.projectName}/${pkg.name}`, - vscode.TreeItemCollapsibleState.Expanded - ); - } -} - -export class CommitTreeElement extends vscode.TreeItem { - public contextValue = "commit"; - - public iconPath = new vscode.ThemeIcon("git-commit"); - - constructor(public readonly rev: Revision) { - super( - `${rev.revision}: ${ - rev.commitMessage === undefined - ? "no commit message available" - : rev.commitMessage.split("\n")[0] - }`, - vscode.TreeItemCollapsibleState.None - ); - this.command = { - arguments: [this], - command: OPEN_COMMIT_DOCUMENT_COMMAND, - title: "Show commit info" - }; - } -} - -function isCommitTreeElement(elem: vscode.TreeItem): elem is CommitTreeElement { - return elem.contextValue === "commit"; -} - -function isHistoryRootTreeElement( - elem: vscode.TreeItem -): elem is HistoryRootTreeElement { - return elem.contextValue === "historyRoot"; -} - -type HistoryTreeItem = CommitTreeElement | HistoryRootTreeElement; - -const cmdId = "scmHistory"; - -export const OBS_REVISION_FILE_SCHEME = "vscodeObsCommit"; - -export function fsPathFromObsRevisionUri(uri: vscode.Uri): string | undefined { - return uri.scheme === OBS_REVISION_FILE_SCHEME - ? uri.with({ scheme: "file", query: "" }).fsPath - : undefined; -} - -export const OPEN_COMMIT_DOCUMENT_COMMAND = `${cmdPrefix}.${cmdId}.openCommitDocument`; - -export class PackageScmHistoryTree - extends ConnectionListenerLoggerBase - implements - vscode.TreeDataProvider, - vscode.TextDocumentContentProvider { - private commitToUri(rev: Revision): vscode.Uri | undefined { - return this.currentPackage === undefined - ? undefined - : vscode.Uri.file(this.currentPackage.path).with({ - scheme: OBS_REVISION_FILE_SCHEME, - query: rev.revisionHash - }); - } - - public static async createPackageScmHistoryTree( - currentPackageWatcher: CurrentPackageWatcher, - accountManager: AccountManager, - logger: IVSCodeExtLogger - ): Promise { - const historyTree = new PackageScmHistoryTree( - currentPackageWatcher, - accountManager, - logger - ); - await historyTree.setCurrentPackage(currentPackageWatcher.currentPackage); - return historyTree; - } - - public onDidChangeTreeData: vscode.Event; - - private onDidChangeTreeDataEmitter: vscode.EventEmitter< - HistoryTreeItem | undefined - > = new vscode.EventEmitter(); - - private currentPackage: ModifiedPackage | undefined = undefined; - private currentHistory: readonly Revision[] | undefined = undefined; - - private constructor( - currentPackageWatcher: CurrentPackageWatcher, - accountManager: AccountManager, - logger: IVSCodeExtLogger - ) { - super(accountManager, logger); - this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - - this.disposables.push( - this.onDidChangeTreeDataEmitter, - currentPackageWatcher.onDidChangeCurrentPackage( - this.setCurrentPackage, - this - ), - vscode.commands.registerCommand( - OPEN_COMMIT_DOCUMENT_COMMAND, - this.openCommitDocument, - this - ), - vscode.workspace.registerTextDocumentContentProvider( - OBS_REVISION_FILE_SCHEME, - this - ) - ); - } - - public provideTextDocumentContent( - uri: vscode.Uri, - token: vscode.CancellationToken - ): string | undefined { - const rev = this.commitFromUri(uri); - - if (token.isCancellationRequested || rev === undefined) { - return undefined; - } - let content = `r${rev.revision} | ${ - rev.userId ?? "unknown user" - } | ${rev.commitTime.toString()} | ${rev.revisionHash}`; - if (rev.version !== undefined) { - content = content.concat(" | ", rev.version); - } - if (rev.requestId !== undefined) { - content = content.concat(" | rq", rev.requestId.toString()); - } - content = content.concat( - ` -`, - rev.commitMessage ?? "No commit message available" - ); - - return content; - } - - public getTreeItem(element: HistoryTreeItem): vscode.TreeItem { - return element; - } - - public getChildren(element?: HistoryTreeItem): HistoryTreeItem[] { - if (this.currentPackage === undefined) { - return []; - } - if (element === undefined) { - return [new HistoryRootTreeElement(this.currentPackage)]; - } - - assert(isHistoryRootTreeElement(element)); - if (this.currentHistory === undefined) { - this.logger.error("currentPackage is set, but no history is present"); - return []; - } - return this.currentHistory.map( - (_rev, index, hist) => - new CommitTreeElement(hist[hist.length - 1 - index]) - ); - } - - private commitFromUri(uri: vscode.Uri): Revision | undefined { - if (uri.scheme !== OBS_REVISION_FILE_SCHEME) { - throw new Error( - `cannot extract a Revision from the uri '${uri.toString()}', invalid scheme: ${ - uri.scheme - }, expected ${OBS_REVISION_FILE_SCHEME}` - ); - } - - if (this.currentHistory === undefined) { - this.logger.error( - "commit document was requested but no currentHistory is set" - ); - return undefined; - } - - const revisionHash = uri.query; - - return this.currentHistory.find((rev) => rev.revisionHash === revisionHash); - } - - private async openCommitDocument(element?: vscode.TreeItem): Promise { - if (element === undefined || !isCommitTreeElement(element)) { - return; - } - const uri = this.commitToUri(element.rev); - if (uri === undefined) { - this.logger.error( - "Could not get an uri from the element with the revision: %s", - element.rev - ); - return; - } - const document = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(document, { preview: false }); - } - - private async setCurrentPackage(curPkg: CurrentPackage): Promise { - const pkg = curPkg.currentPackage; - if (pkg === undefined || !isModifiedPackage(pkg)) { - this.logger.debug( - "setCurrentPackage called without the pkg parameter or the package %s/%s is not a ModifiedPackage (= not checked out)", - pkg?.projectName, - pkg?.name - ); - return; - } - const con = this.activeAccounts.getConfig(pkg.apiUrl)?.connection; - if (con === undefined) { - this.logger.error( - "cannot fetch history for %s/%s: no account is configured for the API %s", - pkg.projectName, - pkg.name, - pkg.apiUrl - ); - return; - } - - try { - this.currentHistory = await fetchHistory(con, pkg); - this.currentPackage = pkg; - this.onDidChangeTreeDataEmitter.fire(undefined); - } catch (err) { - this.logger.error( - "Failed to load history of %s/%s from %s, got error: %s", - pkg.projectName, - pkg.name, - pkg.apiUrl, - (err as Error).toString() - ); - } - } -} diff --git a/src/vcs.ts b/src/vcs.ts index 2fba449..567ae20 100644 --- a/src/vcs.ts +++ b/src/vcs.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { assert } from "./assert"; +import { IVSCodeExtLogger } from "@vscode-logging/logger"; import { promises as fsPromises } from "fs"; import { addAndDeleteFilesFromPackage, @@ -32,9 +32,9 @@ import { } from "open-build-service-api"; import { undoFileDeletion } from "open-build-service-api/lib/vcs"; import { basename, dirname, join, sep } from "path"; -import { IVSCodeExtLogger } from "@vscode-logging/logger"; import * as vscode from "vscode"; import { AccountManager } from "./accounts"; +import { assert } from "./assert"; import { ConnectionListenerLoggerBase } from "./base-components"; import { cmdPrefix, ignoreFocusOut } from "./constants"; import { @@ -46,7 +46,6 @@ import { EmptyDocumentForDiffProvider, fsPathFromEmptyDocumentUri } from "./empty-file-provider"; -import { fsPathFromObsRevisionUri } from "./scm-history"; import { makeThemedIconPath } from "./util"; interface LineChange { @@ -97,17 +96,12 @@ export const fileAtHeadUri = { * to a source control file. */ export function getPkgPathFromVcsUri(uri: vscode.Uri): string | undefined { - let fsPath = + const fsPath = uri.scheme === "file" ? uri.fsPath : fsPathFromFileAtHeadUri(uri) ?? fsPathFromEmptyDocumentUri(uri); - if (fsPath !== undefined) { - fsPath = dirname(fsPath); - } else { - fsPath = fsPathFromObsRevisionUri(uri); - } - return fsPath; + return fsPath !== undefined ? dirname(fsPath) : undefined; } export class PackageScm