Merge pull request #190 from crashbell/master

Add Lazy Load Option
This commit is contained in:
Buu Nguyen 2015-10-05 14:18:12 -07:00
commit 77282c0a52
16 changed files with 314 additions and 172 deletions

View File

@ -50,6 +50,9 @@ By default, Octotree only works on `github.com`. To support GitHub Enterprise on
## Changelog
### v1.7.2
* Fix bug long branches are not loaded correctly due to GitHub DOM change
### v1.7.1
* Fix space between tree and GitHub contents due to GitHub DOM change

BIN
dist/chrome.crx vendored

Binary file not shown.

BIN
dist/chrome.zip vendored

Binary file not shown.

BIN
dist/firefox.xpi vendored

Binary file not shown.

BIN
dist/opera.nex vendored

Binary file not shown.

View File

@ -7,12 +7,13 @@ const
'search', 'developer', 'account'
]
, GH_RESERVED_REPO_NAMES = ['followers', 'following', 'repositories']
, GH_BRANCH_SEL = '[aria-label="Switch branches or tags"]'
, GH_404_SEL = '#parallax_wrapper'
, GH_PJAX_SEL = '#js-repo-pjax-container'
, GH_CONTAINERS = '.container'
, GH_404_SEL = '#parallax_wrapper'
, GH_PJAX_SEL = '#js-repo-pjax-container'
, GH_CONTAINERS = '.container'
function GitHub() {
this._defaultBranch = {}
if (!window.MutationObserver) return
// Fix #151 by detecting when page layout is updated.
@ -36,24 +37,9 @@ function GitHub() {
}
/**
* Selects a submodule.
*/
GitHub.prototype.selectSubmodule = function(path) {
window.location.href = path
}
/**
* Downloads the file at the given
*/
GitHub.prototype.downloadFile = function(path, fileName) {
var link = document.createElement('a')
link.setAttribute('href', path.replace(/\/blob\//, '/raw/'))
link.setAttribute('download', fileName)
link.click()
}
/**
* Selects a path.
* Selects a file.
* @param {String} path - the file path.
* @param {Number} tabSize - the tab size to use.
*/
GitHub.prototype.selectFile = function(path, tabSize) {
var container = $(GH_PJAX_SEL)
@ -69,8 +55,30 @@ GitHub.prototype.selectFile = function(path, tabSize) {
else window.location.href = path + qs // falls back if no container (i.e. GitHub DOM has changed or is not yet available)
}
/**
* Downloads a file
* @param {String} path - the file path.
* @param {String} fileName - the file name.
*/
GitHub.prototype.downloadFile = function(path, fileName) {
var link = document.createElement('a')
link.setAttribute('href', path.replace(/\/blob\//, '/raw/'))
link.setAttribute('download', fileName)
link.click()
}
/**
* Selects a submodule
* @param {String} path - the submodule path.
*/
GitHub.prototype.selectSubmodule = function(path) {
window.location.href = path
}
/**
* Updates page layout based on visibility status and width of the Octotree sidebar.
* @param {Boolean} sidebarVisible - current visibility of the sidebar.
* @param {Number} sidebarWidth - current width of the sidebar.
*/
GitHub.prototype.updateLayout = function(sidebarVisible, sidebarWidth) {
var $containers = $(GH_CONTAINERS)
@ -89,50 +97,83 @@ GitHub.prototype.updateLayout = function(sidebarVisible, sidebarWidth) {
}
/**
* Returns the repository information if user is at a repository URL. Returns `null` otherwise.
* Retrieves the repository info at the current location.
* @param {Boolean} showInNonCodePage - if false, should not return data in non-code pages.
* @param {Object} currentRepo - current repo being shown by Octotree.
* @param {String} token - the personal access token.
* @param {Function} cb - the callback function.
*/
GitHub.prototype.getRepoFromPath = function(showInNonCodePage, currentRepo) {
GitHub.prototype.getRepoFromPath = function(showInNonCodePage, currentRepo, token, cb) {
// 404 page, skip
if ($(GH_404_SEL).length) return false
if ($(GH_404_SEL).length) {
return cb()
}
// (username)/(reponame)[/(type)]
var match = window.location.pathname.match(/([^\/]+)\/([^\/]+)(?:\/([^\/]+))?/)
if (!match) return false
if (!match) {
return cb()
}
var username = match[1]
var reponame = match[2]
// not a repository, skip
if (~GH_RESERVED_USER_NAMES.indexOf(match[1])) return false
if (~GH_RESERVED_REPO_NAMES.indexOf(match[2])) return false
if (~GH_RESERVED_USER_NAMES.indexOf(username) ||
~GH_RESERVED_REPO_NAMES.indexOf(reponame)) {
return cb()
}
// skip non-code page unless showInNonCodePage is true
if (!showInNonCodePage && match[3] && !~['tree', 'blob'].indexOf(match[3])) return false
if (!showInNonCodePage && match[3] && !~['tree', 'blob'].indexOf(match[3])) {
return cb()
}
// get branch by inspecting page, quite fragile so provide multiple fallbacks
var branch =
$(GH_BRANCH_SEL).data('ref') ||
$(GH_BRANCH_SEL).children('.js-select-button').text() ||
(currentRepo.username === match[1] && currentRepo.reponame === match[2] && currentRepo.branch) ||
'master'
var GH_BRANCH_SEL_1 = '[aria-label="Switch branches or tags"]'
var GH_BRANCH_SEL_2 = '.repo-root a[data-branch]'
var GH_BRANCH_SEL_3 = '.repository-sidebar a[aria-label="Code"]'
return {
username : match[1],
reponame : match[2],
branch : branch
var branch =
// Code page
$(GH_BRANCH_SEL_1).attr('title') || $(GH_BRANCH_SEL_2).data('branch') ||
// Non-code page
($(GH_BRANCH_SEL_3).attr('href') || '').match(/([^\/]+)/g)[3] ||
// Assume same with previously
(currentRepo.username === username && currentRepo.reponame === reponame && currentRepo.branch) ||
// Default from cache
this._defaultBranch[username + '/' + reponame]
var repo = {username: username, reponame: reponame, branch: branch}
if (repo.branch) {
cb(null, repo)
}
else {
this._get(repo, null, token, function (err, data) {
if (err) return cb(err)
repo.branch = this._defaultBranch[username + '/' + reponame] = data.default_branch || 'master'
cb(null, repo)
}.bind(this))
}
}
/**
* Fetches data of a particular repository.
* @param opts: { repo: repository, token (optional): user access token, apiUrl (optional): base API URL }
* @param cb(err: error, tree: array (of arrays) of items)
* Retrieves the code tree of a repository.
* @param {Object} opts: { repo: repository, node(optional): selected node (null for resursively loading), token (optional): user access token, apiUrl (optional): base API URL }
* @param {Function} cb(err: error, tree: array (of arrays) of items)
*/
GitHub.prototype.fetchData = function(opts, cb) {
var self = this
, repo = opts.repo
, folders = { '': [] }
GitHub.prototype.getCodeTree = function(opts, cb) {
var self = this
, folders = { '': [] }
, repo = opts.repo
, token = opts.token
, encodedBranch = encodeURIComponent(decodeURIComponent(repo.branch))
, $dummyDiv = $('<div/>')
, $dummyDiv = $('<div/>')
getTree(encodedBranch + '?recursive=true', function(err, tree) {
var treePath = (opts.node && (opts.node.sha || encodedBranch)) || (encodedBranch + '?recursive=1')
getTree(treePath, function(err, tree) {
if (err) return cb(err)
fetchSubmodules(function(err, submodules) {
@ -153,6 +194,10 @@ GitHub.prototype.fetchData = function(opts, cb) {
// we're done
if (item === undefined) return cb(null, folders[''])
// includes parent path
if (opts.node && opts.node.path)
item.path = opts.node.path + '/' + item.path
path = item.path
type = item.type
index = path.lastIndexOf('/')
@ -162,10 +207,16 @@ GitHub.prototype.fetchData = function(opts, cb) {
item.text = name
item.icon = type // use `type` as class name for tree node
folders[path.substring(0, index)].push(item)
if (opts.node) {
// no hierarchy in lazy loading
folders[''].push(item)
}
else
folders[path.substring(0, index)].push(item)
if (type === 'tree') {
folders[item.path] = item.children = []
if (opts.node) item.children = true
else folders[item.path] = item.children = []
item.a_attr = { href: '#' }
}
else if (type === 'blob') {
@ -206,79 +257,81 @@ GitHub.prototype.fetchData = function(opts, cb) {
})
function getTree(tree, cb) {
get('/git/trees/' + tree, function(err, res) {
self._get(repo, '/git/trees/' + tree, token, function(err, res) {
if (err) return cb(err)
cb(null, res.tree)
})
}
function getBlob(sha, cb) {
get('/git/blobs/' + sha, function(err, res) {
self._get(repo, '/git/blobs/' + sha, token, function(err, res) {
if (err) return cb(err)
cb(null, atob(res.content.replace(/\n/g,'')))
})
}
function get(path, cb) {
var token = opts.token
, host = (location.host === 'github.com' ? 'api.github.com' : (location.host + '/api/v3'))
, base = location.protocol + '//' + host + '/repos/' + repo.username + '/' + repo.reponame
, cfg = { method: 'GET', url: base + path, cache: false }
if (token) cfg.headers = { Authorization: 'token ' + token }
$.ajax(cfg)
.done(function(data) {
cb(null, data)
})
.fail(function(jqXHR) {
var createTokenUrl = location.protocol + '//' + location.host + '/settings/tokens/new'
, error
, message
, needAuth
switch (jqXHR.status) {
case 0:
error = 'Connection error'
message = 'Cannot connect to GitHub. If your network connection to GitHub is fine, maybe there is an outage of the GitHub API. Please try again later.'
needAuth = false
break
case 401:
error = 'Invalid token'
message = 'The token is invalid. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create a new token and paste it below.'
needAuth = true
break
case 409:
error = 'Empty repository'
message = 'This repository is empty.'
break
case 404:
error = 'Private repository'
message = 'Accessing private repositories requires a GitHub access token. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
case 403:
if (~jqXHR.getAllResponseHeaders().indexOf('X-RateLimit-Remaining: 0')) {
error = 'API limit exceeded'
message = 'You have exceeded the GitHub API hourly limit and need GitHub access token to make extra requests. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
}
else {
error = 'Forbidden'
message = 'You are not allowed to access the API. You might need to provide an access token. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
}
default:
error = message = jqXHR.statusText
needAuth = false
break
}
cb({
error : 'Error: ' + error,
message : message,
needAuth : needAuth,
})
})
}
}
GitHub.prototype._get = function(repo, path, token, cb) {
var host = (location.host === 'github.com' ? 'api.github.com' : (location.host + '/api/v3'))
, base = location.protocol + '//' + host + '/repos/' + repo.username + '/' + repo.reponame
, cfg = { method: 'GET', url: base + (path || ''), cache: false }
if (token) {
cfg.headers = { Authorization: 'token ' + token }
}
$.ajax(cfg)
.done(function(data) {
cb(null, data)
})
.fail(function(jqXHR) {
var createTokenUrl = location.protocol + '//' + location.host + '/settings/tokens/new'
, error
, message
, needAuth
switch (jqXHR.status) {
case 0:
error = 'Connection error'
message = 'Cannot connect to GitHub. If your network connection to GitHub is fine, maybe there is an outage of the GitHub API. Please try again later.'
needAuth = false
break
case 401:
error = 'Invalid token'
message = 'The token is invalid. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create a new token and paste it below.'
needAuth = true
break
case 409:
error = 'Empty repository'
message = 'This repository is empty.'
break
case 404:
error = 'Private repository'
message = 'Accessing private repositories requires a GitHub access token. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
case 403:
if (~jqXHR.getAllResponseHeaders().indexOf('X-RateLimit-Remaining: 0')) {
error = 'API limit exceeded'
message = 'You have exceeded the GitHub API hourly limit and need GitHub access token to make extra requests. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
}
else {
error = 'Forbidden'
message = 'You are not allowed to access the API. You might need to provide an access token. Follow <a href="' + createTokenUrl + '" target="_blank">this link</a> to create one and paste it below.'
needAuth = true
break
}
default:
error = message = jqXHR.statusText
needAuth = false
break
}
cb({
error : 'Error: ' + error,
message : message,
needAuth : needAuth,
})
})
}

View File

@ -1,6 +1,6 @@
{
"name": "Octotree",
"version": "1.7.1",
"version": "2.0.0",
"manifest_version": 2,
"author": "Buu Nguyen",
"description": "Display GitHub code in tree format",

View File

@ -2,17 +2,18 @@ const
PREFIX = 'octotree'
, STORE = {
TOKEN : 'octotree.github_access_token',
COLLAPSE : 'octotree.collapse',
TABSIZE : 'octotree.tabsize',
REMEMBER : 'octotree.remember',
LAZYLOAD : 'octotree.lazyload',
HOTKEYS : 'octotree.hotkeys',
GHEURLS : 'octotree.gheurls',
WIDTH : 'octotree.sidebar_width',
POPUP : 'octotree.popup_shown',
SHOWN : 'octotree.sidebar_shown',
NONCODE : 'octotree.noncode_shown',
TOKEN : 'octotree.github_access_token',
COLLAPSE : 'octotree.collapse',
TABSIZE : 'octotree.tabsize',
REMEMBER : 'octotree.remember',
LAZYLOAD : 'octotree.lazyload',
RECURSIVE : 'octotree.recursive',
HOTKEYS : 'octotree.hotkeys',
GHEURLS : 'octotree.gheurls',
WIDTH : 'octotree.sidebar_width',
POPUP : 'octotree.popup_shown',
SHOWN : 'octotree.sidebar_shown',
NONCODE : 'octotree.noncode_shown',
}
, DEFAULTS = {
@ -21,6 +22,7 @@ const
TABSIZE : '',
REMEMBER : false,
LAZYLOAD : false,
RECURSIVE: true,
// @ifdef SAFARI
HOTKEYS : '⌘+b, ⌃+b',
// @endif
@ -43,4 +45,5 @@ const
OPTS_CHANGE : 'octotree:change',
VIEW_READY : 'octotree:ready',
VIEW_CLOSE : 'octotree:close',
FETCH_ERROR : 'octotree:error'
}

View File

@ -11,7 +11,7 @@
"icon": "data/icons/icon48.png",
"icon64": "data/icons/icon64.png",
"license": "MIT",
"version": "1.7.1",
"version": "2.0.0",
"permissions": {
"cross-domain-content": ["https://api.github.com", "https://github.com"]
}

View File

@ -50,6 +50,9 @@ $(document).ready(function() {
showView(hasError ? errorView.$view : treeView.$view)
})
.on(EVENT.OPTS_CHANGE, optionsChanged)
.on(EVENT.FETCH_ERROR, function(event, err) {
errorView.show(err)
})
})
$document
@ -81,6 +84,9 @@ $(document).ready(function() {
key.unbind(value[0])
key(value[1], toggleSidebar)
break
case STORE.RECURSIVE:
reload = true
break
}
})
if (reload) tryLoadRepo(true)
@ -92,33 +98,34 @@ $(document).ready(function() {
, shown = store.get(STORE.SHOWN)
, lazyload = store.get(STORE.LAZYLOAD)
, token = store.get(STORE.TOKEN)
, repo = adapter.getRepoFromPath(showInNonCodePage, currRepo)
if (repo) {
$toggler.show()
helpPopup.show()
if (remember && shown) toggleSidebar(true)
if (!lazyload || isSidebarVisible()) {
var repoChanged = JSON.stringify(repo) !== JSON.stringify(currRepo)
if (repoChanged || reload === true) {
$document.trigger(EVENT.REQ_START)
currRepo = repo
treeView.showHeader(repo)
adapter.fetchData({ repo: repo, token: token }, function(err, tree) {
if (err) errorView.show(err)
else treeView.show(repo, tree)
})
}
else treeView.syncSelection()
adapter.getRepoFromPath(showInNonCodePage, currRepo, token, function(err, repo) {
if (err) {
errorView.show(err)
}
}
else {
$toggler.hide()
toggleSidebar(false)
}
else if (repo) {
$toggler.show()
helpPopup.show()
if (remember && shown) toggleSidebar(true)
if (!lazyload || isSidebarVisible()) {
var repoChanged = JSON.stringify(repo) !== JSON.stringify(currRepo)
if (repoChanged || reload === true) {
$document.trigger(EVENT.REQ_START)
currRepo = repo
treeView.showHeader(repo)
treeView.show(repo, token)
}
else treeView.syncSelection()
}
}
else {
$toggler.hide()
toggleSidebar(false)
}
})
}
function showView(view) {

View File

@ -202,6 +202,11 @@
label {
font-weight: normal !important;
}
label.disabled {
color: gray;
}
input[type=text], textarea {
width: 100%;
}

View File

@ -13,9 +13,9 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>1.7.1</string>
<string>2.0.0</string>
<key>CFBundleVersion</key>
<string>1.7.1</string>
<string>2.0.0</string>
<key>Chrome</key>
<dict/>
<key>Content</key>

View File

@ -53,7 +53,10 @@
<label><input type="checkbox" data-store="LAZYLOAD"> Only load tree when sidebar is open</label>
</div>
<div>
<label><input type="checkbox" data-store="COLLAPSE"> Collapse folders with single sub-folder</label>
<label><input type="checkbox" data-store="RECURSIVE"> Load repository recursively</label>
</div>
<div>
<label><input type="checkbox" data-store="COLLAPSE" data-trigger-disable="RECURSIVE"> Collapse folders with single sub-folder</label>
</div>
<div>
<div>

View File

@ -1,13 +1,24 @@
$(document).ready(function() {
// When navigating from non-code pages (i.e. Pulls, Issues) to code page
// GitHub doesn't reload the page but uses pjax. Need to detect and load Octotree.
var href, hash
var firstLoad = true, href, hash
function detectLocationChange() {
if (location.href !== href || location.hash !== hash) {
href = location.href
hash = location.hash
$(document).trigger(EVENT.LOC_CHANGE, href, 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(function () {
$(document).trigger(EVENT.LOC_CHANGE, href, hash)
}, 200) // Waits a bit for pjax DOM change
}
}
setTimeout(detectLocationChange, 200)
}
detectLocationChange()

View File

@ -6,6 +6,23 @@ function OptionsView($dom, store) {
this.$view = $view
$(document).ready(function() {
function triggerChange(checkbox) {
var store = $(checkbox).data('store')
, checkboxs = $view.find('[data-trigger-disable=' + store + ']')
checkboxs.prop('disabled', !checkbox.checked).closest('label').toggleClass('disabled', !checkbox.checked)
}
eachOption(
function($elm) {
// triggers to disable all checkboxs having data-trigger-disable
$elm.change(function(event) {
triggerChange(event.target)
})
}
)
})
// hide options view when sidebar is hidden
$(document).on(EVENT.TOGGLE, function(event, visible) {
if (!visible) toggle(false)
@ -23,7 +40,8 @@ function OptionsView($dom, store) {
else {
eachOption(
function($elm, key, local, value, cb) {
if ($elm.is(':checkbox')) $elm.prop('checked', value)
// Original jQuery prop function doesn't trigger change event
if ($elm.is(':checkbox')) $elm.prop('checked', value).trigger("change")
else $elm.val(value)
cb()
},

View File

@ -74,15 +74,30 @@ TreeView.prototype.showHeader = function(repo) {
})
}
TreeView.prototype.show = function(repo, treeData) {
TreeView.prototype.show = function(repo, token) {
var self = this
, treeContainer = self.$view.find('.octotree_view_body')
, tree = treeContainer.jstree(true)
, collapseTree = self.store.get(STORE.COLLAPSE)
, recursiveLoad = self.store.get(STORE.RECURSIVE)
treeData = sort(treeData)
if (collapseTree) treeData = collapse(treeData)
tree.settings.core.data = treeData
function fetchData(node, success) {
var selectedNode = node.original
if (node.id === '#') selectedNode = {path: ''}
self.adapter.getCodeTree({ repo: repo, token: token, node: recursiveLoad ? null : selectedNode}, function(err, treeData) {
if (err) $(self).trigger(EVENT.FETCH_ERROR, [err])
else success(treeData)
})
}
tree.settings.core.data = function (node, cb) {
fetchData(node, function(treeData) {
treeData = sort(treeData)
if (collapseTree && recursiveLoad)
treeData = collapse(treeData)
cb(treeData)
})
}
treeContainer.one('refresh.jstree', function() {
self.syncSelection()
@ -97,7 +112,7 @@ TreeView.prototype.show = function(repo, treeData) {
return a.type === 'blob' ? 1 : -1
})
folder.forEach(function(item) {
if (item.type === 'tree') sort(item.children)
if (item.type === 'tree' && item.children !== true && item.children.length > 0) sort(item.children)
})
return folder
}
@ -119,17 +134,41 @@ TreeView.prototype.show = function(repo, treeData) {
TreeView.prototype.syncSelection = function() {
var tree = this.$view.find('.octotree_view_body').jstree(true)
, path = location.pathname
, path = decodeURIComponent(location.pathname)
, recursiveLoad = this.store.get(STORE.RECURSIVE)
if (!tree) return
tree.deselect_all()
// e.g. converts /buunguyen/octotree/type/branch/path to path
var match = path.match(/(?:[^\/]+\/){4}(.*)/)
, nodeId
if (match) {
nodeId = PREFIX + decodeURIComponent(match[1])
tree.select_node(nodeId)
tree.open_node(nodeId)
if (!match) return
currentPath = match[1]
// e.g. converts ["lib/controllers"] to ["lib", "lib/controllers"]
function createPaths(fullPath) {
var paths = fullPath.split("/")
, arrResult = [paths[0]]
paths.reduce(function(lastPath, curPath) {
var path = (lastPath + "/" + curPath)
arrResult.push(path)
return path
})
return arrResult
}
function openPathAtIndex (paths, index) {
nodeId = PREFIX + paths[index]
if (tree.get_node(nodeId)) {
tree.deselect_all()
tree.select_node(nodeId)
tree.open_node(nodeId, function(node){
if (index < paths.length - 1) openPathAtIndex(paths, index + 1)
})
}
}
var paths = recursiveLoad ? [currentPath] : createPaths(currentPath)
openPathAtIndex(paths, 0)
}