diff --git a/native/v8_extensions/git.js b/native/v8_extensions/git.js index b240204ff..60de65ed1 100644 --- a/native/v8_extensions/git.js +++ b/native/v8_extensions/git.js @@ -7,6 +7,7 @@ var $git = {}; native function getStatus(path); native function isIgnored(path); native function checkoutHead(path); + native function getDiffStats(path); function GitRepository(path) { var repo = getRepository(path); @@ -20,5 +21,6 @@ var $git = {}; GitRepository.prototype.getStatus = getStatus; GitRepository.prototype.isIgnored = isIgnored; GitRepository.prototype.checkoutHead = checkoutHead; + GitRepository.prototype.getDiffStats = getDiffStats; this.GitRepository = GitRepository; })(); diff --git a/native/v8_extensions/git.mm b/native/v8_extensions/git.mm index 5ac5f23b7..43f7e6d3f 100644 --- a/native/v8_extensions/git.mm +++ b/native/v8_extensions/git.mm @@ -37,10 +37,14 @@ public: if (sha) { char oid[GIT_OID_HEXSZ + 1]; git_oid_tostr(oid, GIT_OID_HEXSZ + 1, sha); + git_reference_free(head); return CefV8Value::CreateString(oid); } } - return CefV8Value::CreateString(git_reference_name(head)); + + CefRefPtr result = CefV8Value::CreateString(git_reference_name(head)); + git_reference_free(head); + return result; } return CefV8Value::CreateNull(); @@ -105,6 +109,81 @@ public: } } + CefRefPtr GetDiffStats(const char *path) { + if (!exists) { + return CefV8Value::CreateNull(); + } + + git_reference *head; + if (git_repository_head(&head, repo) != GIT_OK) { + return CefV8Value::CreateNull(); + } + + const git_oid* sha = git_reference_oid(head); + git_commit *commit; + int commitStatus = git_commit_lookup(&commit, repo, sha); + git_reference_free(head); + if (commitStatus != GIT_OK) { + return CefV8Value::CreateNull(); + } + + git_tree *tree; + int treeStatus = git_commit_tree(&tree, commit); + git_commit_free(commit); + if (treeStatus != GIT_OK) { + return CefV8Value::CreateNull(); + } + + char *copiedPath = (char *)malloc(sizeof(char) * (strlen(path) + 1)); + strcpy(copiedPath, path); + git_diff_options options; + memset(&options, 0, sizeof(options)); + git_strarray paths; + paths.count = 1; + paths.strings = &copiedPath; + options.pathspec = paths; + options.context_lines = 1; + options.flags = GIT_DIFF_DISABLE_PATHSPEC_MATCH; + + git_diff_list *diffs; + int diffStatus = git_diff_workdir_to_tree(repo, &options, tree, &diffs); + free(copiedPath); + if (diffStatus != GIT_OK) { + return CefV8Value::CreateNull(); + } + + git_diff_patch *patch; + int patchStatus = git_diff_get_patch(&patch, NULL, diffs, 0); + git_diff_list_free(diffs); + if (patchStatus != GIT_OK) { + return CefV8Value::CreateNull(); + } + + int added = 0; + int deleted = 0; + int hunks = git_diff_patch_num_hunks(patch); + for (int i = 0; i < hunks; i++) { + int lines = git_diff_patch_num_lines_in_hunk(patch, i); + for (int j = 0; j < lines; j++) { + char lineType[2]; + lineType[1] = '\0'; + if (git_diff_patch_get_line_in_hunk(lineType, NULL, NULL, NULL, NULL, patch, i, j) == GIT_OK) { + if (strcmp(lineType, "+") == 0) { + added++; + } else if(strcmp(lineType, "-") == 0) { + deleted++; + } + } + } + } + git_diff_patch_free(patch); + + CefRefPtr result = CefV8Value::CreateObject(NULL); + result->SetValue("added", CefV8Value::CreateInt(added), V8_PROPERTY_ATTRIBUTE_NONE); + result->SetValue("deleted", CefV8Value::CreateInt(deleted), V8_PROPERTY_ATTRIBUTE_NONE); + return result; + } + IMPLEMENT_REFCOUNTING(GitRepository); }; @@ -156,6 +235,12 @@ bool Git::Execute(const CefString& name, return true; } + if (name == "getDiffStats") { + GitRepository *userData = (GitRepository *)object->GetUserData().get(); + retval = userData->GetDiffStats(arguments[0]->GetStringValue().ToString().c_str()); + return true; + } + return false; } diff --git a/src/app/git.coffee b/src/app/git.coffee index ea4127744..c8369a53c 100644 --- a/src/app/git.coffee +++ b/src/app/git.coffee @@ -63,3 +63,7 @@ class Git checkoutHead: (path) -> @repo.checkoutHead(@relativize(path)) + + getDiffStats: (path) -> + stats = @repo.getDiffStats(@relativize(path)) + stats or {'added': 0, 'deleted': 0} diff --git a/src/app/root-view.coffee b/src/app/root-view.coffee index 0448e3b9e..37b3c1382 100644 --- a/src/app/root-view.coffee +++ b/src/app/root-view.coffee @@ -52,20 +52,24 @@ class RootView extends View panesViewState: @panes.children().view()?.serialize() packageStates: @serializePackages() - handleEvents: -> - @on 'toggle-dev-tools', => atom.toggleDevTools() - @on 'focus', (e) => - if @getActiveEditor() - @getActiveEditor().focus() + handleFocus: (e) -> + if @getActiveEditor() + @getActiveEditor().focus() + false + else + @setTitle(null) + focusableChild = this.find("[tabindex=-1]:visible:first") + if focusableChild.length + focusableChild.focus() false else - @setTitle(null) - focusableChild = this.find("[tabindex=-1]:visible:first") - if focusableChild.length - focusableChild.focus() - false - else - true + true + + handleEvents: -> + @on 'toggle-dev-tools', => atom.toggleDevTools() + @on 'focus', (e) => @handleFocus(e) + $(window).on 'focus', (e) => + @handleFocus(e) if document.activeElement is document.body @on 'active-editor-path-change', (e, path) => @project.setPath(path) unless @project.getRootDirectory() diff --git a/src/packages/status-bar/spec/status-bar-spec.coffee b/src/packages/status-bar/spec/status-bar-spec.coffee index 3f9f47d11..e0ad16b1e 100644 --- a/src/packages/status-bar/spec/status-bar-spec.coffee +++ b/src/packages/status-bar/spec/status-bar-spec.coffee @@ -166,3 +166,12 @@ describe "StatusBar", -> fs.write(path, originalPathText) $(window).trigger 'focus' expect(statusBar.gitStatusIcon).not.toHaveClass('modified-status-icon') + + it "displays the diff stat for modified files", -> + fs.write(path, "i've changed for the worse") + rootView.open(path) + expect(statusBar.gitStatusIcon).toHaveText('+1,-1') + + it "displays the diff stat for new files", -> + rootView.open(newPath) + expect(statusBar.gitStatusIcon).toHaveText('+1') diff --git a/src/packages/status-bar/src/status-bar.coffee b/src/packages/status-bar/src/status-bar.coffee index 83a3e4833..fc9080cc7 100644 --- a/src/packages/status-bar/src/status-bar.coffee +++ b/src/packages/status-bar/src/status-bar.coffee @@ -79,8 +79,18 @@ class StatusBar extends View @gitStatusIcon.removeClass().addClass('git-status octicons') if @buffer.getGit()?.isPathModified(path) @gitStatusIcon.addClass('modified-status-icon') - else if @buffer.getGit()?.isPathNew(path) + stats = @buffer.getGit().getDiffStats(path) + if stats.added and stats.deleted + @gitStatusIcon.text("+#{stats.added},-#{stats.deleted}") + else if stats.added + @gitStatusIcon.text("+#{stats.added}") + else if stats.deleted + @gitStatusIcon.text("-#{stats.deleted}") + else + @gitStatusIcon.text('') + else if @buffer.getGit()?.isPathNew(path) @gitStatusIcon.addClass('new-status-icon') + @gitStatusIcon.text("+#{@buffer.getLineCount()}") updatePathText: -> if path = @editor.getPath() diff --git a/src/packages/tabs/src/tab.coffee b/src/packages/tabs/src/tab.coffee index 67935a3d5..2d46e09c4 100644 --- a/src/packages/tabs/src/tab.coffee +++ b/src/packages/tabs/src/tab.coffee @@ -11,6 +11,25 @@ class Tab extends View @updateFileName() @editSession.on 'buffer-path-change.tab', => @updateFileName() + @subscribeToBuffer() + + updateTab: -> + @updateBufferHasModifiedText(@buffer.isModified()) + + subscribeToBuffer: -> + @buffer = @editSession.buffer + @subscribe @buffer, 'contents-modified.tabs', (e) => @updateBufferHasModifiedText(e.differsFromDisk) + @subscribe @buffer, 'after-save.tabs', => @updateTab() + @subscribe @buffer, 'git-status-change.tabs', => @updateTab() + @updateTab() + + updateBufferHasModifiedText: (differsFromDisk) -> + if differsFromDisk + @toggleClass('file-modified') unless @isModified + @isModified = true + else + @removeClass('file-modified') if @isModified + @isModified = false updateFileName: -> @fileName.text(@editSession.buffer.getBaseName() ? 'untitled') diff --git a/src/packages/tabs/src/tabs.css b/src/packages/tabs/src/tabs.css index dc4be74fa..8ddf33b0f 100644 --- a/src/packages/tabs/src/tabs.css +++ b/src/packages/tabs/src/tabs.css @@ -1,14 +1,13 @@ .tabs { background: #333333; border-bottom: 4px solid #424242; - font: caption !important; + font: caption; } .tab { cursor: default; padding: 2px 21px 2px 9px; background-image: -webkit-linear-gradient(#444, #3d3d3d); - color: #a5aaaa; display: table-cell; position: relative; width:175px; @@ -21,6 +20,43 @@ height: 24px; } +.tab, +.tab .close-icon { + color: #aaa; +} + +.tab.file-modified .close-icon { + border-color: #aaa; +} + +.tab.active, +.tab.active:hover, +.tab.active .close-icon { + color: #e6e6e6; +} + +.tab.file-modified.active .close-icon { + border-color: #e6e6e6; +} + +.tab:hover .close-icon { + color: #c8c8c8; + border-color: #c8c8c8; +} + +.tab.file-modified .close-icon { + border: 3px solid #777; + top: 6px; + border-radius: 10px; + width: 5px; + height: 5px; + right: 5px; +} + +.tab.file-modified .close-icon:before { + content: ""; +} + .tab:first-child { box-shadow: inset 0 0 5px #383838, 0 1px 0 #585858, inset -1px 0 0 #4a4a4a; } @@ -32,7 +68,6 @@ .tab.active, .tab.active:hover { - color: #dae6e6; border-top: 1px solid #4a4a4a; box-shadow: inset -1px 0 0 #595959, inset 1px 0 0 #595959; border-bottom: 0 none; @@ -66,12 +101,12 @@ } .tab:hover { - color: #c8c8c5; + color: #c8c8c8; background-image: -webkit-linear-gradient(#474747, #444444); } .tab .file-name { - font-size: 11px !important; + font-size: 11px; display: block; overflow: hidden; white-space: nowrap; @@ -90,7 +125,6 @@ width: 14px; height: 14px; display: block; - color: #777; cursor: pointer; position: absolute; right: 4px; diff --git a/src/packages/tree-view/spec/tree-view-spec.coffee b/src/packages/tree-view/spec/tree-view-spec.coffee index d3995dd1b..7de459451 100644 --- a/src/packages/tree-view/spec/tree-view-spec.coffee +++ b/src/packages/tree-view/spec/tree-view-spec.coffee @@ -194,6 +194,19 @@ describe "TreeView", -> expect(treeView).not.toMatchSelector(':focus') expect(rootView.getActiveEditor().isFocused).toBeTruthy() + describe "when core:close is triggered on the tree view", -> + it "detaches the TreeView, focuses the RootView and does not bubble the core:close event", -> + treeView.attach() + treeView.focus() + rootViewCloseHandler = jasmine.createSpy('rootViewCloseHandler') + rootView.on 'core:close', rootViewCloseHandler + spyOn(rootView, 'focus') + + treeView.trigger('core:close') + expect(rootView.focus).toHaveBeenCalled() + expect(rootViewCloseHandler).not.toHaveBeenCalled() + expect(treeView.hasParent()).toBeFalsy() + describe "when a directory's disclosure arrow is clicked", -> it "expands / collapses the associated directory", -> subdir = treeView.root.find('.entries > li:contains(dir1)').view() @@ -859,3 +872,31 @@ describe "TreeView", -> config.set("core.hideGitIgnoredFiles", false) expect(treeView.find('.file:contains(tree-view.js)').length).toBe 1 + + describe "Git status decorations", -> + [ignoreFile, modifiedFile, originalFileContent] = [] + + beforeEach -> + config.set "core.hideGitIgnoredFiles", false + ignoreFile = fs.join(require.resolve('fixtures/tree-view'), '.gitignore') + fs.write(ignoreFile, 'tree-view.js') + modifiedFile = fs.join(require.resolve('fixtures/tree-view'), 'tree-view.txt') + originalFileContent = fs.read(modifiedFile) + fs.write modifiedFile, 'ch ch changes' + treeView.updateRoot() + + afterEach -> + fs.remove(ignoreFile) if fs.exists(ignoreFile) + fs.write modifiedFile, originalFileContent + + describe "when a file is modified", -> + it "adds a custom style", -> + expect(treeView.find('.file:contains(tree-view.txt)')).toHaveClass 'modified' + + describe "when a file is new", -> + it "adds a custom style", -> + expect(treeView.find('.file:contains(.gitignore)')).toHaveClass 'new' + + describe "when a file is ignored", -> + it "adds a custom style", -> + expect(treeView.find('.file:contains(tree-view.js)')).toHaveClass 'ignored' diff --git a/src/packages/tree-view/src/file-view.coffee b/src/packages/tree-view/src/file-view.coffee index ea636cbfb..ec6f15872 100644 --- a/src/packages/tree-view/src/file-view.coffee +++ b/src/packages/tree-view/src/file-view.coffee @@ -25,7 +25,13 @@ class FileView extends View else @fileName.addClass('text-name') - @addClass('ignored') if new Git(path).isPathIgnored(path) + git = new Git(path) + if git.isPathIgnored(path) + @addClass('ignored') + else if git.isPathModified(path) + @addClass('modified') + else if git.isPathNew(path) + @addClass('new') getPath: -> @file.path diff --git a/src/packages/tree-view/src/tree-view.coffee b/src/packages/tree-view/src/tree-view.coffee index 92a7b4af8..a8c01378e 100644 --- a/src/packages/tree-view/src/tree-view.coffee +++ b/src/packages/tree-view/src/tree-view.coffee @@ -48,7 +48,7 @@ class TreeView extends ScrollView @on 'click', '.entry', (e) => @entryClicked(e) @command 'core:move-up', => @moveUp() @command 'core:move-down', => @moveDown() - @command 'core:close', => false + @command 'core:close', => @detach(); false @command 'tree-view:expand-directory', => @expandDirectory() @command 'tree-view:collapse-directory', => @collapseDirectory() @command 'tree-view:open-selected-entry', => @openSelectedEntry(true) diff --git a/src/packages/wrap-guide/spec/wrap-guide-spec.coffee b/src/packages/wrap-guide/spec/wrap-guide-spec.coffee index 1bc562624..6e38845be 100644 --- a/src/packages/wrap-guide/spec/wrap-guide-spec.coffee +++ b/src/packages/wrap-guide/spec/wrap-guide-spec.coffee @@ -38,39 +38,23 @@ describe "WrapGuide", -> expect(wrapGuide.position().left).toBeGreaterThan(initial) expect(wrapGuide).toBeVisible() - describe "overriding getGuideColumn", -> - it "invokes the callback with the editor path", -> - editorPath = null - wrapGuide.getGuideColumn = (path) -> - editorPath = path - 80 + describe "using a custom config column", -> + it "places the wrap guide at the custom column", -> + config.set('wrapGuide.columns', [{pattern: '\.js$', column: 20}]) wrapGuide.updateGuide() - expect(editorPath).toBe(require.resolve('fixtures/sample.js')) - - it "invokes the callback with a default value", -> - column = null - wrapGuide.getGuideColumn = (path, defaultColumn) -> - editorPath = path - column = defaultColumn - defaultColumn + width = editor.charWidth * 20 + expect(width).toBeGreaterThan(0) + expect(wrapGuide.position().left).toBe(width) + it "uses the default column when no custom column matches the path", -> + config.set('wrapGuide.columns', [{pattern: '\.jsp$', column: '100'}]) wrapGuide.updateGuide() - expect(column).toBeGreaterThan(0) + width = editor.charWidth * wrapGuide.defaultColumn + expect(width).toBeGreaterThan(0) + expect(wrapGuide.position().left).toBe(width) - # this is disabled because we no longer support passing config to an extension - # at load time. we need to convert it to use the global config vars. - xit "uses the function from the config data", -> - rootView.find('.wrap-guide').remove() - config = - getGuideColumn: -> - 1 - atom.loadPackage('wrap-guide', config) - wrapGuide = rootView.find('.wrap-guide').view() - expect(wrapGuide.getGuideColumn).toBe(config.getGuideColumn) - - it "hides the guide when the column is less than 1", -> - wrapGuide.getGuideColumn = (path) -> - -1 + it "hides the guide when the config column is less than 1", -> + config.set('wrapGuide.columns', [{pattern: 'sample\.js$', column: -1}]) wrapGuide.updateGuide() expect(wrapGuide).toBeHidden() diff --git a/src/packages/wrap-guide/src/wrap-guide.coffee b/src/packages/wrap-guide/src/wrap-guide.coffee index 8442cc736..53f62c289 100644 --- a/src/packages/wrap-guide/src/wrap-guide.coffee +++ b/src/packages/wrap-guide/src/wrap-guide.coffee @@ -1,21 +1,22 @@ {View} = require 'space-pen' $ = require 'jquery' +_ = require 'underscore' module.exports = class WrapGuide extends View - @activate: (rootView, state, config) -> + @activate: (rootView, state) -> requireStylesheet 'wrap-guide.css' for editor in rootView.getEditors() if rootView.parents('html').length - @appendToEditorPane(rootView, editor, config) + @appendToEditorPane(rootView, editor) rootView.on 'editor-open', (e, editor) => - @appendToEditorPane(rootView, editor, config) + @appendToEditorPane(rootView, editor) @appendToEditorPane: (rootView, editor, config) -> if underlayer = editor.pane()?.find('.underlayer') - underlayer.append(new WrapGuide(rootView, editor, config)) + underlayer.append(new WrapGuide(rootView, editor)) @content: -> @div class: 'wrap-guide' @@ -23,17 +24,22 @@ class WrapGuide extends View getGuideColumn: null defaultColumn: 80 - initialize: (@rootView, @editor, options = {}) => - if typeof options.getGuideColumn is 'function' - @getGuideColumn = options.getGuideColumn - else - @getGuideColumn = (path, defaultColumn) -> defaultColumn - + initialize: (@rootView, @editor) => @observeConfig 'editor.fontSize', => @updateGuide() @subscribe @editor, 'editor-path-change', => @updateGuide() @subscribe @editor, 'editor:min-width-changed', => @updateGuide() @subscribe $(window), 'resize', => @updateGuide() + getGuideColumn: (path) -> + customColumns = config.get('wrapGuide.columns') + return @defaultColumn unless _.isArray(customColumns) + for customColumn in customColumns + continue unless _.isObject(customColumn) + regex = customColumn['pattern'] + continue unless regex + return parseInt(customColumn['column']) if new RegExp(regex).test(path) + @defaultColumn + updateGuide: -> column = @getGuideColumn(@editor.getPath(), @defaultColumn) if column > 0 diff --git a/static/atom.css b/static/atom.css index d666aaac3..d43d6c5ba 100644 --- a/static/atom.css +++ b/static/atom.css @@ -1,5 +1,5 @@ html, body { - font: caption !important; + font: caption; width: 100%; height: 100%; overflow: hidden; diff --git a/static/command-panel.css b/static/command-panel.css index 13cab5f5f..e404f7ddd 100644 --- a/static/command-panel.css +++ b/static/command-panel.css @@ -1,6 +1,6 @@ .command-panel { background: #515151; - padding: 3px; + padding: 5px; } .command-panel .preview-list { @@ -45,7 +45,7 @@ .command-panel .prompt { color: white; font-weight: bold; - padding: .2em; + padding-right: 5px; } .error-messages { diff --git a/static/editor.css b/static/editor.css index f95816056..000420ce1 100644 --- a/static/editor.css +++ b/static/editor.css @@ -6,7 +6,7 @@ -webkit-box-flex: 1; position: relative; z-index: 0; - font-family: Inconsolata, Monaco, Courier !important; + font-family: Inconsolata, Monaco, Courier; } .editor.mini { diff --git a/static/status-bar.css b/static/status-bar.css index b3e6c1069..14ba139c7 100644 --- a/static/status-bar.css +++ b/static/status-bar.css @@ -1,5 +1,5 @@ .status-bar { - background-image: -webkit-linear-gradient(#303030, #252525); + background-color: #303030; border-top: 1px solid #454545; padding: 4px 10px 3px; font-size: 11px; @@ -17,7 +17,6 @@ } .status-bar .branch-label { - padding-left: 5px; vertical-align: baseline; } @@ -27,7 +26,7 @@ margin-top:-2px; } -.status-bar .octicons { +.status-bar .octicons:before { font-family: 'Octicons Regular'; font-size: 14px; width: 14px; @@ -36,6 +35,7 @@ -webkit-font-smoothing: antialiased; display: inline-block; vertical-align: middle; + margin-right: 5px; } .status-bar .branch-icon:before { @@ -59,4 +59,3 @@ .status-bar .new-status-icon:before { content: "\f26b"; } - diff --git a/static/tree-view.css b/static/tree-view.css index 18751fad1..dd3d1bfcb 100644 --- a/static/tree-view.css +++ b/static/tree-view.css @@ -72,6 +72,14 @@ color: #555; } +.tree-view .modified { + color: #f78a46; +} + +.tree-view .new { + color: #5293d8; +} + .tree-view-dialog { position: absolute; bottom: 0; @@ -80,6 +88,7 @@ color: white; border: 2px solid #222; -webkit-box-shadow: 0 0 3px 3px rgba(0, 0, 0, .5); + z-index: 99; } .tree-view .directory .header .name,