diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index bc49929d33..215770f173 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -23,6 +23,7 @@ import GitTabController from './git-tab-controller'; import StatusBarTileController from './status-bar-tile-controller'; import RepositoryConflictController from './repository-conflict-controller'; import GithubLoginModel from '../models/github-login-model'; +import GitCacheView from '../views/git-cache-view'; import Conflict from '../models/conflicts/conflict'; import RefHolder from '../models/ref-holder'; import Switchboard from '../switchboard'; @@ -115,6 +116,7 @@ export default class RootController extends React.Component { {devMode && } + @@ -343,6 +345,9 @@ export default class RootController extends React.Component { {({itemHolder}) => } + + {({itemHolder}) => } + ); } @@ -404,6 +409,11 @@ export default class RootController extends React.Component { this.props.workspace.open(GitTimingsView.buildURI()); } + @autobind + showCacheDiagnostics() { + this.props.workspace.open(GitCacheView.buildURI()); + } + @autobind async acceptClone(remoteUrl, projectPath) { this.setState({cloneDialogInProgress: true}); diff --git a/lib/models/branch-set.js b/lib/models/branch-set.js index 94371cb033..afd8c41df0 100644 --- a/lib/models/branch-set.js +++ b/lib/models/branch-set.js @@ -1,3 +1,5 @@ +import util from 'util'; + import {nullBranch} from './branch'; function pushAtKey(map, key, value) { @@ -60,4 +62,8 @@ export default class BranchSet { getPushSources(remoteName, remoteRefName) { return this.byPushRef.get(`${remoteName}\0${remoteRefName}`) || []; } + + [util.inspect.custom](depth, options) { + return `BranchSet {${util.inspect(this.all)}}`; + } } diff --git a/lib/models/branch.js b/lib/models/branch.js index aba10e3f20..2f7307cfee 100644 --- a/lib/models/branch.js +++ b/lib/models/branch.js @@ -1,3 +1,5 @@ +import util from 'util'; + const DETACHED = Symbol('detached'); const REMOTE_TRACKING = Symbol('remote-tracking'); @@ -132,4 +134,8 @@ export const nullBranch = { isPresent() { return false; }, + + [util.inspect.custom](depth, options) { + return '{nullBranch}'; + }, }; diff --git a/lib/models/repository-states/present.js b/lib/models/repository-states/present.js index 1342b6bf6b..4e02cf4f16 100644 --- a/lib/models/repository-states/present.js +++ b/lib/models/repository-states/present.js @@ -1,4 +1,5 @@ import path from 'path'; +import {Emitter} from 'event-kit'; import fs from 'fs-extra'; import State from './state'; @@ -87,6 +88,11 @@ export default class Present extends State { return true; } + destroy() { + this.cache.destroy(); + super.destroy(); + } + showStatusBarTiles() { return true; } @@ -695,6 +701,11 @@ export default class Present extends State { getLastHistorySnapshots(partialDiscardFilePath = null) { return this.discardHistory.getLastSnapshots(partialDiscardFilePath); } + + // Cache + getCache() { + return this.cache; + } } State.register(Present); @@ -818,18 +829,25 @@ class Cache { constructor() { this.storage = new Map(); this.byGroup = new Map(); + + this.emitter = new Emitter(); } getOrSet(key, operation) { const primary = key.getPrimary(); const existing = this.storage.get(primary); if (existing !== undefined) { - return existing; + existing.hits++; + return existing.promise; } const created = operation(); - this.storage.set(primary, created); + this.storage.set(primary, { + createdAt: performance.now(), + hits: 0, + promise: created, + }); const groups = key.getGroups(); for (let i = 0; i < groups.length; i++) { @@ -842,6 +860,8 @@ class Cache { groupSet.add(key); } + this.didUpdate(); + return created; } @@ -849,6 +869,10 @@ class Cache { for (let i = 0; i < keys.length; i++) { keys[i].removeFromCache(this); } + + if (keys.length > 0) { + this.didUpdate(); + } } keysInGroup(group) { @@ -857,16 +881,35 @@ class Cache { removePrimary(primary) { this.storage.delete(primary); + this.didUpdate(); } removeFromGroup(group, key) { const groupSet = this.byGroup.get(group); groupSet && groupSet.delete(key); + this.didUpdate(); + } + + [Symbol.iterator]() { + return this.storage[Symbol.iterator](); } clear() { this.storage.clear(); this.byGroup.clear(); + this.didUpdate(); + } + + didUpdate() { + this.emitter.emit('did-update'); + } + + onDidUpdate(callback) { + return this.emitter.on('did-update', callback); + } + + destroy() { + this.emitter.dispose(); } } diff --git a/lib/models/repository-states/state.js b/lib/models/repository-states/state.js index 7008d2d246..6ac1a85cca 100644 --- a/lib/models/repository-states/state.js +++ b/lib/models/repository-states/state.js @@ -448,6 +448,13 @@ export default class State { return ''; } + // Cache + + @shouldDelegate + getCache() { + return null; + } + // Internal ////////////////////////////////////////////////////////////////////////////////////////////////////////// // Non-delegated methods that provide subclasses with convenient access to Repository properties. diff --git a/lib/models/repository.js b/lib/models/repository.js index 9bfc75b187..e09bac85cd 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -347,6 +347,7 @@ const delegates = [ 'setCommitMessage', 'getCommitMessage', + 'getCache', ]; for (let i = 0; i < delegates.length; i++) { diff --git a/lib/views/git-cache-view.js b/lib/views/git-cache-view.js new file mode 100644 index 0000000000..0998b8ca85 --- /dev/null +++ b/lib/views/git-cache-view.js @@ -0,0 +1,216 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {autobind} from 'core-decorators'; +import {inspect} from 'util'; + +import ObserveModel from './observe-model'; + +const sortOrders = { + 'by key': (a, b) => a.key.localeCompare(b.key), + 'oldest first': (a, b) => b.age - a.age, + 'newest first': (a, b) => a.age - b.age, + 'most hits': (a, b) => b.hits - a.hits, + 'fewest hits': (a, b) => a.hits - b.hits, +}; + +export default class GitCacheView extends React.Component { + static uriPattern = 'atom-github://debug/cache' + + static buildURI() { + return this.uriPattern; + } + + static propTypes = { + repository: PropTypes.object.isRequired, + } + + constructor(props, context) { + super(props, context); + + this.state = { + order: 'by key', + }; + } + + getURI() { + return 'atom-github://debug/cache'; + } + + getTitle() { + return 'GitHub Package Cache View'; + } + + serialize() { + return null; + } + + @autobind + fetchRepositoryData(repository) { + return repository.getCache(); + } + + @autobind + fetchCacheData(cache) { + const cached = {}; + const promises = []; + const now = performance.now(); + + for (const [key, value] of cache) { + cached[key] = { + hits: value.hits, + age: now - value.createdAt, + }; + + promises.push( + value.promise + .then( + payload => inspect(payload, {depth: 3, breakLength: 30}), + err => `${err.message}\n${err.stack}`, + ) + .then(resolved => { cached[key].value = resolved; }), + ); + } + + return Promise.all(promises).then(() => cached); + } + + render() { + return ( + + {cache => ( + + {this.renderCache} + + )} + + ); + } + + @autobind + renderCache(contents) { + const rows = Object.keys(contents || {}).map(key => { + return { + key, + age: contents[key].age, + hits: contents[key].hits, + content: contents[key].value, + }; + }); + + rows.sort(sortOrders[this.state.order]); + + const orders = Object.keys(sortOrders); + + return ( +
+
+

Cache contents

+

+ {rows.length} cached items +

+
+
+

+ + order + + + + + +

+ + + + + + + + + + + {rows.map(row => ( + + + + + + + ))} + +
keyagehitscontent
+ + + {this.formatAge(row.age)} + + {row.hits} + + {row.content} +
+
+
+ ); + } + + formatAge(ageMs) { + let remaining = ageMs; + const parts = []; + + if (remaining > 3600000) { + const hours = Math.floor(remaining / 3600000); + parts.push(`${hours}h`); + remaining -= (3600000 * hours); + } + + if (remaining > 60000) { + const minutes = Math.floor(remaining / 60000); + parts.push(`${minutes}m`); + remaining -= (60000 * minutes); + } + + if (remaining > 1000) { + const seconds = Math.floor(remaining / 1000); + parts.push(`${seconds}s`); + remaining -= (1000 * seconds); + } + + parts.push(`${Math.floor(remaining)}ms`); + + return parts.slice(parts.length - 2).join(' '); + } + + @autobind + didSelectItem(event) { + this.setState({order: event.target.value}); + } + + didClickKey(key) { + const cache = this.props.repository.getCache(); + if (!cache) { + return; + } + + cache.removePrimary(key); + } + + @autobind + clearCache() { + const cache = this.props.repository.getCache(); + if (!cache) { + return; + } + + cache.clear(); + } +} diff --git a/styles/cache-view.less b/styles/cache-view.less new file mode 100644 index 0000000000..0847e9aff5 --- /dev/null +++ b/styles/cache-view.less @@ -0,0 +1,64 @@ +@import "variables"; + +.github-CacheView { + width: 100%; + height: 100%; + overflow-y: auto; + + header { + text-align: center; + border-bottom: 1px solid @base-border-color; + padding: @component-padding; + } + + &-Controls { + padding: 0 @component-padding; + text-align: right; + } + + &-Clear { + margin-left: 1em; + } + + &-Order select { + margin-left: 0.5em; + } + + table { + margin: 10px 20px; + width: 90%; + } + + thead { + color: @text-color-highlight; + background-color: @background-color-highlight; + font-weight: bold; + } + + td { + padding: @component-padding / 2; + vertical-align: top; + } + + &-Key { + button { + width: 100%; + font-family: monospace; + text-align: left; + } + } + + &-Age { + min-width: 100px; + } + + &-Hits { + min-width: 50px; + } + + &-Content { + white-space: pre-wrap; + max-height: 100px; + overflow-y: scroll; + } +}