diff --git a/package.json b/package.json index 3d97500..fb3aa65 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "devDependencies": { "@playwright/test": "^1.35.1", + "@types/chrome": "^0.0.246", "@types/webextension-polyfill": "0.10.1", "@typescript-eslint/eslint-plugin": "6.0.0", "@typescript-eslint/parser": "6.0.0", diff --git a/src/background.ts b/src/background.ts index e73be24..91db3be 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,5 @@ import type { WebNavigation } from 'webextension-polyfill'; -import { scripting, webNavigation } from 'webextension-polyfill'; +import { scripting, tabs, webNavigation } from 'webextension-polyfill'; import { injectionScope as inject_bitbucket } from './hosts/bitbucket'; import { injectionScope as inject_github } from './hosts/github'; import { injectionScope as inject_gitlab } from './hosts/gitlab'; @@ -8,6 +8,18 @@ webNavigation.onDOMContentLoaded.addListener(injectScript, { url: [{ hostContains: 'github.com' }, { hostContains: 'gitlab.com' }, { hostContains: 'bitbucket.org' }], }); +webNavigation.onHistoryStateUpdated.addListener(details => { + // used to detect when the user navigates to a different page in the same tab + // is currently needed to handle bitbucket navigation + const url = new URL(details.url); + if (url.host === 'bitbucket.org') { + tabs.sendMessage(details.tabId, { + message: 'onHistoryStateUpdated', + details: details, + }).catch(console.error); + } +}); + function injectScript(details: WebNavigation.OnDOMContentLoadedDetailsType) { void scripting.executeScript({ target: { tabId: details.tabId }, diff --git a/src/hosts/bitbucket.ts b/src/hosts/bitbucket.ts index d90b8c2..ecb9744 100644 --- a/src/hosts/bitbucket.ts +++ b/src/hosts/bitbucket.ts @@ -1,16 +1,362 @@ -import type { InjectionProvider } from '../provider'; +import type { InjectionProvider, LinkTarget } from '../provider'; + +declare const MODE: 'production' | 'development' | 'none'; + +interface ReplaceSelector { + selector: string; + href: string; +} export function injectionScope(url: string) { - class BitbucketInjectionProvider implements InjectionProvider { + class BitBucketInjectionProvider implements InjectionProvider { + private _timer: ReturnType | undefined; + private _observer: MutationObserver | undefined; + constructor(private readonly uri: URL) {} inject(): void { this.render(); } - private render() {} + private render() { + const insertions = this.getInsertions(this.uri.pathname); + this.insertHTML(insertions); + chrome.runtime.onMessage.addListener(request => { + if (request.message === 'onHistoryStateUpdated') { + setTimeout( + () => { + const newUri = new URL(request.details.url); + const newInsertions = this.getInsertions(newUri.pathname); + this.insertHTML(newInsertions); + }, + request.details.url.includes('pull-requests') ? 1000 : 0, + ); + } + }); + } + + private getInsertions(pathname: string) { + const insertions = new Map< + string, + { html: string; position: InsertPosition; replaceSelectorList?: ReplaceSelector[] } + >(); + try { + const label = 'Open with GitKraken'; + const url = this.transformUrl('gkdev', 'open', pathname); + + const [, , , type] = pathname.split('/'); + switch (type) { + case 'compare': { + // TODO update the url when the dropdown changes/url changes + const compareUrl = this.transformUrl('gkdev', 'compare', pathname); + insertions.set('#compare-toolbar .aui-buttons', { + html: /*html*/ `${this.getGitKrakenSvg( + 22, + undefined, + 'position:relative; top:5px;', + )} + Open Comparison with GitKraken + `, + position: 'afterbegin', + replaceSelectorList: [{ selector: '.gk-insert-compare', href: compareUrl }], + }); + break; + } + case 'pull-requests': { + const compareUrl = this.transformUrl('gkdev', 'compare', pathname); + insertions.set('.css-1oy5iav', { + html: /*html*/ `${this.getGitKrakenSvg( + 20, + undefined, + 'position:relative; top:4px; left:-5px;', + )}Open with GitKraken + ${this.getGitKrakenSvg( + 20, + undefined, + 'position:relative; top:4px; left:-5px;', + )}Open Comparison with GitKraken`, + position: 'afterbegin', + replaceSelectorList: [ + { selector: '.gk-insert-pr', href: url }, + { selector: '.gk-insert-comparison', href: compareUrl }, + ], + }); + break; + } + case 'branches': { + insertions.set('.css-1bvc4cc', { + html: /*html*/ `${this.getGitKrakenSvg( + 20, + undefined, + 'position:relative; top:4px; left:-5px;', + )}Open with GitKraken`, + position: 'afterbegin', + }); + break; + } + case 'branch': + case 'commits': + case 'src': + case undefined: { + insertions.set('.css-1oy5iav', { + html: /*html*/ `${this.getGitKrakenSvg( + 20, + undefined, + 'position:relative; top:4px; left:-5px;', + )}Open with GitKraken`, + position: 'afterbegin', + replaceSelectorList: [{ selector: '.gk-insert', href: url }], + }); + + break; + } + } + } catch (ex) { + debugger; + console.error(ex); + } + return insertions; + } + + private insertHTML( + insertions: Map< + string, + { html: string; position: InsertPosition; replaceSelectorList?: ReplaceSelector[] } + >, + ) { + if (insertions.size) { + for (const [selector, { html, position, replaceSelectorList }] of insertions) { + if (replaceSelectorList?.length) { + let found = false; + for (const { selector: replaceSelector, href: replaceHref } of replaceSelectorList) { + const el = document.querySelector(replaceSelector); + if (el) { + insertions.delete(selector); + el.href = replaceHref; + found = true; + } + } + if (found) continue; + } + const el = document.querySelector(selector); + if (el) { + insertions.delete(selector); + el.insertAdjacentHTML(position, html); + } + } + + if (!insertions.size) return; + + this._observer = new MutationObserver(() => { + if (this._timer != null) { + clearTimeout(this._timer); + } + + this._timer = setTimeout(() => { + for (const [selector, { html, position, replaceSelectorList }] of insertions) { + if (replaceSelectorList?.length) { + let found = false; + for (const { selector: replaceSelector, href: replaceHref } of replaceSelectorList) { + const el = document.querySelector(replaceSelector); + if (el) { + insertions.delete(selector); + el.href = replaceHref; + found = true; + } + } + if (found) continue; + } + const el = document.querySelector(selector); + if (el) { + insertions.delete(selector); + el.insertAdjacentHTML(position, html); + } + } + + if (!insertions.size) { + this._observer?.disconnect(); + this._observer = undefined; + } + }, 300); + }); + this._observer.observe(document.body, { childList: true, subtree: true }); + } + } + + private transformUrl(target: LinkTarget, action: 'open' | 'compare', pathname: string): string { + let [, owner, repo, type, ...rest] = pathname.split('/'); + if (rest?.length) { + rest = rest.filter(Boolean); + } + + if (target === 'gkdev') { + const redirectUrl = new URL(this.transformUrl('vscode', action, pathname)); + const deepLinkUrl = + MODE === 'production' ? 'https://gitkraken.dev/link' : 'https://dev.gitkraken.dev/link'; + const deepLink = new URL(`${deepLinkUrl}/${encodeURIComponent(btoa(redirectUrl.toString()))}`); + deepLink.searchParams.set('referrer', 'extension'); + if (redirectUrl.searchParams.get('pr')) { + deepLink.searchParams.set('context', 'pr'); + } + return deepLink.toString(); + } + + const repoId = '-'; + + let url; + switch (type) { + case 'commits': { + const urlTarget = rest.join('/'); + if (!urlTarget) { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + break; + } + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/c/${urlTarget}`); + break; + } + case 'compare': { + let comparisonTarget = rest.join('/'); + if (!comparisonTarget) { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + break; + } + const sameOrigin = !comparisonTarget.includes(':'); + if (sameOrigin) { + const branches = comparisonTarget.split('%0D').map(branch => `${owner}/${repo}:${branch}`); + + comparisonTarget = branches.join('...'); + } + url = new URL( + `${target}://eamodio.gitlens/link/r/${repoId}/compare/${comparisonTarget.replace( + /%0D/g, + '...', + )}`, + ); + break; + } + case 'pull-requests': { + const [prNumber] = rest; + const [prBranchElement, baseBranchElement, ..._] = + document.querySelectorAll('.css-1ul4m4g.evx2nil0'); + const pr = prBranchElement?.innerText; + const base = baseBranchElement?.innerText; + + if (pr && base) { + const splitPr = pr.split('/'); + const splitBase = base.split('/'); + let prBranch; + let prOwner; + let prRepo; + let baseOwner; + let baseRepo; + if (splitPr.length === 1) { + prBranch = pr; + prOwner = owner; + prRepo = repo; + } else { + prOwner = splitPr[0]; + const splitPr2 = splitPr[1].split(':'); + prRepo = splitPr2[0]; + prBranch = splitPr2[1]; + } + if (splitBase.length === 1) { + baseOwner = owner; + baseRepo = repo; + } else { + baseOwner = splitBase[0]; + const splitBase2 = splitBase[1].split(':'); + baseRepo = splitBase2[0]; + } + + if (action === 'compare') { + let baseBranchString; + let prBranchString; + + if (prOwner === baseOwner && prRepo === baseRepo) { + const baseBranch = base; + const prBranch = pr; + baseBranchString = `${baseOwner}/${baseRepo}:${baseBranch}`; + prBranchString = `${prOwner}/${prRepo}:${prBranch}`; + } else { + baseBranchString = base; + prBranchString = pr; + } + url = new URL( + `${target}://eamodio.gitlens/link/r/${repoId}/compare/${baseBranchString}...${prBranchString}`, + ); + } + + if (url == null) { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch}`); + } + + url.searchParams.set('pr', prNumber); + url.searchParams.set('prUrl', this.uri.toString()); + + if (prOwner !== owner || prRepo !== repo) { + const prRepoUrl = new URL(this.uri.toString()); + prRepoUrl.hash = ''; + prRepoUrl.search = ''; + prRepoUrl.pathname = `/${owner}/${repo}.git`; + url.searchParams.set('prRepoUrl', prRepoUrl.toString()); + + owner = prOwner; + repo = prRepo; + } + } else { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + url.searchParams.set('pr', prNumber); + url.searchParams.set('prUrl', this.uri.toString()); + } + console.log('transform', url.toString(), action); + break; + } + case 'branches': + case 'branch': { + const urlTarget = rest.join('/'); + if (!urlTarget) { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + break; + } + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${urlTarget}`); + break; + } + case 'src': { + // TODO@miggy-e this is a pretty naive check, please update if you find a better way + // this is currently broken when branches have 40 characters or if you use the short sha of a commit + if (rest.length === 1 && rest[0].length === 40) { + // commit sha's are 40 characters long + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/c/${rest.join('/')}`); + } else if (!rest.length) { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + } else { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${rest.join('/')}`); + } + break; + } + default: + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}`); + break; + } + + const remoteUrl = new URL(this.uri.toString()); + remoteUrl.hash = ''; + remoteUrl.search = ''; + remoteUrl.pathname = `/${owner}/${repo}.git`; + + url.searchParams.set('url', remoteUrl.toString()); + return url.toString(); + } + + private getGitKrakenSvg(size: number, classes?: string, style?: string) { + return /*html*/ ``; + } } - const provider = new BitbucketInjectionProvider(new URL(url)); + const provider = new BitBucketInjectionProvider(new URL(url)); provider.inject(); } diff --git a/src/hosts/github.ts b/src/hosts/github.ts index ff916ed..6e6efae 100644 --- a/src/hosts/github.ts +++ b/src/hosts/github.ts @@ -7,7 +7,7 @@ export function injectionScope(url: string) { private _timer: ReturnType | undefined; private _observer: MutationObserver | undefined; - constructor(private uri: URL) { } + constructor(private uri: URL) {} inject(): void { document.addEventListener('pjax:end', () => { @@ -167,6 +167,9 @@ export function injectionScope(url: string) { private transformUrl(target: LinkTarget, action: 'open' | 'compare'): string { let [, owner, repo, type, ...rest] = this.uri.pathname.split('/'); + if (rest?.length) { + rest = rest.filter(Boolean); + } if (target === 'gkdev') { const redirectUrl = new URL(this.transformUrl('vscode', action)); @@ -183,7 +186,7 @@ export function injectionScope(url: string) { const repoId = '-'; - let url; + let url = new URL(MODE === 'production' ? 'https://gitkraken.dev' : 'https://dev.gitkraken.dev'); switch (target) { case 'gitkraken': { switch (type) { @@ -313,14 +316,13 @@ export function injectionScope(url: string) { break; } case 'tree': { - // TODO@eamodio this is naive as it assumes everything after the tree is the branch, but it could also contain a path - const prButtonForBranchPage = document.querySelector( - '.btn-link.no-underline.color-fg-muted', - ); - if (prButtonForBranchPage && prButtonForBranchPage.textContent?.includes('Contribute')) { - url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${rest.join('/')}`); - } else { + // TODO@miggy-e this is a pretty naive check, please update if you find a better way + // this is currently broken when branches have 40 characters or if you use the short sha of a commit + if (rest.length === 1 && rest[0].length === 40) { + // commit sha's are 40 characters long url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/c/${rest.join('/')}`); + } else { + url = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${rest.join('/')}`); } break; } @@ -342,8 +344,9 @@ export function injectionScope(url: string) { } private getGitKrakenSvg(size: number, classes?: string, style?: string) { - return /*html*/ `