Support BitBucket

This commit is contained in:
Xiao Tan 2017-05-16 14:34:21 +08:00 committed by Buu Nguyen
parent c5cfdb4e52
commit 770e1e145f
13 changed files with 515 additions and 76 deletions

View File

@ -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:

View File

@ -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',

View File

@ -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)
}
}
}

212
src/adapters/bitbucket.js Normal file
View File

@ -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}`
}
}

164
src/adapters/bitbucket.less Normal file
View File

@ -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');
}
}
}

View File

@ -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

View File

@ -9,6 +9,7 @@
"128": "icons/icon128.png"
},
"permissions": [
"https://bitbucket.org/*",
"https://github.com/*",
"storage"
],

View File

@ -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'),

View File

@ -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,

View File

@ -39,6 +39,7 @@
</array>
<key>Whitelist</key>
<array>
<string>https://bitbucket.org/*</string>
<string>https://github.com/*</string>
</array>
</dict>
@ -52,6 +53,7 @@
<dict>
<key>Allowed Domains</key>
<array>
<string>bitbucket.org</string>
<string>github.com</string>
</array>
<key>Include Secure Pages</key>

View File

@ -11,7 +11,20 @@ $(document).ready(() => {
}
function createAdapter() {
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)

View File

@ -1,2 +1,3 @@
@import "base";
@import "../adapters/bitbucket";
@import "../adapters/github";

View File

@ -49,6 +49,7 @@ class OptionsView {
*/
// @ifdef CHROME
const $ta = this.$view.find('[data-store$=EURLS]').filter(':visible')
if ($ta.length > 0) {
const storeKey = $ta.data('store')
const urls = $ta.val().split(/\n/).filter((url) => url !== '')
@ -62,6 +63,7 @@ class OptionsView {
})
return
}
}
// @endif
return this._saveOptions()