diff --git a/static/css/quay.css b/static/css/quay.css index cb2578b52..c12083d94 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -557,11 +557,13 @@ p.editable:hover i { .repo-image-view .changes-container .change-side-controls { float: right; clear: both; + margin-top: 10px; + margin-bottom: 10px; } .repo-image-view .changes-container .filter-input { display: inline-block; - width: 200px; + width: 400px; } .repo-image-view .changes-container .result-count { @@ -903,6 +905,7 @@ p.editable:hover i { margin-bottom: 12px; } + #image-history-container { overflow: hidden; min-height: 400px; @@ -959,8 +962,40 @@ p.editable:hover i { 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. */ diff --git a/static/js/controllers.js b/static/js/controllers.js index 251699988..316c4f346 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -834,6 +834,15 @@ function ImageViewCtrl($scope, $routeParams, $rootScope, Restangular) { $scope.search['$'] = 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. var imageFetch = Restangular.one('repository/' + namespace + '/' + name + '/image/' + imageid); diff --git a/static/js/graphing.js b/static/js/graphing.js index 4794acbd0..615d2523f 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -713,4 +713,350 @@ ImageHistoryTree.prototype.toggle_ = function(d) { d.children = d._children; 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; + } }; \ No newline at end of file diff --git a/static/partials/image-view.html b/static/partials/image-view.html index 333e6c08b..6c02cf9a1 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -19,10 +19,13 @@ </h3> </div> + <!-- Comment --> + <blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote> + <!-- Information --> - <dl class="dl-normal"> - <dt>Full Image ID</dt> - <dd> + <dl class="dl-normal"> + <dt>Full Image ID</dt> + <dd> <div> <div class="id-container"> <div class="input-group"> @@ -42,32 +45,51 @@ <dd am-time-ago="parseDate(image.created)"></dd> </dl> - <!-- Comment --> - <blockquote ng-show="image.comment" ng-bind-html-unsafe="getMarkedDown(image.comment)"></blockquote> - - <!-- Changes --> - <div class="changes-container full-changes-container" ng-show="combinedChanges.length > 0"> + <!-- Changes tabs --> + <div ng-show="combinedChanges.length > 0"> <b>File Changes:</b> - <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 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> + <br> + <br> + <ul class="nav nav-tabs"> + <li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li> + <li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li> + </ul> + </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>