├── .gitignore ├── LICENSE ├── README.md ├── atom-inline-blame.png ├── keymaps └── atom-inline-blame.json ├── lib ├── GitBlameHelper.js ├── InlineBlameModal.js ├── InlineBlameView.js └── atom-inline-blame.js ├── menus └── atom-inline-blame.json ├── package-lock.json ├── package.json ├── spec ├── atom-inline-blame-spec.js └── atom-inline-blame-view-spec.js ├── styles └── atom-inline-blame.less └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | .tags 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Gregory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atom Inline Blame 2 | 3 | Built-in Git annotation to quickly see who and when each line was changed. 4 | This project was inspired by [Gitlens](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens). 5 | 6 | ![Atom Inline Blame Screenshot](https://raw.githubusercontent.com/gregorym/atom-inline-blame/master/atom-inline-blame.png) 7 | 8 | # Install 9 | 10 | Search for `atom-inline-blame` in the Atom settings. 11 | -------------------------------------------------------------------------------- /atom-inline-blame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregorym/atom-inline-blame/161a7bba1e7519e03e5e48538f2044d9fafd94bd/atom-inline-blame.png -------------------------------------------------------------------------------- /keymaps/atom-inline-blame.json: -------------------------------------------------------------------------------- 1 | { 2 | "atom-workspace": { 3 | "ctrl-alt-o": "atom-inline-blame:toggle" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/GitBlameHelper.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { distanceInWordsToNow } from 'date-fns'; 4 | import Path from 'path'; 5 | import { exec } from 'child_process'; 6 | 7 | function run(filePath, lineNumber) { 8 | return new Promise((resolve, reject) => { 9 | const cmdText = `git blame "${filePath}" --line-porcelain -L ${lineNumber},+1`; 10 | exec(cmdText, { cwd: Path.dirname(filePath) }, (error, stdout, stderr) => { 11 | if (error) { 12 | console.log(error); 13 | reject(); 14 | } 15 | 16 | return resolve(parse(stdout)); 17 | }); 18 | }); 19 | } 20 | 21 | function parse(string) { 22 | obj = {}; 23 | 24 | string.split('\n').forEach((line, index) => { 25 | const sepIndex = line.indexOf(' '); 26 | 27 | if (index === 0) { 28 | obj['sha'] = line.substr(0, sepIndex); 29 | } else { 30 | obj[line.substr(0, sepIndex)] = line.substr(sepIndex + 1); 31 | } 32 | }); 33 | 34 | return { 35 | author : obj['author'], 36 | authorEmail : obj['author-mail'], 37 | authorTime : obj['author-time'], 38 | authorTimezone: obj['author-tz'], 39 | committer : obj['commiter'], 40 | summary : obj['summary'], 41 | sha : obj['sha'], 42 | }; 43 | } 44 | 45 | function shortLine(blameInfo, format = "%author%, %relativeTime% ago - %summary%") { 46 | let output = format; 47 | 48 | blameInfo.relativeTime = distanceInWordsToNow(new Date(blameInfo.authorTime * 1000)); 49 | blameInfo.author = blameInfo.author === "Unknown" ? blameInfo.authorEmail : blameInfo.author; 50 | blameInfo.summary = blameInfo.summary.replace(/\"/g, `\\\"`); 51 | blameInfo.sha = blameInfo.sha.substr(0, 7); 52 | 53 | Object.keys(blameInfo).forEach(token => { 54 | const tokenRe = new RegExp(`%${token}%`, "g"); 55 | output = output.replace(tokenRe, blameInfo[token]); 56 | }); 57 | 58 | return output; 59 | } 60 | 61 | export default { 62 | run, 63 | shortLine, 64 | } 65 | -------------------------------------------------------------------------------- /lib/InlineBlameModal.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | export default class InlineBlameView { 4 | constructor() { 5 | this.element = document.createElement('div'); 6 | this.element.classList.add('atom-inline-blame'); 7 | // Create message element 8 | const message = document.createElement('div'); 9 | message.textContent = 'The AtomInlineBlame package is Alive! It\'s ALIVE!'; 10 | message.classList.add('message'); 11 | this.element.appendChild(message); 12 | } 13 | 14 | getElement() { 15 | return this.element; 16 | } 17 | 18 | destroy() { 19 | this.element.remove(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/InlineBlameView.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { distanceInWordsToNow } from 'date-fns'; 4 | import { CompositeDisposable, Point } from 'atom'; 5 | import GitBlameHelper from './GitBlameHelper'; 6 | 7 | const INLINE_BLAME_MESSAGE_VARIABLE = "--inline-blame-message"; 8 | 9 | export default class InlineBlameView { 10 | constructor() { 11 | this.editor = atom.workspace.getActiveTextEditor(); 12 | this.subscriptions = new CompositeDisposable(); 13 | this.decoration = null; 14 | this.marker = null; 15 | this.styleEl = null; 16 | this.updating = null; 17 | 18 | const throttledUpdate = () => this.throttle(this.updateLine.bind(this)); 19 | 20 | window.requestIdleCallback(throttledUpdate); // lazily run once 21 | this.subscriptions.add(this.editor.onDidChangeCursorPosition(throttledUpdate)); 22 | 23 | this.subscriptions.add(this.editor.onDidDestroy(() => { 24 | this.subscriptions.dispose(); 25 | })); 26 | } 27 | 28 | /** 29 | * Throttle to prevent stutter 30 | * @param {Function} cb Function to throttle 31 | */ 32 | throttle(cb) { 33 | // Don't update if moving in same line 34 | const lineNumber = this.editor.getCursorBufferPosition().row; 35 | if (this.lineNumber === lineNumber) return; 36 | 37 | this.removeDecoration(); // clear because moving 38 | if (this.updating) return; 39 | 40 | this.updating = setTimeout( 41 | () => { 42 | cb(); 43 | this.updating = null; 44 | }, 45 | atom.config.get("atom-inline-blame.timeout"), 46 | ); 47 | } 48 | 49 | updateLine() { 50 | if (!this.editor.buffer.file) return; 51 | 52 | const filePath = this.editor.buffer.file.path; 53 | this.lineNumber = this.editor.getCursorBufferPosition().row; 54 | 55 | // Don't run on empty lines 56 | const lineText = this.editor.lineTextForBufferRow(this.lineNumber) || ''; 57 | if (lineText.trim() === "") return; 58 | 59 | GitBlameHelper 60 | .run(filePath, this.lineNumber + 1) 61 | .catch(this.removeDecoration) 62 | .then((blameInfo) => { 63 | this.removeDecoration(); 64 | 65 | if (!blameInfo || !blameInfo.author) return; 66 | if (blameInfo.author === "Not Committed Yet") return; 67 | 68 | const message = GitBlameHelper.shortLine(blameInfo, atom.config.get("atom-inline-blame.format")); 69 | document.body.style.setProperty(INLINE_BLAME_MESSAGE_VARIABLE, `"${message}`); // set value first 70 | 71 | this.marker = this.editor.markBufferPosition(new Point(this.lineNumber, 0)); 72 | this.decoration = this.editor.decorateMarker(this.marker, { 73 | type: "line", 74 | class: "atom-inline-git-blame" 75 | }); 76 | }); 77 | } 78 | 79 | removeDecoration() { 80 | document.body.style.removeProperty(INLINE_BLAME_MESSAGE_VARIABLE); 81 | if (this.marker) this.marker.destroy(); 82 | if (this.decoration) this.decoration.destroy(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/atom-inline-blame.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { CompositeDisposable } from "atom"; 4 | 5 | let InlineBlameView; 6 | 7 | export default { 8 | watchedEditors: new Set([]), 9 | toggleState: true, 10 | view: null, 11 | 12 | config: { 13 | format: { 14 | type: "string", 15 | default: `%author%, %relativeTime% ago - %summary%`, 16 | description: "Available tokens: author, authorEmail, relativeTime, authorTime, authorTimezone, committer, summary, sha", 17 | }, 18 | timeout: { 19 | type: "number", 20 | default: 200, 21 | description: "Delay after which the inline blame summary will be displayed. Useful when navigating using cursor keys to prevent unnecessarily fetching history for each line.", 22 | } 23 | }, 24 | 25 | attachBlamer(editor) { 26 | if (!editor) return; 27 | 28 | const { id } = editor; 29 | if (!this.watchedEditors.has(id)) { 30 | if (!InlineBlameView) { 31 | InlineBlameView = require("./InlineBlameView"); // lazy load only when needed the first time 32 | } 33 | 34 | this.watchedEditors.add(id); 35 | this.view = new InlineBlameView(); 36 | 37 | editor.onDidDestroy(() => this.watchedEditors.delete(id)); 38 | } 39 | }, 40 | 41 | activate(state) { 42 | this.subscriptions = new CompositeDisposable(); 43 | 44 | this.subscriptions.add(atom.workspace.onDidChangeActiveTextEditor(this.attachBlamer.bind(this))); // subscribe to changing editors 45 | 46 | this.subscriptions.add(atom.commands.add('atom-workspace', { 47 | 'atom-inline-blame:toggle': () => { 48 | this.toggleState = !this.toggleState; 49 | if (this.toggleState) { 50 | this.view = new InlineBlameView(); 51 | } else { 52 | this.view && this.view.removeDecoration(); 53 | this.view && this.view.subscriptions.dispose(); 54 | this.view = null; 55 | } 56 | } 57 | })); 58 | 59 | // Annotate current open buffer lazily 60 | window.requestIdleCallback(() => { 61 | const currentEditor = atom.workspace.getActiveTextEditor(); 62 | if (currentEditor) { 63 | this.attachBlamer.bind(this)(currentEditor); 64 | } 65 | }); 66 | }, 67 | 68 | deactivate() { 69 | this.subscriptions.dispose(); 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /menus/atom-inline-blame.json: -------------------------------------------------------------------------------- 1 | { 2 | "menu": [ 3 | { 4 | "label": "Packages", 5 | "submenu": [ 6 | { 7 | "label": "atom-inline-blame", 8 | "submenu": [ 9 | { 10 | "label": "Toggle inline blame", 11 | "command": "atom-inline-blame:toggle" 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atom-inline-blame", 3 | "version": "0.0.10", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "date-fns": { 8 | "version": "1.29.0", 9 | "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.29.0.tgz", 10 | "integrity": "sha512-lbTXWZ6M20cWH8N9S6afb0SBm6tMk+uUg6z3MqHPKE9atmsY3kJkTm8vKe93izJ2B2+q5MV990sM2CHgtAZaOw==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atom-inline-blame", 3 | "main": "./lib/atom-inline-blame", 4 | "version": "0.0.10", 5 | "description": "Inline Git Blame", 6 | "keywords": [], 7 | "repository": "https://github.com/gregorym/atom-inline-blame", 8 | "license": "MIT", 9 | "engines": { 10 | "atom": ">=1.0.0 <2.0.0" 11 | }, 12 | "dependencies": { 13 | "date-fns": "^1.29.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spec/atom-inline-blame-spec.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import AtomInlineBlame from '../lib/atom-inline-blame'; 4 | 5 | // Use the command `window:run-package-specs` (cmd-alt-ctrl-p) to run specs. 6 | // 7 | // To run a specific `it` or `describe` block add an `f` to the front (e.g. `fit` 8 | // or `fdescribe`). Remove the `f` to unfocus the block. 9 | 10 | describe('AtomInlineBlame', () => { 11 | let workspaceElement, activationPromise; 12 | 13 | beforeEach(() => { 14 | workspaceElement = atom.views.getView(atom.workspace); 15 | activationPromise = atom.packages.activatePackage('atom-inline-blame'); 16 | }); 17 | 18 | describe('when the atom-inline-blame:toggle event is triggered', () => { 19 | it('hides and shows the modal panel', () => { 20 | // Before the activation event the view is not on the DOM, and no panel 21 | // has been created 22 | expect(workspaceElement.querySelector('.atom-inline-blame')).not.toExist(); 23 | 24 | // This is an activation event, triggering it will cause the package to be 25 | // activated. 26 | atom.commands.dispatch(workspaceElement, 'atom-inline-blame:toggle'); 27 | 28 | waitsForPromise(() => { 29 | return activationPromise; 30 | }); 31 | 32 | runs(() => { 33 | expect(workspaceElement.querySelector('.atom-inline-blame')).toExist(); 34 | 35 | let atomInlineBlameElement = workspaceElement.querySelector('.atom-inline-blame'); 36 | expect(atomInlineBlameElement).toExist(); 37 | 38 | let atomInlineBlamePanel = atom.workspace.panelForItem(atomInlineBlameElement); 39 | expect(atomInlineBlamePanel.isVisible()).toBe(true); 40 | atom.commands.dispatch(workspaceElement, 'atom-inline-blame:toggle'); 41 | expect(atomInlineBlamePanel.isVisible()).toBe(false); 42 | }); 43 | }); 44 | 45 | it('hides and shows the view', () => { 46 | // This test shows you an integration test testing at the view level. 47 | 48 | // Attaching the workspaceElement to the DOM is required to allow the 49 | // `toBeVisible()` matchers to work. Anything testing visibility or focus 50 | // requires that the workspaceElement is on the DOM. Tests that attach the 51 | // workspaceElement to the DOM are generally slower than those off DOM. 52 | jasmine.attachToDOM(workspaceElement); 53 | 54 | expect(workspaceElement.querySelector('.atom-inline-blame')).not.toExist(); 55 | 56 | // This is an activation event, triggering it causes the package to be 57 | // activated. 58 | atom.commands.dispatch(workspaceElement, 'atom-inline-blame:toggle'); 59 | 60 | waitsForPromise(() => { 61 | return activationPromise; 62 | }); 63 | 64 | runs(() => { 65 | // Now we can test for view visibility 66 | let atomInlineBlameElement = workspaceElement.querySelector('.atom-inline-blame'); 67 | expect(atomInlineBlameElement).toBeVisible(); 68 | atom.commands.dispatch(workspaceElement, 'atom-inline-blame:toggle'); 69 | expect(atomInlineBlameElement).not.toBeVisible(); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /spec/atom-inline-blame-view-spec.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import AtomInlineBlameView from '../lib/atom-inline-blame-view'; 4 | 5 | describe('AtomInlineBlameView', () => { 6 | it('has one valid test', () => { 7 | expect('life').toBe('easy'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /styles/atom-inline-blame.less: -------------------------------------------------------------------------------- 1 | @import "ui-variables"; 2 | 3 | @keyframes fadeIn { 4 | from { opacity: 0; } 5 | to { opacity: 0.2; } 6 | } 7 | 8 | .atom-inline-git-blame[data-screen-row]::after { 9 | content: var(--inline-blame-message, ""); 10 | opacity: 0.2; 11 | position: relative; 12 | left: 25px; 13 | animation: fadeIn 0.2s linear 0s 1; 14 | -webkit-font-smoothing: antialiased; 15 | }; 16 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | date-fns@>=1.29.0: 6 | version "1.29.0" 7 | resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6" 8 | --------------------------------------------------------------------------------