diff --git a/README.md b/README.md index af2bb5d..e216309 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Type `help` in the command box to see a list of supported operations `pres()` = Turn on presenter mode
`undo` = Undo the last git command
`redo` = Redo the last undone git command
+`edit` = Make a file edit
`mode` = Change mode (`local` or `remote`)
`clear` = Clear the history pane and reset the visualization @@ -45,6 +46,8 @@ git reset git rev_parse git revert git tag +git add +git stash ``` diff --git a/css/explaingit.css b/css/explaingit.css index 13f9b22..067f8cc 100644 --- a/css/explaingit.css +++ b/css/explaingit.css @@ -88,10 +88,21 @@ span.cmd { border: 1px dotted #AAA; position: absolute; top: 0; - bottom: 0; right: 0; left: 250px; margin-left: 0; + height: 49%; +} + +#ExplainGitZen-Container .ws-container { + display: inline-block; + border: 1px dotted #AAA; + position: absolute; + bottom: 0; + right: 0; + left: 250px; + margin-left: 5px; + height: 49%; } #ExplainGitZen-Container .svg-container.remote-container { @@ -285,6 +296,27 @@ text.message-label { font-size: 10px; } +g.stash > rect { + fill: #FFCC66; + stroke: #CC9900; + stroke-width: 2; +} + +g.curr-ws > rect { + fill: #7FC9FF; + stroke: #0026FF; +} + +g.index > rect { + fill: #CCC; + stroke: #888; +} + +g.blob-space > rect { + fill: #8ce08c; + stroke: #339900; +} + g.branch-tag > rect { fill: #FFCC66; stroke: #CC9900; diff --git a/examples/working_tree.md b/examples/working_tree.md new file mode 100644 index 0000000..9389b52 --- /dev/null +++ b/examples/working_tree.md @@ -0,0 +1,52 @@ +### Make local changes to the working tree + +Simulate file edits with the 'edit' command. No filename is required. + +``` +edit +``` + +### The Stash + +The stash a convenient place to store changes that aren't ready for committing. +To move these files changes to the stash: + +``` +git stash +``` + +To move these changes back to the working tree: + +``` +git stash apply +``` +OR +``` +git stash pop +``` + +Applying the changes will move the changes back to the working tree and leave a copy in the stash. +Popping the changes will move the changes back to the working tree but will remove the changes from +the stash. + +### Add files to the index + +If files are ready for committing, they can "staged"/"added to the index". + +``` +git add -u +``` + +The `-u` flag adds all previously tracked files to the staging area + +### Remove files from the index + +If a file needs to be removed from the staging area, use 'git reset' + +``` +git reset -- +``` + +### Committing + +Committing files will remove them from the index and add them to the local repository. diff --git a/index.html b/index.html index a87e584..d75e97f 100644 --- a/index.html +++ b/index.html @@ -109,9 +109,9 @@ explainGit.reset(); var savedState = null - if (window.localStorage) { - savedState = JSON.parse(window.localStorage.getItem('git-viz-snapshot') || 'null') - } + //if (window.localStorage) { + // savedState = JSON.parse(window.localStorage.getItem('git-viz-snapshot') || 'null') + //} var initial = Object.assign(copyDemo(lastDemo), { name: 'Zen', diff --git a/js/controlbox.js b/js/controlbox.js index c9761bf..336fa6b 100644 --- a/js/controlbox.js +++ b/js/controlbox.js @@ -20,6 +20,7 @@ function(_yargs, d3, demos) { function ControlBox(config) { this.historyView = config.historyView; this.originView = config.originView; + this.workspace = config.workspace; this.initialMessage = config.initialMessage || 'Enter git commands below.'; this._commandHistory = []; this._currentCommand = -1; @@ -85,7 +86,6 @@ function(_yargs, d3, demos) { }, changeMode: function (mode) { - console.log(mode) if (mode === 'local' && this.historyView) { this.mode = 'local' } else if (mode === 'remote' && this.originView) { @@ -231,6 +231,7 @@ function(_yargs, d3, demos) { this.info('pres() = Turn on presenter mode') this.info('undo = Undo the last git command') this.info('redo = Redo the last undone git command') + this.info('edit = Make a file edit') this.info('mode = Change mode (`local` or `remote`)') this.info('clear = Clear the history pane and reset the visualization') this.info() @@ -250,6 +251,8 @@ function(_yargs, d3, demos) { this.info('`git rev_parse`') this.info('`git revert`') this.info('`git tag`') + this.info('`git add`') + this.info('`git stash`') return } @@ -300,6 +303,12 @@ function(_yargs, d3, demos) { return } + if (entry.toLowerCase() === 'edit') { + var workspace = this.workspace; + workspace.addBlob(null, workspace.curr_ws); + return + } + if (entry.toLowerCase() === 'clear') { window.resetVis() return @@ -378,6 +387,7 @@ function(_yargs, d3, demos) { this.getRepoView().amendCommit(opts.m || this.getRepoView().getCommit('head').message) } else { this.getRepoView().commit(null, opts.m); + workspace.removeAllBlobs(workspace.index); } }, function(before, after) { var reflogMsg = 'commit: ' + msg @@ -472,25 +482,33 @@ function(_yargs, d3, demos) { }, checkout: function(args, opts) { - if (opts.b) { - if (opts._[0]) { - this.branch(null, null, opts.b + ' ' + opts._[0]) - } else { - this.branch(null, null, opts.b) + if (args && args[0] === "--") { + args.shift(); + while (args.length > 0) { + var arg = args.shift(); + workspace.moveBlobByName(workspace.curr_ws, undefined, arg); + } + } else { + if (opts.b) { + if (opts._[0]) { + this.branch(null, null, opts.b + ' ' + opts._[0]) + } else { + this.branch(null, null, opts.b) + } } - } - var name = opts.b || opts._[0] + var name = opts.b || opts._[0] - this.transact(function() { - this.getRepoView().checkout(name); - }, function(before, after) { - this.getRepoView().addReflogEntry( - 'HEAD', after.commit.id, - 'checkout: moving from ' + before.ref + - ' to ' + name - ) - }) + this.transact(function() { + this.getRepoView().checkout(name); + }, function(before, after) { + this.getRepoView().addReflogEntry( + 'HEAD', after.commit.id, + 'checkout: moving from ' + before.ref + + ' to ' + name + ) + }) + } }, tag: function(args) { @@ -534,33 +552,38 @@ function(_yargs, d3, demos) { }, reset: function(args) { + var unstage = false; while (args.length > 0) { var arg = args.shift(); - - switch (arg) { - case '--soft': - this.info( - 'The "--soft" flag works in real git, but ' + - 'I am unable to show you how it works in this demo. ' + - 'So I am just going to show you what "--hard" looks like instead.' - ); - break; - case '--mixed': - this.info( - 'The "--mixed" flag works in real git, but ' + - 'I am unable to show you how it works in this demo. ' + - 'So I am just going to show you what "--hard" looks like instead.' - ); - break; - case '--hard': - this.doReset(args.join(' ')); - args.length = 0; - break; - default: - var remainingArgs = [arg].concat(args); - args.length = 0; - this.info('Assuming "--hard".'); - this.doReset(remainingArgs.join(' ')); + if (unstage) { + workspace.moveBlobByName(workspace.index, workspace.curr_ws, arg); + } else { + switch (arg) { + case '--soft': + // no resetting of the index or working tree + this.doReset(args.join(' ')); + args.length = 0; + break; + case '--mixed': + // reset just the index + this.doReset(args.join(' ')); + workspace.removeAllBlobs(workspace.index); + args.length = 0; + break; + case '--hard': + this.doReset(args.join(' ')); + // reset the index and the working tree + workspace.removeAllBlobs(workspace.curr_ws); + workspace.removeAllBlobs(workspace.index); + args.length = 0; + break; + case "HEAD": + // unstage the file (move from index to working tree) + unstage = true; + break; + default: + this.info("Invalid ref: " + arg); + } } } }, @@ -915,6 +938,48 @@ function(_yargs, d3, demos) { this.info("Real git reflog supports the '" + subcommand + "' subcommand but this tool only supports 'show' and 'exists'") } + }, + + add: function(args) { + // Create boxes to visualize working tree, index, stash + var workspace = this.workspace; + while (args.length > 0) { + var arg = args.shift(); + switch (arg) { + case '-u': + case '.': + workspace.addBlob(workspace.curr_ws, workspace.index, true); + break; + default: + workspace.moveBlobByName(workspace.curr_ws, workspace.index, arg); + break; + } + } + return; + }, + + stash: function(args) { + // Create boxes to visualize working tree, index, stash + var workspace = this.workspace; + var stash_id = "0"; + if (args && args[0] === "pop") { + workspace.addBlob(workspace.stash, workspace.curr_ws); + } else if (args && args[0] === "apply") { + if (args[1] != undefined) { + stash_id = args[1]; + } + workspace.moveBlobByName(workspace.stash, workspace.curr_ws, stash_id, false); + } else if (args && args[0] === "drop") { + if (args[1] != undefined) { + stash_id = args[1]; + } + workspace.removeBlob(workspace.stash, stash_id); + } else if (args && args[0] === "clear") { + workspace.removeAllBlobs(workspace.stash); + } else { + workspace.addBlob(workspace.curr_ws, workspace.stash, true); + } + return; } }; diff --git a/js/explaingit.js b/js/explaingit.js index 8aa89d5..bd4e3b6 100644 --- a/js/explaingit.js +++ b/js/explaingit.js @@ -1,4 +1,5 @@ -define(['historyview', 'controlbox', 'd3'], function(HistoryView, ControlBox, d3) { +define(['historyview', 'controlbox', 'workspace', 'd3'], function(HistoryView, +ControlBox, Workspace, d3) { var prefix = 'ExplainGit', openSandBoxes = [], open, @@ -36,9 +37,20 @@ define(['historyview', 'controlbox', 'd3'], function(HistoryView, ControlBox, d3 window.ov = originView; } + workspace = new Workspace({ + historyView: historyView, + originView: originView, + undoHistory: args.undoHistory, + name: name + '-Workspace', + width: 300, + height: 400 + }); + window.ws = workspace + controlBox = new ControlBox({ historyView: historyView, originView: originView, + workspace: workspace, initialMessage: args.initialMessage, undoHistory: args.undoHistory }); @@ -46,10 +58,12 @@ define(['historyview', 'controlbox', 'd3'], function(HistoryView, ControlBox, d3 controlBox.render(playground); historyView.render(playground); + workspace.render(playground); openSandBoxes.push({ hv: historyView, cb: controlBox, + ws: workspace, container: container }); }; @@ -59,6 +73,7 @@ define(['historyview', 'controlbox', 'd3'], function(HistoryView, ControlBox, d3 var osb = openSandBoxes[i]; osb.hv.destroy(); osb.cb.destroy(); + osb.ws.destroy(); osb.container.style('display', 'none'); } @@ -69,6 +84,7 @@ define(['historyview', 'controlbox', 'd3'], function(HistoryView, ControlBox, d3 explainGit = { HistoryView: HistoryView, ControlBox: ControlBox, + Workspace: Workspace, generateId: HistoryView.generateId, open: open, reset: reset diff --git a/js/workspace.js b/js/workspace.js new file mode 100644 index 0000000..98be4e8 --- /dev/null +++ b/js/workspace.js @@ -0,0 +1,350 @@ +define(['historyview', 'd3'], function(HistoryView) { + "use strict"; + + var fixBlobPosition, + fixIdPosition, tagY, getUniqueSetItems; + + fixBlobPosition = function(selection) { + selection + .attr('x', function(d) { + return d.x; + }) + .attr('y', function(d) { + return d.y; + }); + }; + + fixIdPosition = function(selection, view, delta) { + selection.attr('x', function(d) { + return d.x + view.blob_width / 2; + }).attr('y', function(d) { + return d.y + view.blob_height + delta; + }); + }; + + tagY = function tagY(t, view) { + var blobs = view.getBlobs, + blobIndex = blobs.indexOf(t.blob); + + if (blobIndex === -1) { + blobIndex = blobs.length; + } + return t.blob.y - 45 - (blobIndex * 25); + }; + + getUniqueSetItems = function(set1, set2) { + var uniqueSet1 = JSON.parse(JSON.stringify(set1)) + var uniqueSet2 = JSON.parse(JSON.stringify(set2)) + for (var id in set1) { + delete uniqueSet2[id] + } + for (var id in set2) { + delete uniqueSet1[id] + } + return [uniqueSet1, uniqueSet2] + }; + + /** + * @class Workspace + * @constructor + */ + function Workspace(config) { + this.historyView = config.historyView; + this.originView = config.originView; + this.name = config.name || 'UnnamedWorkspace'; + this.width = config.width; + this.height = config.height || 400; + //this.width = this.historyView.width; + //this.height = this.historyView.height || 400; + this.blob_height = config.blob_height || 75; + this.blob_width = config.blob_height || 200; + this.filename_counter = 0; + } + + Workspace.prototype = { + /** + * @method render + * @param container {String} selector for the container to render the SVG into + */ + render: function(container) { + var svgContainer, svg, curr_ws, stash, index; + + //svgContainer = container.select('svg-container'); + svgContainer = container.append('div') + .classed('ws-container', true); + + svg = svgContainer.append('svg:svg'); + + svg.attr('id', this.name) + .attr('width', "100%") + .attr('height', "100%"); + //.attr('width', this.width) + //.attr('height', this.height); + var labelX = 15; + var labelY = 25; + + + stash = svg.append('svg:g').classed('stash', true).attr('id', 'stash') + stash.append('svg:rect') + .attr('width', "31%") + .attr('height', "100%") + .attr('x', 0) + .attr('y', 0); + stash.append('svg:text') + .classed('workspace-label', true) + .text('Stash') + .attr('x', labelX) + .attr('y', labelY); + stash.append('svg:g').classed('blob-space', true).attr('id', 'stash.blob-space'); + + curr_ws = svg.append('svg:g').classed('curr-ws', true) + .attr('id', 'curr_ws') + .attr('transform', 'translate(750, 0)'); + curr_ws.append('svg:rect') + .attr('width', "31%") + .attr('height', "100%") + .attr('x', 0) + .attr('y', 0); + curr_ws.append('svg:text') + .classed('workspace-label', true) + .text('Workspace/Working Tree') + .attr('x', labelX) + .attr('y', labelY); + curr_ws.append('svg:g').classed('blob-space', true).attr('id', 'curr_ws.blob-space'); + + index = svg.append('svg:g').classed('index', true) + .attr('id', 'index') + .attr('transform', 'translate(1500, 0)'); + index.append('svg:rect') + .attr('width', "31%") + .attr('height', "100%") + .attr('x', 0) + .attr('y', 0); + index.append('svg:text') + .classed('workspace-label', true) + .text('Index/Stage') + .attr('x', labelX) + .attr('y', labelY); + index.append('svg:g').classed('blob-space', true).attr('id', 'index.blob-space'); + + this.svgContainer = svgContainer; + this.svg = svg; + this.curr_ws = curr_ws; + this.curr_ws.name = "workspace" + this.curr_ws.blobs = this.curr_ws.blobs || [] + this.stash = stash + this.stash.name = "stash" + this.stash.blobs = this.stash.blobs || [] + this.index = index + this.index.name = "index" + this.index.blobs = this.index.blobs || [] + + this.renderBlobs(); + }, + + destroy: function() { + this.svg.remove(); + this.svgContainer.remove(); + clearInterval(this.refreshSizeTimer); + + for (var prop in this) { + if (this.hasOwnProperty(prop)) { + this[prop] = null; + } + } + }, + + addNewBlob: function(ws) { + if (ws.blobs === undefined || !ws.blobs) { + ws.blobs = []; + } + var blob = {'id': HistoryView.generateId(), + 'x': 50, + 'y': 50, + 'filename': 'file_' + this.filename_counter} + this.filename_counter += 1; + ws.blobs.push(blob); + }, + + addBlob: function(src, dst, moveAll=false) { + if (src === null) { + // Adding a brand new blob + this.addNewBlob(dst); + } else { + // Moving an existing blob from 'src' to 'dst' + if (src.blobs !== undefined && src.blobs.length > 0) { + if (moveAll) { + if (dst.name === "stash") { + // stash is treated like a stack of changesets (group of blobs) + dst.blobs.unshift(src.blobs); + } else { + dst.blobs = dst.blobs.concat(src.blobs); + } + // empty out the src blobs + src.blobs = []; + } else { + if (dst.blobs === undefined) { + dst.blobs = []; + } + if (src.name === "stash") { + var top_blob = src.blobs.shift(); + } else { + var top_blob = src.blobs.pop(); + } + if (Array.isArray(top_blob)) { + dst.blobs = dst.blobs.concat(top_blob); + } else { + dst.blobs.push(top_blob); + } + } + } + } + this.renderBlobs(); + }, + + moveBlobByName: function(src, dst, filename, remove_from_src=true) { + // set dst to undefined to remove the blob + var target_blob = src.blobs.filter(function(d) { + if (src.name === "stash") { + return d.filename.split("{")[1].split("}")[0] === filename; + } else { + return d.filename === filename; + } + }); + if (target_blob && target_blob.length == 1) { + if (remove_from_src) { + src.blobs.splice(src.blobs.indexOf(target_blob[0]), 1); + } + // add to dst + if (dst) { + if (Array.isArray(target_blob[0])) { + // ensure there's no duplicates after a 'stash apply' + dst.blobs = Array.from(new Set(dst.blobs.concat(target_blob[0]))); + } else { + dst.blobs.push(target_blob[0]); + } + } + } + this.renderBlobs(); + }, + + removeBlob: function(ws, index_to_remove) { + ws.blobs.splice(index_to_remove, 1); + this.renderBlobs(); + }, + + removeAllBlobs: function(ws) { + ws.blobs = [] + this.renderBlobs(); + }, + + _calculatePositionData: function(blobs) { + for (var i = 0; i < blobs.length; i++) { + var blob = blobs[i]; + blob.x = 50; + blob.y = 50 + i * 125; + } + }, + + _resizeSvg: function() { + var ele = document.getElementById(this.svg.node().id); + var container = ele.parentNode; + var currentWidth = ele.offsetWidth; + var newWidth; + + if (ele.getBBox().width > container.offsetWidth) + newWidth = Math.round(ele.getBBox().width); + else + newWidth = container.offsetWidth - 5; + + if (currentWidth != newWidth) { + this.svg.attr('width', newWidth); + container.scrollLeft = container.scrollWidth; + } + }, + + _renderWorkspace: function(view, ws, blob_type) { + // Bind the data + var blob_rect = ws.select("g.blob-space").selectAll("rect").data(ws.blobs); + // Enter + blob_rect.enter().append("svg:rect") + .attr("id", function(d) { return blob_type + "-" + d.id; }) + .classed("rendered-" + blob_type, true) + .attr("width", 1) + .attr("height", 1) + .transition("inflate") + .attr("width", view.blob_width) + .attr("height", view.blob_height) + .duration(500); + // Update + view._calculatePositionData(ws.blobs); + blob_rect + .call(fixBlobPosition); + // Remove + blob_rect.exit().remove(); + view._renderIdLabels(ws); + }, + + renderBlobs: function() { + var view = this, + existingBlobs, + newBlobs, + curr_workspace = this.stash, + workspaces = [this.curr_ws, this.index], + changeset_workspaces = [this.stash]; + + workspaces.forEach(function(ws) { + view._renderWorkspace(view, ws, "blob"); + }); + changeset_workspaces.forEach(function(ws) { + view._renderWorkspace(view, ws, "changeset"); + }); + }, + + _renderIdLabels: function(ws) { + this._renderText(ws, 'id-label', function(d) { + if (Array.isArray(d)) { + var ret_str = ""; + d.forEach( function(blob) { + ret_str += blob.id + ','; + }); + ret_str = ret_str.substr(0, ret_str.length - 1); + return ret_str.substr(0, 32); + } + return d.id + '..'; + }, 14); + this._renderText(ws, 'message-label', function(d) { + if (ws.name === "stash") { + var filename = "stash@{" + ws.blobs.indexOf(d) + "}"; + // save the stash name in the changeset object + d.filename = filename; + return filename; + } + return d.filename; + }, 24); + }, + + _renderText: function(ws, className, getText, delta) { + var view = this, + existingTexts, + newtexts; + + existingTexts = ws.select("g.blob-space").selectAll('text.' + className) + .data(ws.blobs) + .text(getText); + + existingTexts.transition().call(fixIdPosition, view, delta); + + newtexts = existingTexts.enter() + .insert('svg:text', ':first-child') + .classed(className, true) + .text(getText) + .call(fixIdPosition, view, delta); + + existingTexts.exit() + .remove() + }, + }; + + return Workspace; +});