diff --git a/lib/models/hunk-line.js b/lib/models/hunk-line.js index 97f09424d9..f75ce7f4b8 100644 --- a/lib/models/hunk-line.js +++ b/lib/models/hunk-line.js @@ -6,11 +6,12 @@ export default class HunkLine { '\\': 'nonewline', } - constructor(text, status, oldLineNumber, newLineNumber) { + constructor(text, status, oldLineNumber, newLineNumber, diffLineNumber) { this.text = text; this.status = status; this.oldLineNumber = oldLineNumber; this.newLineNumber = newLineNumber; + this.diffLineNumber = diffLineNumber; } copy({text, status, oldLineNumber, newLineNumber} = {}) { diff --git a/lib/models/repository.js b/lib/models/repository.js index 2b6e5ecdfe..2f17bd2d9b 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -178,6 +178,7 @@ export default class Repository { } buildFilePatchesFromRawDiffs(rawDiffs) { + let diffLineNumber = 0; return rawDiffs.map(patch => { const hunks = patch.hunks.map(hunk => { let oldLineNumber = hunk.oldStartLine; @@ -187,17 +188,19 @@ export default class Repository { const text = line.slice(1); let hunkLine; if (status === 'unchanged') { - hunkLine = new HunkLine(text, status, oldLineNumber, newLineNumber); + hunkLine = new HunkLine(text, status, oldLineNumber, newLineNumber, diffLineNumber++); oldLineNumber++; newLineNumber++; } else if (status === 'added') { - hunkLine = new HunkLine(text, status, -1, newLineNumber); + hunkLine = new HunkLine(text, status, -1, newLineNumber, diffLineNumber++); newLineNumber++; } else if (status === 'deleted') { - hunkLine = new HunkLine(text, status, oldLineNumber, -1); + hunkLine = new HunkLine(text, status, oldLineNumber, -1, diffLineNumber++); oldLineNumber++; } else if (status === 'nonewline') { - hunkLine = new HunkLine(text.substr(1), status, -1, -1); + hunkLine = new HunkLine(text.substr(1), status, -1, -1, diffLineNumber++); + } else { + throw new Error(`unknow status type: ${status}`); } return hunkLine; }); diff --git a/lib/views/file-patch-selection.js b/lib/views/file-patch-selection.js index 05060f7dbf..85fdff35a6 100644 --- a/lib/views/file-patch-selection.js +++ b/lib/views/file-patch-selection.js @@ -246,4 +246,8 @@ export default class FilePatchSelection { this.resolveNextUpdatePromise = resolve; }); } + + getLineSelectionTailIndex() { + return this.linesSelection.getTailIndex(); + } } diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js index 818e1b001f..ea22cb5a8d 100644 --- a/lib/views/file-patch-view.js +++ b/lib/views/file-patch-view.js @@ -4,6 +4,7 @@ import {CompositeDisposable, Disposable} from 'atom'; import etch from 'etch'; +import cx from 'classnames'; import {autobind} from 'core-decorators'; import HunkView from './hunk-view'; @@ -62,9 +63,10 @@ export default class FilePatchView { const headHunk = this.selection.getHeadHunk(); const headLine = this.selection.getHeadLine(); const hunkSelectionMode = this.selection.getMode() === 'hunk'; - const stageButtonLabelPrefix = this.props.stagingStatus === 'unstaged' ? 'Stage' : 'Unstage'; + const unstaged = this.props.stagingStatus === 'unstaged'; + const stageButtonLabelPrefix = unstaged ? 'Stage' : 'Unstage'; return ( -
{this.props.hunks.map(hunk => { @@ -85,9 +87,10 @@ export default class FilePatchView { selectedLines={selectedLines} headLine={headLine} headHunk={headHunk} - mousedownOnHeader={() => this.mousedownOnHeader(hunk)} + mousedownOnHeader={e => this.mousedownOnHeader(e, hunk)} mousedownOnLine={this.mousedownOnLine} mousemoveOnLine={this.mousemoveOnLine} + contextMenuOnItem={this.contextMenuOnItem} didClickStageButton={() => this.didClickStageButtonForHunk(hunk)} registerView={this.props.registerHunkView} /> @@ -97,45 +100,89 @@ export default class FilePatchView { ); } - mousedownOnHeader(hunk) { - this.selection.selectHunk(hunk); - this.mouseSelectionInProgress = true; - return etch.update(this); + @autobind + async contextMenuOnItem(event, hunk, line) { + const mode = this.selection.getMode(); + if (mode === 'hunk' && !this.selection.getSelectedHunks().has(hunk)) { + this.selection.selectHunk(hunk, event.shiftKey); + } else if (mode === 'line' && !this.selection.getSelectedLines().has(line)) { + this.selection.selectLine(line, event.shiftKey); + } else { + return; + } + event.stopPropagation(); + await etch.update(this); + const newEvent = new MouseEvent(event.type, event); + requestAnimationFrame(() => { + event.target.parentNode.dispatchEvent(newEvent); + }); } - @autobind - mousedownOnLine(event, hunk, line) { + async mousedownOnHeader(event, hunk) { + if (event.button !== 0) { return; } if (event.ctrlKey || event.metaKey) { - this.selection.addOrSubtractLineSelection(line); + if (this.selection.getMode() === 'hunk') { + this.selection.addOrSubtractHunkSelection(hunk); + } else { + // TODO: optimize + hunk.getLines().forEach(line => { + this.selection.addOrSubtractLineSelection(line); + this.selection.coalesce(); + }); + } } else if (event.shiftKey) { if (this.selection.getMode() === 'hunk') { this.selection.selectHunk(hunk, true); } else { - this.selection.selectLine(line, true); + const hunkLines = hunk.getLines(); + const tailIndex = this.selection.getLineSelectionTailIndex(); + const selectedHunkAfterTail = tailIndex < hunkLines[0].diffLineNumber; + if (selectedHunkAfterTail) { + this.selection.selectLine(hunkLines[hunkLines.length - 1], true); + } else { + this.selection.selectLine(hunkLines[0], true); + } } } else { - if (event.detail === 1) { - this.selection.selectLine(line, false); - } else { - this.selection.selectHunk(hunk, false); - } + this.selection.selectHunk(hunk, false); } this.mouseSelectionInProgress = true; - return etch.update(this); + await etch.update(this); } @autobind - mousemoveOnLine(event, hunk, line) { - if (this.mouseSelectionInProgress) { + async mousedownOnLine(event, hunk, line) { + if (event.button !== 0) { return; } + if (event.ctrlKey || event.metaKey) { + if (this.selection.getMode() === 'hunk') { + this.selection.addOrSubtractHunkSelection(hunk); + } else { + this.selection.addOrSubtractLineSelection(line); + } + } else if (event.shiftKey) { if (this.selection.getMode() === 'hunk') { this.selection.selectHunk(hunk, true); } else { this.selection.selectLine(line, true); } - return etch.update(this); + } else if (event.detail === 1) { + this.selection.selectLine(line, false); + } else if (event.detail === 2) { + this.selection.selectHunk(hunk, false); + } + this.mouseSelectionInProgress = true; + await etch.update(this); + } + + @autobind + async mousemoveOnLine(event, hunk, line) { + if (!this.mouseSelectionInProgress) { return; } + if (this.selection.getMode() === 'hunk') { + this.selection.selectHunk(hunk, true); } else { - return null; + this.selection.selectLine(line, true); } + await etch.update(this); } @autobind @@ -149,6 +196,18 @@ export default class FilePatchView { return etch.update(this); } + getPatchSelectionMode() { + return this.selection.getMode(); + } + + getSelectedHunks() { + return this.selection.getSelectedHunks(); + } + + getSelectedLines() { + return this.selection.getSelectedLines(); + } + selectNext() { this.selection.selectNext(); return etch.update(this); diff --git a/lib/views/hunk-view.js b/lib/views/hunk-view.js index 8566c2ae83..f9f74bd9ce 100644 --- a/lib/views/hunk-view.js +++ b/lib/views/hunk-view.js @@ -43,7 +43,7 @@ export default class HunkView { return (
this.props.mousedownOnHeader()}> + onmousedown={e => this.props.mousedownOnHeader(e)}> {this.props.hunk.getHeader().trim()} {this.props.hunk.getSectionHeading().trim()} @@ -62,6 +62,7 @@ export default class HunkView { registerLineElement={this.registerLineElement} mousedown={this.mousedownOnLine} mousemove={this.mousemoveOnLine} + contextMenuOnItem={(e, clickedLine) => this.props.contextMenuOnItem(e, this.props.hunk, clickedLine)} />, )}
@@ -101,7 +102,8 @@ class LineView { return (
this.props.mousedown(event, line)} - onmousemove={event => this.props.mousemove(event, line)}> + onmousemove={event => this.props.mousemove(event, line)} + oncontextmenu={event => this.props.contextMenuOnItem(event, line)}>
{oldLineNumber}
{newLineNumber}
diff --git a/lib/views/list-selection.js b/lib/views/list-selection.js index ce59d13e11..b5d70aff05 100644 --- a/lib/views/list-selection.js +++ b/lib/views/list-selection.js @@ -209,4 +209,8 @@ export default class ListSelection { const selection = this.selections[0]; return Math.min(selection.head, selection.tail); } + + getTailIndex() { + return this.selections[0] ? this.selections[0].tail : null; + } } diff --git a/menus/git.cson b/menus/git.cson index dd621fb69e..99cd8cce19 100644 --- a/menus/git.cson +++ b/menus/git.cson @@ -58,3 +58,15 @@ 'command': 'github:open-file' } ] + '.github-FilePatchView.is-staged': [ + { + 'label': 'Unstage Selection' + 'command': 'core:confirm' + } + ] + '.github-FilePatchView.is-unstaged': [ + { + 'label': 'Stage Selection' + 'command': 'core:confirm' + } + ] diff --git a/styles/hunk-view.less b/styles/hunk-view.less index e3337bf40d..23cbde76df 100644 --- a/styles/hunk-view.less +++ b/styles/hunk-view.less @@ -73,6 +73,9 @@ // Highlight hunk title .github-HunkView { + &.is-selected.is-hunkMode &-header { + background-color: @background-color-selected; + } &.is-selected.is-hunkMode &-title { color: @text-color-selected; } diff --git a/test/controllers/file-patch-controller.test.js b/test/controllers/file-patch-controller.test.js index 854df7a9ad..8ff5b5ce87 100644 --- a/test/controllers/file-patch-controller.test.js +++ b/test/controllers/file-patch-controller.test.js @@ -136,7 +136,7 @@ describe('FilePatchController', function() { let hunk = unstagedFilePatch.getHunks()[0]; let lines = hunk.getLines(); let hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({detail: 1}, hunk, lines[1]); + hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); hunkView.props.mousemoveOnLine({}, hunk, lines[3]); view.mouseup(); await hunkView.props.didClickStageButton(); @@ -169,9 +169,9 @@ describe('FilePatchController', function() { hunk = stagedFilePatch.getHunks()[0]; lines = hunk.getLines(); hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({detail: 1}, hunk, lines[1]); + hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); view.mouseup(); - hunkView.props.mousedownOnLine({detail: 1, metaKey: true}, hunk, lines[2]); + hunkView.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk, lines[2]); view.mouseup(); await hunkView.props.didClickStageButton(); @@ -261,7 +261,7 @@ describe('FilePatchController', function() { let hunk = unstagedFilePatch.getHunks()[0]; let lines = hunk.getLines(); let hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({detail: 1}, hunk, lines[1]); + hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[1]); view.mouseup(); // stage lines in rapid succession @@ -286,7 +286,7 @@ describe('FilePatchController', function() { hunk = modifiedFilePatch.getHunks()[0]; lines = hunk.getLines(); hunkView = hunkViewsByHunk.get(hunk); - hunkView.props.mousedownOnLine({detail: 1}, hunk, lines[2]); + hunkView.props.mousedownOnLine({button: 0, detail: 1}, hunk, lines[2]); view.mouseup(); const line2StagingPromises = hunkView.props.didClickStageButton(); diff --git a/test/views/file-patch-view.test.js b/test/views/file-patch-view.test.js index 64aca20905..8ae7226600 100644 --- a/test/views/file-patch-view.test.js +++ b/test/views/file-patch-view.test.js @@ -16,88 +16,269 @@ describe('FilePatchView', function() { atomEnv.destroy(); }); - it('allows lines and hunks to be selected via the mouse', async function() { - const hunks = [ - new Hunk(1, 1, 2, 4, '', [ - new HunkLine('line-1', 'unchanged', 1, 1), + describe('mouse selection', () => { + it('allows lines and hunks to be selected via mouse drag', async function() { + const hunks = [ + new Hunk(1, 1, 2, 4, '', [ + new HunkLine('line-1', 'unchanged', 1, 1), + new HunkLine('line-2', 'added', -1, 2), + new HunkLine('line-3', 'added', -1, 3), + new HunkLine('line-4', 'unchanged', 2, 4), + ]), + new Hunk(5, 7, 1, 4, '', [ + new HunkLine('line-5', 'unchanged', 5, 7), + new HunkLine('line-6', 'added', -1, 8), + new HunkLine('line-7', 'added', -1, 9), + new HunkLine('line-8', 'added', -1, 10), + ]), + ]; + const hunkViews = new Map(); + function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } + + const filePatchView = new FilePatchView({commandRegistry, hunks, registerHunkView}); + const hunkView0 = hunkViews.get(hunks[0]); + const hunkView1 = hunkViews.get(hunks[1]); + + // drag a selection + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + await hunkViews.get(hunks[1]).props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); + await filePatchView.mouseup(); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + + // start a new selection, drag it across an existing selection + await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunks[1], hunks[1].lines[3]); + await hunkView0.props.mousemoveOnLine({}, hunks[0], hunks[0].lines[0]); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + + // drag back down without releasing mouse; the other selection remains intact + await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); + assert(hunkView0.props.isSelected); + assert(!hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + assert(!hunkView1.props.selectedLines.has(hunks[1].lines[2])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + + // drag back up so selections are adjacent, then release the mouse. selections should merge. + await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[2]); + await filePatchView.mouseup(); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + + // we detect merged selections based on the head here + await filePatchView.selectToNext(); + assert(!hunkView0.props.isSelected); + assert(!hunkView0.props.selectedLines.has(hunks[0].lines[2])); + + // double-clicking clears the existing selection and starts hunk-wise selection + await hunkView0.props.mousedownOnLine({button: 0, detail: 2}, hunks[0], hunks[0].lines[2]); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(!hunkView1.props.isSelected); + + await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + + // clicking the header clears the existing selection and starts hunk-wise selection + await hunkView0.props.mousedownOnHeader({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(!hunkView1.props.isSelected); + + await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); + assert(hunkView0.props.isSelected); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); + assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); + assert(hunkView1.props.isSelected); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); + assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + }); + + it('allows lines and hunks to be selected via cmd-clicking', async () => { + const hunk0 = new Hunk(1, 1, 2, 4, '', [ + new HunkLine('line-1', 'added', -1, 1), new HunkLine('line-2', 'added', -1, 2), new HunkLine('line-3', 'added', -1, 3), - new HunkLine('line-4', 'unchanged', 2, 4), - ]), - new Hunk(5, 7, 1, 4, '', [ - new HunkLine('line-5', 'unchanged', 5, 7), + ]); + const hunk1 = new Hunk(5, 7, 1, 4, '', [ + new HunkLine('line-5', 'added', -1, 7), new HunkLine('line-6', 'added', -1, 8), new HunkLine('line-7', 'added', -1, 9), new HunkLine('line-8', 'added', -1, 10), - ]), - ]; - const hunkViews = new Map(); - function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } - - const filePatchView = new FilePatchView({commandRegistry, hunks, registerHunkView}); - const hunkView0 = hunkViews.get(hunks[0]); - const hunkView1 = hunkViews.get(hunks[1]); - - // drag a selection - await hunkView0.props.mousedownOnLine({detail: 1}, hunks[0], hunks[0].lines[2]); - await hunkViews.get(hunks[1]).props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); - await filePatchView.mouseup(); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - - // start a new selection, drag it across an existing selection - await hunkView1.props.mousedownOnLine({detail: 1, metaKey: true}, hunks[1], hunks[1].lines[3]); - await hunkView0.props.mousemoveOnLine({}, hunks[0], hunks[0].lines[0]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); - - // drag back down without releasing mouse; the other selection remains intact - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); - assert(hunkView0.props.isSelected); - assert(!hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(!hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); - - // drag back up so selections are adjacent, then release the mouse. selections should merge. - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[2]); - await filePatchView.mouseup(); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); - - // we detect merged selections based on the head here - await filePatchView.selectToNext(); - assert(!hunkView0.props.isSelected); - assert(!hunkView0.props.selectedLines.has(hunks[0].lines[2])); - - // double-click drag clears the existing selection and starts hunk-wise selection - await hunkView0.props.mousedownOnLine({detail: 2}, hunks[0], hunks[0].lines[2]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(!hunkView1.props.isSelected); - - await hunkView1.props.mousemoveOnLine({}, hunks[1], hunks[1].lines[1]); - assert(hunkView0.props.isSelected); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[1])); - assert(hunkView0.props.selectedLines.has(hunks[0].lines[2])); - assert(hunkView1.props.isSelected); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[1])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[2])); - assert(hunkView1.props.selectedLines.has(hunks[1].lines[3])); + ]); + const hunkViews = new Map(); + function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } + + const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0, hunk1], registerHunkView}); + const hunkView0 = hunkViews.get(hunk0); + const hunkView1 = hunkViews.get(hunk1); + + // in line selection mode, cmd-click line + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + assert.equal(filePatchView.getPatchSelectionMode(), 'line'); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2], hunk1.lines[2]])); + await hunkView1.props.mousedownOnLine({button: 0, detail: 1, metaKey: true}, hunk1, hunk1.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + + // in line selection mode, cmd-click hunk header for separate hunk + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + assert.equal(filePatchView.getPatchSelectionMode(), 'line'); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + await hunkView1.props.mousedownOnHeader({button: 0, metaKey: true}); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2], ...hunk1.lines])); + await hunkView1.props.mousedownOnHeader({button: 0, metaKey: true}); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + + // in hunk selection mode, cmd-click line for separate hunk + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + filePatchView.togglePatchSelectionMode(); + assert.equal(filePatchView.getPatchSelectionMode(), 'hunk'); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines)); + + // in hunk selection mode, cmd-click hunk header for separate hunk + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + filePatchView.togglePatchSelectionMode(); + assert.equal(filePatchView.getPatchSelectionMode(), 'hunk'); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines)); + }); + + it('allows lines and hunks to be selected via shift-clicking', async () => { + const hunk0 = new Hunk(1, 1, 2, 4, '', [ + new HunkLine('line-1', 'unchanged', 1, 1, 0), + new HunkLine('line-2', 'added', -1, 2, 1), + new HunkLine('line-3', 'added', -1, 3, 2), + ]); + const hunk1 = new Hunk(5, 7, 1, 4, '', [ + new HunkLine('line-5', 'added', -1, 7, 3), + new HunkLine('line-6', 'added', -1, 8, 4), + new HunkLine('line-7', 'added', -1, 9, 5), + new HunkLine('line-8', 'added', -1, 10, 6), + ]); + const hunk2 = new Hunk(15, 17, 1, 4, '', [ + new HunkLine('line-15', 'added', -1, 15, 7), + new HunkLine('line-16', 'added', -1, 18, 8), + new HunkLine('line-17', 'added', -1, 19, 9), + new HunkLine('line-18', 'added', -1, 20, 10), + ]); + const hunkViews = new Map(); + function registerHunkView(hunk, view) { hunkViews.set(hunk, view); } + + const filePatchView = new FilePatchView({commandRegistry, hunks: [hunk0, hunk1, hunk2], registerHunkView}); + const hunkView0 = hunkViews.get(hunk0); + const hunkView1 = hunkViews.get(hunk1); + const hunkView2 = hunkViews.get(hunk2); + + // in line selection mode, shift-click line in separate hunk that comes after selected line + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + await hunkView2.props.mousedownOnLine({button: 0, detail: 1, shiftKey: true}, hunk2, hunk2.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + await hunkView1.props.mousedownOnLine({button: 0, detail: 1, shiftKey: true}, hunk1, hunk1.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines.slice(0, 3)])); + + // in line selection mode, shift-click hunk header for separate hunk that comes after selected line + await hunkView0.props.mousedownOnLine({button: 0, detail: 1}, hunk0, hunk0.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk0.lines[2]])); + await hunkView2.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk2); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines, ...hunk2.lines])); + await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(2), ...hunk1.lines])); + + // in line selection mode, shift-click hunk header for separate hunk that comes before selected line + await hunkView2.props.mousedownOnLine({button: 0, detail: 1}, hunk2, hunk2.lines[2]); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([hunk2.lines[2]])); + await hunkView0.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk0); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines.slice(0, 3)])); + + // in hunk selection mode, shift-click hunk header for separate hunk that comes after selected line + await hunkView0.props.mousedownOnHeader({button: 0}, hunk0); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0])); + assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk0.lines.slice(1))); + await hunkView2.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk2); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); + await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines])); + + // in hunk selection mode, shift-click hunk header for separate hunk that comes before selected line + await hunkView2.props.mousedownOnHeader({button: 0}, hunk2); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set(hunk2.lines)); + await hunkView0.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk0); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk0, hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk0.lines.slice(1), ...hunk1.lines, ...hunk2.lines])); + await hunkView1.props.mousedownOnHeader({button: 0, shiftKey: true}, hunk1); + await filePatchView.mouseup(); + assertEqualSets(filePatchView.getSelectedHunks(), new Set([hunk1, hunk2])); + assertEqualSets(filePatchView.getSelectedLines(), new Set([...hunk1.lines, ...hunk2.lines])); + }); }); it('scrolls off-screen lines and hunks into view when they are selected', async function() { @@ -212,7 +393,7 @@ describe('FilePatchView', function() { const openCurrentFile = sinon.stub(); const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({detail: 1}, hunks[0], hunks[0].lines[2]); + filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); await filePatchView.mousemoveOnLine({}, hunks[0], hunks[0].lines[3]); await filePatchView.mouseup(); @@ -241,7 +422,7 @@ describe('FilePatchView', function() { const openCurrentFile = sinon.stub(); const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({detail: 1}, hunks[1], hunks[1].lines[2]); + filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[1], hunks[1].lines[2]); await filePatchView.mousemoveOnLine({}, hunks[1], hunks[1].lines[3]); await filePatchView.mouseup(); @@ -263,7 +444,7 @@ describe('FilePatchView', function() { const openCurrentFile = sinon.stub(); const filePatchView = new FilePatchView({commandRegistry, hunks, openCurrentFile}); - filePatchView.mousedownOnLine({detail: 1}, hunks[0], hunks[0].lines[2]); + filePatchView.mousedownOnLine({button: 0, detail: 1}, hunks[0], hunks[0].lines[2]); await filePatchView.mouseup(); filePatchView.openFile();