diff --git a/README.md b/README.md index 4fde9a5..e98cba8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ Octotree uses [GitHub API](https://developer.github.com/v3/) to retrieve reposit When that happens, Octotree will ask for your [GitHub personal access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use). If you don't already have one, [create one](https://github.com/settings/tokens/new), then copy and paste it into the textbox. Note that the minimal scopes that should be granted are `public_repo` and `repo` (if you need access to private repositories). +#### Bitbucket +Octotree uses [Bitbucket API](https://confluence.atlassian.com/bitbucket/repositories-endpoint-1-0-296092719.html) to retrieve repository metadata. By defualt, Octotree will ask for your [Bitbucket App password](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html). If you don't already have one, [create one](https://bitbucket.org/account/admin/app-passwords) (the minimal requirement is `Repositories`'s `Read` permission), then copy and paste it into the textbox. + +Note that Octotree extract your username from your current page by default for calling Bitbucket API. If fail to extract, Octotree will ask you for a token update, then you just need to prepend your username to the token, separated by a colon, i.e. `USERNAME:TOKEN`. + ### Enterprise URLs By default, Octotree only works on `github.com`. To support enterprise version (Chrome and Opera only), you must grant Octotree sufficient permissions. Follow these steps to do so: diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 161f2c6..7dd2088 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -173,6 +173,7 @@ function buildJs(overrides, ctx) { './tmp/template.js', './src/constants.js', './src/adapters/adapter.js', + './src/adapters/bitbucket.js', './src/adapters/github.js', './src/view.help.js', './src/view.error.js', diff --git a/src/adapters/adapter.js b/src/adapters/adapter.js index c2aef9a..fc88d8b 100644 --- a/src/adapters/adapter.js +++ b/src/adapters/adapter.js @@ -76,7 +76,7 @@ class Adapter { // encodes but retains the slashes, see #274 const encodedPath = path.split('/').map(encodeURIComponent).join('/') item.a_attr = { - href: `/${repo.username}/${repo.reponame}/${type}/${repo.branch}/${encodedPath}` + href: this._getItemHref(repo, type, path) } } else if (type === 'commit') { @@ -273,7 +273,7 @@ class Adapter { */ downloadFile(path, fileName) { const link = document.createElement('a') - link.setAttribute('href', path.replace(/\/blob\//, '/raw/')) + link.setAttribute('href', path.replace(/\/blob\/|\/src\//, '/raw/')) link.setAttribute('download', fileName) link.click() } @@ -295,4 +295,95 @@ class Adapter { _getSubmodules(tree, opts, cb) { throw new Error('Not implemented') } + + /** + * Returns item's href value. + * @api protected + */ + _getItemHref(repo, type, encodedPath) { + return `/${repo.username}/${repo.reponame}/${type}/${repo.branch}/${encodedPath}` + } +} + + +class PjaxAdapter extends Adapter { + constructor() { + super(['jquery.pjax.js']) + + $.pjax.defaults.timeout = 0 // no timeout + $(document) + .on('pjax:send', () => $(document).trigger(EVENT.REQ_START)) + .on('pjax:end', () => $(document).trigger(EVENT.REQ_END)) + } + + + // @override + // @param {Object} opts - {pjaxContainer: the specified pjax container} + // @api public + init($sidebar, opts) { + super.init($sidebar) + + opts = opts || {} + const pjaxContainer = opts.pjaxContainer + + if (!window.MutationObserver) return + + // Some host switch pages using pjax. This observer detects if the pjax container + // has been updated with new contents and trigger layout. + const pageChangeObserver = new window.MutationObserver(() => { + // Trigger location change, can't just relayout as Octotree might need to + // hide/show depending on whether the current page is a code page or not. + return $(document).trigger(EVENT.LOC_CHANGE) + }) + + if (pjaxContainer) { + pageChangeObserver.observe(pjaxContainer, { + childList: true, + }) + } + else { // Fall back if DOM has been changed + let firstLoad = true, href, hash + + function detectLocChange() { + if (location.href !== href || location.hash !== hash) { + href = location.href + hash = location.hash + + // If this is the first time this is called, no need to notify change as + // Octotree does its own initialization after loading options. + if (firstLoad) { + firstLoad = false + } + else { + setTimeout(() => { + $(document).trigger(EVENT.LOC_CHANGE) + }, 300) // Wait a bit for pjax DOM change + } + } + setTimeout(detectLocChange, 200) + } + + detectLocChange() + } + } + + + // @override + // @param {Object} opts - {$pjax_container: jQuery object} + // @api public + selectFile(path, opts) { + opts = opts || {} + const $pjaxContainer = opts.$pjaxContainer + + if ($pjaxContainer.length) { + $.pjax({ + // needs full path for pjax to work with Firefox as per cross-domain-content setting + url: location.protocol + '//' + location.host + path, + container: $pjaxContainer + }) + } + else { // falls back + super.selectFile(path) + } + } } diff --git a/src/adapters/bitbucket.js b/src/adapters/bitbucket.js new file mode 100644 index 0000000..f00be00 --- /dev/null +++ b/src/adapters/bitbucket.js @@ -0,0 +1,212 @@ +const BB_RESERVED_USER_NAMES = [ + 'account', 'dashboard', 'integrations', 'product', + 'repo', 'snippets', 'support', 'whats-new' +] +const BB_RESERVED_REPO_NAMES = [] +const BB_RESERVED_TYPES = ['raw'] +const BB_404_SEL = '#error.404' +const BB_PJAX_CONTAINER_SEL = '#source-container' + +class Bitbucket extends PjaxAdapter { + + constructor() { + super(['jquery.pjax.js']) + } + + // @override + init($sidebar) { + const pjaxContainer = $(BB_PJAX_CONTAINER_SEL)[0] + super.init($sidebar, {'pjaxContainer': pjaxContainer}) + } + + // @override + getCssClass() { + return 'octotree_bitbucket_sidebar' + } + + // @override + getCreateTokenUrl() { + return `${location.protocol}//${location.host}/account/admin/app-passwords/new` + } + + // @override + updateLayout(togglerVisible, sidebarVisible, sidebarWidth) { + $('.octotree_toggle').css('right', sidebarVisible ? '' : -44) + $('.aui-header').css('padding-left', sidebarVisible ? '' : 56) + $('html').css('margin-left', sidebarVisible ? sidebarWidth : '') + } + + // @override + getRepoFromPath(showInNonCodePage, currentRepo, token, cb) { + + // 404 page, skip + if ($(BB_404_SEL).length) { + return cb() + } + + // (username)/(reponame)[/(type)] + const match = window.location.pathname.match(/([^\/]+)\/([^\/]+)(?:\/([^\/]+))?/) + if (!match) { + return cb() + } + + const username = match[1] + const reponame = match[2] + const type = match[3] + + // Not a repository, skip + if (~BB_RESERVED_USER_NAMES.indexOf(username) || + ~BB_RESERVED_REPO_NAMES.indexOf(reponame) || + ~BB_RESERVED_TYPES.indexOf(type)) { + return cb() + } + + // Skip non-code page unless showInNonCodePage is true + // with Bitbucket /username/repo is non-code page + if (!showInNonCodePage && + (!type || (type && type !== 'src'))) { + return cb() + } + + // Get branch by inspecting page, quite fragile so provide multiple fallbacks + const BB_BRANCH_SEL_1 = '.branch-dialog-trigger' + + const branch = + // Code page + $(BB_BRANCH_SEL_1).attr('title') || + // Assume same with previously + (currentRepo.username === username && currentRepo.reponame === reponame && currentRepo.branch) || + // Default from cache + this._defaultBranch[username + '/' + reponame] + + const repo = {username: username, reponame: reponame, branch: branch} + + if (repo.branch) { + cb(null, repo) + } + else { + this._get('/main-branch', {repo, token}, (err, data) => { + if (err) return cb(err) + repo.branch = this._defaultBranch[username + '/' + reponame] = data.name || 'master' + cb(null, repo) + }) + } + } + + // @override + selectFile(path) { + const $pjaxContainer = $(BB_PJAX_CONTAINER_SEL) + super.selectFile(path, {'$pjaxContainer': $pjaxContainer}) + } + + // @override + loadCodeTree(opts, cb) { + opts.path = opts.node.path + this._loadCodeTree(opts, (item) => { + if (!item.type) { + item.type = 'blob' + } + }, cb) + } + + // @override + _getTree(path, opts, cb) { + this._get(`/src/${opts.repo.branch}/${path}`, opts, (err, res) => { + if (err) return cb(err) + const directories = res.directories.map((dir) => ({path: dir, type: 'tree'})) + res.files.forEach((file) => { + if (file.path.startsWith(res.path)) { + file.path = file.path.substring(res.path.length) + } + }) + const tree = res.files.concat(directories) + cb(null, tree) + }) + } + + // @override + _getSubmodules(tree, opts, cb) { + if (opts.repo.submodules) { + return this._getSubmodulesInCurrentPath(tree, opts, cb) + } + + const item = tree.filter((item) => /^\.gitmodules$/i.test(item.path))[0] + if (!item) return cb() + + this._get(`/src/${opts.encodedBranch}/${item.path}`, opts, (err, res) => { + if (err) return cb(err) + // Memoize submodules so that they will be inserted into the tree later. + opts.repo.submodules = parseGitmodules(res.data) + this._getSubmodulesInCurrentPath(tree, opts, cb) + }) + } + + // @override + _getSubmodulesInCurrentPath(tree, opts, cb) { + const currentPath = opts.path + const isInCurrentPath = currentPath + ? (path) => path.startsWith(`${currentPath}/`) + : (path) => path.indexOf('/') === -1 + + const submodules = opts.repo.submodules + const submodulesInCurrentPath = {} + Object.keys(submodules).filter(isInCurrentPath).forEach((key) => { + submodulesInCurrentPath[key] = submodules[key] + }) + + // Insert submodules in current path into the tree because submodules can not + // be retrieved with Bitbucket API but can only by reading .gitmodules. + Object.keys(submodulesInCurrentPath).forEach((path) => { + if (currentPath) { + // `currentPath` is prefixed to `path`, so delete it. + path = path.substring(currentPath.length + 1) + } + tree.push({path: path, type: 'commit'}) + }) + cb(null, submodulesInCurrentPath) + } + + // @override + _get(path, opts, cb) { + const host = location.protocol + '//' + 'api.bitbucket.org/1.0' + const url = `${host}/repositories/${opts.repo.username}/${opts.repo.reponame}${path || ''}` + const cfg = { url, method: 'GET', cache: false } + + if (opts.token) { + // Bitbucket App passwords can be used only for Basic Authentication. + // Get username of logged-in user. + let username = null, token = null + + // Or get username by spliting token. + if (opts.token.includes(':')) { + const result = opts.token.split(':') + username = result[0], token = result[1] + } + else { + const currentUser = JSON.parse($('body').attr('data-current-user')) + if (!currentUser || !currentUser.username) { + return cb({ + error: 'Error: Invalid token', + message: `Cannot retrieve your user name from the current page. + Please update the token setting to prepend your user + name to the token, separated by a colon, i.e. USERNAME:TOKEN`, + needAuth: true + }) + } + username = currentUser.username, token = opts.token + } + cfg.headers = { Authorization: 'Basic ' + btoa(username + ':' + token) } + } + + $.ajax(cfg) + .done((data) => cb(null, data)) + .fail((jqXHR) => { + this._handleError(jqXHR, cb) + }) + } + + // @override + _getItemHref(repo, type, encodedPath) { + return `/${repo.username}/${repo.reponame}/src/${repo.branch}/${encodedPath}` + } +} diff --git a/src/adapters/bitbucket.less b/src/adapters/bitbucket.less new file mode 100644 index 0000000..cd2a873 --- /dev/null +++ b/src/adapters/bitbucket.less @@ -0,0 +1,164 @@ +.octotree-show { + .octotree_bitbucket_only { + display: none; + } + + .octotree_bitbucket_sidebar { + a.octotree_toggle { + top: 9px; + right: 5px; + &:not(.octotree_loading) > span:after { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/chevron-left.svg'); + } + &:not(.octotree_loading):hover > span:after { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/chevron-left-hover.svg'); + } + } + } + + body.split-diff .container { + padding-left: 0; + } +} + +.octotree_bitbucket_sidebar { + .btn { + box-sizing: border-box; + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 3.01px; + color: #333; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: 14px; + font-variant: normal; + font-weight: 400; + height: 2.14285714em; + line-height: 1.42857143; + margin: 0; + padding: 4px 10px; + text-decoration: none; + vertical-align: baseline; + white-space: nowrap; + } + + .btn:hover { + background: #e9e9e9; + border-color: #999; + color: #000; + text-decoration: none; + } + + padding-top: 49px; + background-color: #f5f5f5; + + .octotree_bitbucket_only { + display: block; + } + + .octotree_views { + .octotree_view { + .octotree_view_header { + height: 49px; + background-color: #f3f3f3; + background-image: linear-gradient(#f9f9f9, #f3f3f3); + background-repeat: repeat-x; + border-bottom: 1px solid #e5e5e5; + } + + .octotree_help { + & > span:before { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/question.svg'); + } + } + } + + .octotree_treeview { + .octotree_header_repo { + font-size: 13px; + } + .octotree_header_repo:before { + font-family: 'Atlassian Icons'; + content: '\f135'; + color: #707070; + } + .octotree_header_branch { + font-size: 11px; + } + .octotree_header_branch:before { + font-family: 'Atlassian Icons'; + content: '\f127'; + color: #707070; + } + .jstree-icon.tree:before { + content: '\f131'; + color: #707070; + } + .jstree-icon.blob:before { + content: '\f12e'; + color: #707070; + } + .jstree-node.jstree-leaf:hover { + .jstree-icon.blob { + margin-top: 3px; + } + .jstree-icon.blob:before { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/cloud-download.svg'); + } + } + .jstree-icon.commit:before { + content: '\f139'; + color: #707070; + } + .jstree-anchor { + color: #3572b0 !important; + & > span { + color: black; + } + } + .jstree-default { + .jstree-wholerow-hovered { + background: #e6e6e6; + } + .jstree-wholerow-clicked { + background: #e6e6e6; + } + } + .jstree-icon.tree, .jstree-icon.blob, .jstree-icon.commit { + font: normal normal 16px 'Atlassian Icons'; + } + } + } + + a.octotree_toggle, a.octotree_opts { + color: black !important; + + &:hover, &.selected { + color: #4183C4 !important; + } + } + + a.octotree_opts { + top: 16px; + right: 38px; + width: 14px; + height: 16px; + background: data-uri('image/svg+xml;charset=UTF-8', './octicons/gear.svg'); + &:hover { + background: data-uri('image/svg+xml;charset=UTF-8', './octicons/gear-hover.svg'); + } + } + + a.octotree_toggle { + top: 5px; + right: -44px; + + &:not(.octotree_loading) > span:after { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/chevron-right.svg'); + } + &:not(.octotree_loading):hover > span:after { + content: data-uri('image/svg+xml;charset=UTF-8', './octicons/chevron-right-hover.svg'); + } + } +} diff --git a/src/adapters/github.js b/src/adapters/github.js index 37dd400..d94e863 100644 --- a/src/adapters/github.js +++ b/src/adapters/github.js @@ -16,22 +16,16 @@ const GH_PJAX_CONTAINER_SEL = '#js-repo-pjax-container, .context-loader-containe const GH_CONTAINERS = '.container, .container-responsive' const GH_RAW_CONTENT = 'body > pre' -class GitHub extends Adapter { +class GitHub extends PjaxAdapter { constructor() { super(['jquery.pjax.js']) - - $.pjax.defaults.timeout = 0 // no timeout - $(document) - .on('pjax:send', () => $(document).trigger(EVENT.REQ_START)) - .on('pjax:end', () => $(document).trigger(EVENT.REQ_END)) } // @override init($sidebar) { - super.init($sidebar) - - if (!window.MutationObserver) return + const pjaxContainer = $(GH_PJAX_CONTAINER_SEL)[0] + super.init($sidebar, {'pjaxContainer': pjaxContainer}) // Fix #151 by detecting when page layout is updated. // In this case, split-diff page has a wider layout, so need to recompute margin. @@ -50,46 +44,6 @@ class GitHub extends Adapter { attributeFilter: ['class'], attributeOldValue: true }) - - // GitHub switch pages using pjax. This observer detects if the pjax container - // has been updated with new contents and trigger layout. - const pageChangeObserver = new window.MutationObserver(() => { - // Trigger location change, can't just relayout as Octotree might need to - // hide/show depending on whether the current page is a code page or not. - return $(document).trigger(EVENT.LOC_CHANGE) - }) - - const pjaxContainer = $(GH_PJAX_CONTAINER_SEL)[0] - - if (pjaxContainer) { - pageChangeObserver.observe(pjaxContainer, { - childList: true, - }) - } - else { // Fall back if DOM has been changed - let firstLoad = true, href, hash - - function detectLocChange() { - if (location.href !== href || location.hash !== hash) { - href = location.href - hash = location.hash - - // If this is the first time this is called, no need to notify change as - // Octotree does its own initialization after loading options. - if (firstLoad) { - firstLoad = false - } - else { - setTimeout(() => { - $(document).trigger(EVENT.LOC_CHANGE) - }, 300) // Wait a bit for pjax DOM change - } - } - setTimeout(detectLocChange, 200) - } - - detectLocChange() - } } // @override @@ -180,17 +134,7 @@ class GitHub extends Adapter { // @override selectFile(path) { const $pjaxContainer = $(GH_PJAX_CONTAINER_SEL) - - if ($pjaxContainer.length) { - $.pjax({ - // needs full path for pjax to work with Firefox as per cross-domain-content setting - url: location.protocol + '//' + location.host + path, - container: $pjaxContainer - }) - } - else { // falls back - super.selectFile(path) - } + super.selectFile(path, {'$pjaxContainer': $pjaxContainer}) } // @override diff --git a/src/config/chrome/manifest.json b/src/config/chrome/manifest.json index 716e9d3..ce4fe80 100755 --- a/src/config/chrome/manifest.json +++ b/src/config/chrome/manifest.json @@ -9,6 +9,7 @@ "128": "icons/icon128.png" }, "permissions": [ + "https://bitbucket.org/*", "https://github.com/*", "storage" ], diff --git a/src/config/firefox/firefox.js b/src/config/firefox/firefox.js index f58e022..96fc06e 100644 --- a/src/config/firefox/firefox.js +++ b/src/config/firefox/firefox.js @@ -2,7 +2,7 @@ const data = require('sdk/self').data const pageMod = require('sdk/page-mod') pageMod.PageMod({ - include: ['https://github.com/*'], + include: ['https://bitbucket.org/*', 'https://github.com/*'], contentScriptFile : [data.url('jquery.js'), data.url('jquery-ui.js'), data.url('jstree.js'), diff --git a/src/config/firefox/package.json b/src/config/firefox/package.json index cd7a416..101c088 100644 --- a/src/config/firefox/package.json +++ b/src/config/firefox/package.json @@ -12,7 +12,9 @@ "version": "$VERSION", "permissions": { "cross-domain-content": [ + "https://api.bitbucket.org", "https://api.github.com", + "https://bitbucket.org", "https://github.com" ], "private-browsing": true, diff --git a/src/config/safari/Info.plist b/src/config/safari/Info.plist index 61a0b04..8620fe5 100755 --- a/src/config/safari/Info.plist +++ b/src/config/safari/Info.plist @@ -39,6 +39,7 @@ Whitelist + https://bitbucket.org/* https://github.com/* @@ -52,6 +53,7 @@ Allowed Domains + bitbucket.org github.com Include Secure Pages diff --git a/src/octotree.js b/src/octotree.js index 56a19c4..2554f0a 100755 --- a/src/octotree.js +++ b/src/octotree.js @@ -11,7 +11,20 @@ $(document).ready(() => { } function createAdapter() { - return new GitHub(store) + const normalizeUrl = (url) => url.replace(/(.*?:\/\/[^/]+)(.*)/, '$1') + + const githubUrls = store.get(STORE.GHEURLS).split(/\n/) + .map(normalizeUrl) + .concat('https://github.com') + + const bitbucketUrls = ['https://bitbucket.org'] + const currentUrl = `${location.protocol}//${location.host}` + + if (~githubUrls.indexOf(currentUrl)) { + return new GitHub(store) + } else if (~bitbucketUrls.indexOf(currentUrl)) { + return new Bitbucket(store) + } } function loadExtension() { @@ -110,7 +123,8 @@ $(document).ready(() => { } if (isSidebarVisible()) { - const repoChanged = JSON.stringify(repo) !== JSON.stringify(currRepo) + const replacer = ['username', 'reponame', 'branch'] + const repoChanged = JSON.stringify(repo, replacer) !== JSON.stringify(currRepo, replacer) if (repoChanged || reload === true) { $document.trigger(EVENT.REQ_START) diff --git a/src/styles/octotree.less b/src/styles/octotree.less index ea0cb1e..6a638e9 100644 --- a/src/styles/octotree.less +++ b/src/styles/octotree.less @@ -1,2 +1,3 @@ @import "base"; +@import "../adapters/bitbucket"; @import "../adapters/github"; diff --git a/src/view.options.js b/src/view.options.js index 8fe9920..d631183 100644 --- a/src/view.options.js +++ b/src/view.options.js @@ -49,18 +49,20 @@ class OptionsView { */ // @ifdef CHROME const $ta = this.$view.find('[data-store$=EURLS]').filter(':visible') - const storeKey = $ta.data('store') - const urls = $ta.val().split(/\n/).filter((url) => url !== '') + if ($ta.length > 0) { + const storeKey = $ta.data('store') + const urls = $ta.val().split(/\n/).filter((url) => url !== '') - if (urls.length > 0) { - chrome.runtime.sendMessage({type: 'requestPermissions', urls: urls}, (granted) => { - if (!granted) { - // permissions not granted (by user or error), reset value - $ta.val(this.store.get(STORE[storeKey])) - } - this._saveOptions() - }) - return + if (urls.length > 0) { + chrome.runtime.sendMessage({type: 'requestPermissions', urls: urls}, (granted) => { + if (!granted) { + // permissions not granted (by user or error), reset value + $ta.val(this.store.get(STORE[storeKey])) + } + this._saveOptions() + }) + return + } } // @endif