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 (
+
+
+
+
+
+ order
+
+
+
+
+
+
+
+
+
+ key |
+ age |
+ hits |
+ content |
+
+
+
+ {rows.map(row => (
+
+
+
+ |
+
+ {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;
+ }
+}