Add a tree view to the image changes view
This commit is contained in:
parent
0afea3a779
commit
3a134c7ab1
4 changed files with 442 additions and 30 deletions
|
@ -557,11 +557,13 @@ p.editable:hover i {
|
||||||
.repo-image-view .changes-container .change-side-controls {
|
.repo-image-view .changes-container .change-side-controls {
|
||||||
float: right;
|
float: right;
|
||||||
clear: both;
|
clear: both;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-image-view .changes-container .filter-input {
|
.repo-image-view .changes-container .filter-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 200px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-image-view .changes-container .result-count {
|
.repo-image-view .changes-container .result-count {
|
||||||
|
@ -903,6 +905,7 @@ p.editable:hover i {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#image-history-container {
|
#image-history-container {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
|
@ -959,8 +962,40 @@ p.editable:hover i {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#image-history-container .tags .tag.current {
|
#changes-tree-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container .node rect {
|
||||||
|
cursor: pointer;
|
||||||
|
fill: #fff;
|
||||||
|
fill-opacity: 1;
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container .node .change-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container .node text {
|
||||||
|
font: 12px sans-serif;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container .node.added text {
|
||||||
|
fill: rgb(32, 163, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container .node.removed text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
fill: rgb(209, 73, 73);
|
||||||
|
}
|
||||||
|
|
||||||
|
#changes-tree-container path.link {
|
||||||
|
fill: none;
|
||||||
|
stroke: #9ecae1;
|
||||||
|
stroke-width: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overrides for the markdown editor. */
|
/* Overrides for the markdown editor. */
|
||||||
|
|
|
@ -835,6 +835,15 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, Restangular) {
|
||||||
document.getElementById('change-filter').value = filter;
|
document.getElementById('change-filter').value = filter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.initializeTree = function() {
|
||||||
|
if ($scope.tree) { return; }
|
||||||
|
|
||||||
|
$scope.tree = new ImageFileChangeTree($scope.image, $scope.combinedChanges);
|
||||||
|
setTimeout(function() {
|
||||||
|
$scope.tree.draw('changes-tree-container');
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch the image.
|
// Fetch the image.
|
||||||
var imageFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid);
|
var imageFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid);
|
||||||
imageFetch.get().then(function(image) {
|
imageFetch.get().then(function(image) {
|
||||||
|
|
|
@ -714,3 +714,349 @@ ImageHistoryTree.prototype.toggle_ = function(d) {
|
||||||
d._children = null;
|
d._children = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based off of http://bl.ocks.org/mbostock/1093025 by Mike Bostock (@mbostock)
|
||||||
|
*/
|
||||||
|
function ImageFileChangeTree(image, changes) {
|
||||||
|
/**
|
||||||
|
* The parent image.
|
||||||
|
*/
|
||||||
|
this.image_ = image;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The changes being drawn.
|
||||||
|
*/
|
||||||
|
this.changes_ = changes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counter for creating unique IDs.
|
||||||
|
*/
|
||||||
|
this.idCounter_ = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from file path to associated tree node.
|
||||||
|
*/
|
||||||
|
this.nodeMap_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the dimensions of the tree.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.calculateDimensions_ = function(container) {
|
||||||
|
var cw = document.getElementById(container).clientWidth;
|
||||||
|
var barWidth = cw * 0.8;
|
||||||
|
|
||||||
|
var barHeight = 20;
|
||||||
|
var ch = (this.changes_.length * barHeight) + 40;
|
||||||
|
|
||||||
|
var margin = { top: 40, right: 20, bottom: 20, left: 40 };
|
||||||
|
var m = [margin.top, margin.right, margin.bottom, margin.left];
|
||||||
|
var w = cw - m[1] - m[3];
|
||||||
|
var h = ch - m[0] - m[2];
|
||||||
|
|
||||||
|
return {
|
||||||
|
'w': w,
|
||||||
|
'h': h,
|
||||||
|
'm': m,
|
||||||
|
'cw': cw,
|
||||||
|
'ch': ch,
|
||||||
|
'bw': barWidth,
|
||||||
|
'bh': barHeight
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the tree.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.draw = function(container) {
|
||||||
|
this.container_ = container;
|
||||||
|
|
||||||
|
var dimensions = this.calculateDimensions_(container);
|
||||||
|
|
||||||
|
var w = dimensions.w;
|
||||||
|
var h = dimensions.h;
|
||||||
|
var m = dimensions.m;
|
||||||
|
|
||||||
|
this.barWidth_ = dimensions.bw;
|
||||||
|
this.barHeight_ = dimensions.bh;
|
||||||
|
|
||||||
|
var tree = d3.layout.tree()
|
||||||
|
.size([h, 100]);
|
||||||
|
|
||||||
|
var diagonal = d3.svg.diagonal()
|
||||||
|
.projection(function(d) { return [d.y, d.x]; });
|
||||||
|
|
||||||
|
var rootSvg = d3.select("#" + container).append("svg:svg")
|
||||||
|
.attr("width", w)
|
||||||
|
.attr("height", h);
|
||||||
|
|
||||||
|
var vis = rootSvg
|
||||||
|
.append("svg:g")
|
||||||
|
.attr("transform", "translate(" + m[3] + "," + m[0] + ")");
|
||||||
|
|
||||||
|
this.rootSvg_ = rootSvg;
|
||||||
|
this.tree_ = tree;
|
||||||
|
this.diagonal_ = diagonal;
|
||||||
|
this.vis_ = vis;
|
||||||
|
|
||||||
|
this.populateAndDraw_();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the tree and then draws it.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.populateAndDraw_ = function() {
|
||||||
|
// For each change, generate all the node(s) needed for the folders, as well
|
||||||
|
// as the final node (if any) for the file.
|
||||||
|
for (var i = 0; i < this.changes_.length; ++i) {
|
||||||
|
var filepath = this.changes_[i].file;
|
||||||
|
var node = this.buildNodes_(filepath);
|
||||||
|
node.kind = this.changes_[i].kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the children of each node so that folders are on top.
|
||||||
|
var sortByName = function (a, b) {
|
||||||
|
if (a.name > b.name) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (a.name < b.name) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
var sortFunction = function(a, b) {
|
||||||
|
var hasA = a._children.length > 0;
|
||||||
|
var hasB = b._children.length > 0;
|
||||||
|
if (hasA == hasB) {
|
||||||
|
// Sort alphabetically.
|
||||||
|
return sortByName(a, b);
|
||||||
|
}
|
||||||
|
if (hasA) { return -1; }
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var path in this.nodeMap_) {
|
||||||
|
if (!this.nodeMap_.hasOwnProperty(path) || !this.nodeMap_[path]._children) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodeMap_[path]._children.sort(sortFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.root_ = this.nodeMap_[''];
|
||||||
|
this.root_.x0 = 0;
|
||||||
|
this.root_.y0 = 0;
|
||||||
|
this.toggle_(this.root_);
|
||||||
|
this.update_(this.root_);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds all the nodes in the tree.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.buildNodes_ = function(path) {
|
||||||
|
var parts = path.split('/');
|
||||||
|
for (var i = 0; i < parts.length; ++i) {
|
||||||
|
var currentPath = parts.slice(0, i + 1).join('/');
|
||||||
|
if (!this.nodeMap_[currentPath]) {
|
||||||
|
this.nodeMap_[currentPath] = { 'name': parts[i] || '/', 'path': currentPath, '_children': [] };
|
||||||
|
|
||||||
|
if (currentPath.length > 0) {
|
||||||
|
var parentPath = parts.slice(0, i).join('/');
|
||||||
|
this.nodeMap_[parentPath]._children.push(this.nodeMap_[currentPath]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.nodeMap_[path];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the count of visible nodes.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.getVisibleCount_ = function(node) {
|
||||||
|
if (node.children) {
|
||||||
|
var count = 1;
|
||||||
|
for (var i = 0; i < node.children.length; ++i) {
|
||||||
|
count += this.getVisibleCount_(node.children[i]);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the height for the container.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.getContainerHeight_ = function() {
|
||||||
|
var dimensions = this.calculateDimensions_(this.container_);
|
||||||
|
var barHeight = this.barHeight_;
|
||||||
|
var height = (this.getVisibleCount_(this.root_) * (barHeight + 2));
|
||||||
|
return height + dimensions.m[0] + dimensions.m[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the tree starting at the given source node.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.update_ = function(source) {
|
||||||
|
var that = this;
|
||||||
|
var tree = this.tree_;
|
||||||
|
var vis = this.vis_;
|
||||||
|
var svg = this.rootSvg_;
|
||||||
|
var diagonal = this.diagonal_;
|
||||||
|
var barWidth = this.barWidth_;
|
||||||
|
var barHeight = this.barHeight_;
|
||||||
|
|
||||||
|
var duration = 400;
|
||||||
|
|
||||||
|
var color = function(d) {
|
||||||
|
if (d.kind) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return d._children ? "#E9E9E9" : "#c6dbef";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the height of the container and the SVG.
|
||||||
|
document.getElementById(this.container_).style.height = this.getContainerHeight_() + 'px';
|
||||||
|
svg.attr('height', this.getContainerHeight_());
|
||||||
|
|
||||||
|
// Compute the flattened node list.
|
||||||
|
var nodes = tree.nodes(this.root_);
|
||||||
|
|
||||||
|
// Compute the "layout".
|
||||||
|
nodes.forEach(function(n, i) {
|
||||||
|
n.x = i * barHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the nodes...
|
||||||
|
var node = vis.selectAll("g.node")
|
||||||
|
.data(nodes, function(d) { return d.id || (d.id = that.idCounter_++); });
|
||||||
|
|
||||||
|
var nodeEnter = node.enter().append("svg:g")
|
||||||
|
.attr("class", function(d) {
|
||||||
|
return "node " + (d.kind ? d.kind : 'folder');
|
||||||
|
})
|
||||||
|
.attr("transform", function(d) { return "translate(" + source.y0 + "," + source.x0 + ")"; })
|
||||||
|
.style("opacity", 1e-6);
|
||||||
|
|
||||||
|
// Enter any new nodes at the parent's previous position.
|
||||||
|
nodeEnter.append("svg:rect")
|
||||||
|
.attr("y", -barHeight / 2)
|
||||||
|
.attr("height", barHeight)
|
||||||
|
.attr("width", barWidth)
|
||||||
|
.style("fill", color)
|
||||||
|
.on("click", function(d) { that.toggle_(d); that.update_(source); });
|
||||||
|
|
||||||
|
nodeEnter.append("svg:text")
|
||||||
|
.attr("dy", 3.5)
|
||||||
|
.attr("dx", 5.5 + 18)
|
||||||
|
.text(function(d) { return d.name; });
|
||||||
|
|
||||||
|
var body = nodeEnter.append('svg:foreignObject')
|
||||||
|
.attr("x", function(d) { return d.kind ? barWidth - 18 : 0; })
|
||||||
|
.attr("y", -10)
|
||||||
|
.attr("width", 18)
|
||||||
|
.attr("height", barHeight)
|
||||||
|
.append('xhtml:body');
|
||||||
|
|
||||||
|
body.append('div')
|
||||||
|
.attr('class', 'node-icon');
|
||||||
|
|
||||||
|
// Transition nodes to their new position.
|
||||||
|
nodeEnter.transition()
|
||||||
|
.duration(duration)
|
||||||
|
.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
|
||||||
|
.style("opacity", 1);
|
||||||
|
|
||||||
|
node.transition()
|
||||||
|
.duration(duration)
|
||||||
|
// TODO: reenable for full animation
|
||||||
|
//.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; })
|
||||||
|
.style("opacity", 1)
|
||||||
|
.select("rect")
|
||||||
|
.style("fill", color);
|
||||||
|
|
||||||
|
// TODO: remove if full animation.
|
||||||
|
node.attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; });
|
||||||
|
|
||||||
|
node.select('.node-icon')
|
||||||
|
.html(function(d) {
|
||||||
|
if (!d.kind) {
|
||||||
|
var folder = d._children ? 'icon-folder-close' : 'icon-folder-open';
|
||||||
|
return '<i class="' + folder + '"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon = {
|
||||||
|
'added': 'plus-sign-alt',
|
||||||
|
'removed': 'minus-sign-alt',
|
||||||
|
'changed': 'edit-sign'
|
||||||
|
};
|
||||||
|
|
||||||
|
return '<i class="change-icon icon-' + icon[d.kind] + '"></i>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transition exiting nodes to the parent's new position.
|
||||||
|
node.exit().transition()
|
||||||
|
.duration(duration)
|
||||||
|
// TODO: reenable for full animation
|
||||||
|
// .attr("transform", function(d) { return "translate(" + source.y + "," + source.x + ")"; })
|
||||||
|
.style("opacity", 1e-6)
|
||||||
|
.remove();
|
||||||
|
|
||||||
|
// Update the links...
|
||||||
|
var link = vis.selectAll("path.link")
|
||||||
|
.data(tree.links(nodes), function(d) { return d.target.id; });
|
||||||
|
|
||||||
|
// Enter any new links at the parent's previous position.
|
||||||
|
link.enter().insert("svg:path", "g")
|
||||||
|
.attr("class", "link")
|
||||||
|
.attr("d", function(d) {
|
||||||
|
var o = {x: source.x0, y: source.y0};
|
||||||
|
return diagonal({source: o, target: o});
|
||||||
|
})
|
||||||
|
.transition()
|
||||||
|
.duration(duration)
|
||||||
|
.attr("d", diagonal);
|
||||||
|
|
||||||
|
// Transition links to their new position.
|
||||||
|
link.transition()
|
||||||
|
.duration(duration)
|
||||||
|
.attr("d", diagonal);
|
||||||
|
|
||||||
|
// Transition exiting nodes to the parent's new position.
|
||||||
|
link.exit().transition()
|
||||||
|
.duration(duration)
|
||||||
|
.attr("d", function(d) {
|
||||||
|
var o = {x: source.x, y: source.y};
|
||||||
|
return diagonal({source: o, target: o});
|
||||||
|
})
|
||||||
|
.remove();
|
||||||
|
|
||||||
|
// Stash the old positions for transition.
|
||||||
|
nodes.forEach(function(d) {
|
||||||
|
d.x0 = d.x;
|
||||||
|
d.y0 = d.y;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles children of a node.
|
||||||
|
*/
|
||||||
|
ImageFileChangeTree.prototype.toggle_ = function(d) {
|
||||||
|
if (d.children) {
|
||||||
|
d._children = d.children;
|
||||||
|
d.children = null;
|
||||||
|
} else {
|
||||||
|
d.children = d._children;
|
||||||
|
d._children = null;
|
||||||
|
}
|
||||||
|
};
|
|
@ -19,10 +19,13 @@
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
|
||||||
|
|
||||||
<!-- Information -->
|
<!-- Information -->
|
||||||
<dl class="dl-normal">
|
<dl class="dl-normal">
|
||||||
<dt>Full Image ID</dt>
|
<dt>Full Image ID</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<div>
|
<div>
|
||||||
<div class="id-container">
|
<div class="id-container">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
@ -42,32 +45,51 @@
|
||||||
<dd am-time-ago="parseDate(image.created)"></dd>
|
<dd am-time-ago="parseDate(image.created)"></dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<!-- Comment -->
|
<!-- Changes tabs -->
|
||||||
<blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote>
|
<div ng-show="combinedChanges.length > 0">
|
||||||
|
|
||||||
<!-- Changes -->
|
|
||||||
<div class="changes-container full-changes-container" ng-show="combinedChanges.length > 0">
|
|
||||||
<b>File Changes:</b>
|
<b>File Changes:</b>
|
||||||
<div class="change-side-controls">
|
<br>
|
||||||
<div class="result-count">
|
<br>
|
||||||
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
|
<ul class="nav nav-tabs">
|
||||||
</div>
|
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
|
||||||
<div class="filter-input">
|
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
|
||||||
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="changes-list well well-sm">
|
|
||||||
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
|
|
||||||
No matching changes
|
|
||||||
</div>
|
|
||||||
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
|
|
||||||
<i ng-class="{'added': 'icon-plus-sign-alt', 'removed': 'icon-minus-sign-alt', 'changed': 'icon-edit-sign'}[change.kind]"></i>
|
|
||||||
<span title="{{change.file}}">
|
|
||||||
<span style="color: #888;">
|
|
||||||
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Changes tab content -->
|
||||||
|
<div class="tab-content" ng-show="combinedChanges.length > 0">
|
||||||
|
<!-- Filterable view -->
|
||||||
|
<div class="tab-pane active" id="filterable">
|
||||||
|
<div class="changes-container full-changes-container">
|
||||||
|
<div class="change-side-controls">
|
||||||
|
<div class="result-count">
|
||||||
|
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
|
||||||
|
</div>
|
||||||
|
<div class="filter-input">
|
||||||
|
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height: 28px;"></div>
|
||||||
|
<div class="changes-list well well-sm">
|
||||||
|
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
|
||||||
|
No matching changes
|
||||||
|
</div>
|
||||||
|
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
|
||||||
|
<i ng-class="{'added': 'icon-plus-sign-alt', 'removed': 'icon-minus-sign-alt', 'changed': 'icon-edit-sign'}[change.kind]"></i>
|
||||||
|
<span title="{{change.file}}">
|
||||||
|
<span style="color: #888;">
|
||||||
|
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tree view -->
|
||||||
|
<div class="tab-pane" id="tree">
|
||||||
|
<div id="changes-tree-container" class="changes-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue