diff --git a/lib/github-package.js b/lib/github-package.js index 25649e86fc..5a4e240876 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -16,9 +16,9 @@ export default class GithubPackage { this.project = project this.commandRegistry = commandRegistry this.notificationManager = notificationManager - this.changeObserver = process.platform === 'linux' ? - new WorkspaceChangeObserver(window, atom.workspace) : - new FileSystemChangeObserver() + this.changeObserver = process.platform === 'linux' + ? new WorkspaceChangeObserver(window, atom.workspace) + : new FileSystemChangeObserver() this.activeRepository = null this.repositoriesByProjectDirectory = new Map() this.subscriptions = new CompositeDisposable() @@ -83,7 +83,7 @@ export default class GithubPackage { } didSelectFilePatch (filePatch, stagingStatus, {focus} = {}) { - if (!filePatch) return + if (!filePatch || filePatch.isDestroyed()) return const repository = this.getActiveRepository() let containingPane if (this.filePatchController) { @@ -144,7 +144,9 @@ export default class GithubPackage { } if (activeItem instanceof FilePatchController) { - activeRepository = activeItem.props.repository + if (!activeItem.props.repository.isDestroyed()) { + activeRepository = activeItem.props.repository + } } if (activeRepository && activeRepository !== this.activeRepository) { diff --git a/lib/models/repository.js b/lib/models/repository.js index 3fa5bb511a..bc92250908 100644 --- a/lib/models/repository.js +++ b/lib/models/repository.js @@ -49,13 +49,19 @@ export default class Repository { this.unstagedFilePatchesByPath = new Map() this.mergeConflictsByPath = new Map() this.git = gitStrategy + this.destroyed = false } destroy () { + this.destroyed = true this.emitter.emit('did-destroy') this.emitter.dispose() } + isDestroyed () { + return this.destroyed + } + onDidDestroy (callback) { return this.emitter.on('did-destroy', callback) } diff --git a/lib/views/composite-list-selection.js b/lib/views/composite-list-selection.js new file mode 100644 index 0000000000..9fcc97a448 --- /dev/null +++ b/lib/views/composite-list-selection.js @@ -0,0 +1,143 @@ +/** @babel */ + +import ListSelection from './list-selection' + +export default class CompositeListSelection { + constructor ({listsByKey, idForItem}) { + this.keysBySelection = new Map() + this.selections = [] + this.idForItem = idForItem || (item => item) + + for (const key in listsByKey) { + const selection = new ListSelection({items: listsByKey[key]}) + this.keysBySelection.set(selection, key) + this.selections.push(selection) + } + + this.activeSelectionIndex = 0 + } + + updateLists (listsByKey) { + const keys = Object.keys(listsByKey) + for (let i = 0; i < keys.length; i++) { + const newItems = listsByKey[keys[i]] + const selection = this.selections[i] + const oldHeadItem = selection.getHeadItem() + selection.setItems(newItems) + const newHeadItem = oldHeadItem ? newItems.find(item => this.idForItem(item) === this.idForItem(oldHeadItem)) : null + if (newHeadItem) selection.selectItem(newHeadItem) + } + if (this.getActiveSelection().getItems().length === 0) { + this.activateNextSelection() || this.activatePreviousSelection() + } + } + + getActiveListKey () { + return this.keysBySelection.get(this.getActiveSelection()) + } + + getSelectedItems () { + return this.getActiveSelection().getSelectedItems() + } + + getHeadItem () { + return this.getActiveSelection().getHeadItem() + } + + getActiveSelection () { + return this.selections[this.activeSelectionIndex] + } + + activateSelection (selection) { + const index = this.selections.indexOf(selection) + if (index === -1) throw new Error('Selection not found') + this.activeSelectionIndex = index + } + + activateNextSelection () { + for (let i = this.activeSelectionIndex + 1; i < this.selections.length; i++) { + if (this.selections[i].getItems().length > 0) { + this.activeSelectionIndex = i + return true + } + } + return false + } + + activatePreviousSelection () { + for (let i = this.activeSelectionIndex - 1; i >= 0; i--) { + if (this.selections[i].getItems().length > 0) { + this.activeSelectionIndex = i + return true + } + } + return false + } + + selectItem (item, preserveTail = false) { + const selection = this.selectionForItem(item) + if (!selection) throw new Error(`No item found: ${item}`) + if (!preserveTail) this.activateSelection(selection) + if (selection === this.getActiveSelection()) selection.selectItem(item, preserveTail) + } + + addOrSubtractSelection (item) { + const selection = this.selectionForItem(item) + if (!selection) throw new Error(`No item found: ${item}`) + + if (selection === this.getActiveSelection()) { + selection.addOrSubtractSelection(item) + } else { + this.activateSelection(selection) + selection.selectItem(item) + } + } + + selectAllItems () { + this.getActiveSelection().selectAllItems() + } + + selectFirstItem (preserveTail) { + this.getActiveSelection().selectFirstItem(preserveTail) + } + + selectLastItem (preserveTail) { + this.getActiveSelection().selectLastItem(preserveTail) + } + + coalesce () { + this.getActiveSelection().coalesce() + } + + selectionForItem (item) { + return this.selections.find(selection => selection.getItems().indexOf(item) > -1) + } + + listKeyForItem (item) { + return this.keysBySelection.get(this.selectionForItem(item)) + } + + selectNextItem (preserveTail = false) { + if (!preserveTail && this.getActiveSelection().getHeadItem() === this.getActiveSelection().getLastItem()) { + if (this.activateNextSelection()) { + this.getActiveSelection().selectFirstItem() + } else { + this.getActiveSelection().selectLastItem() + } + } else { + this.getActiveSelection().selectNextItem(preserveTail) + } + } + + selectPreviousItem (preserveTail = false) { + if (!preserveTail && this.getActiveSelection().getHeadItem() === this.getActiveSelection().getItems()[0]) { + if (this.activatePreviousSelection()) { + this.getActiveSelection().selectLastItem() + } else { + this.getActiveSelection().selectFirstItem() + } + } else { + this.getActiveSelection().selectPreviousItem(preserveTail) + } + } +} diff --git a/lib/views/file-patch-list-item-view.js b/lib/views/file-patch-list-item-view.js index 884773e056..5b14f21e80 100644 --- a/lib/views/file-patch-list-item-view.js +++ b/lib/views/file-patch-list-item-view.js @@ -2,30 +2,31 @@ /** @jsx etch.dom */ import etch from 'etch' -import stateless from 'etch-stateless' - import {classNameForStatus} from '../helpers' -export default stateless(etch, ({filePatch, selected, selectItem, selectionEnabled, ...others}) => { - const status = classNameForStatus[filePatch.getStatus()] - const className = selected ? 'is-selected' : '' +export default class FilePatchListItemView { + constructor (props) { + this.props = props + etch.initialize(this) + this.props.registerItemElement(this.props.filePatch, this.element) + } - let itemAlreadySelected = false - const selectItemIfSelectionEnabled = () => { - if (!itemAlreadySelected) { - if (selectionEnabled) { - selectItem(filePatch) - itemAlreadySelected = true - } - } + update (props) { + this.props = props + this.props.registerItemElement(this.props.filePatch, this.element) + return etch.update(this) } - return ( -
selectItemIfSelectionEnabled()} - onmouseup={() => selectItemIfSelectionEnabled()}> - - {filePatch.getPath()} -
- ) -}) + render () { + const {filePatch, selected, ...others} = this.props + const status = classNameForStatus[filePatch.getStatus()] + const className = selected ? 'is-selected' : '' + + return ( +
+ + {filePatch.getPath()} +
+ ) + } +} diff --git a/lib/views/file-patch-selection.js b/lib/views/file-patch-selection.js index c9ff427117..46b098469f 100644 --- a/lib/views/file-patch-selection.js +++ b/lib/views/file-patch-selection.js @@ -1,21 +1,22 @@ /** @babel */ +import ListSelection from './list-selection' + export default class FilePatchSelection { constructor (hunks) { this.mode = 'hunk' - this.selections = [{head: 0, tail: 0}] + this.hunksSelection = new ListSelection() + this.linesSelection = new ListSelection({isItemSelectable: (line) => line.isChanged()}) this.updateHunks(hunks) } toggleMode () { if (this.mode === 'hunk') { - this.mode = 'line' - const firstLineOfSelectedHunk = this.hunks[this.selections[0].head].lines[0] + const firstLineOfSelectedHunk = this.getHeadHunk().lines[0] this.selectLine(firstLineOfSelectedHunk) if (!firstLineOfSelectedHunk.isChanged()) this.selectNextLine() } else { - this.mode = 'hunk' - const selectedLine = this.lines[this.selections[0].head] + const selectedLine = this.getHeadLine() const hunkContainingSelectedLine = this.hunksByLine.get(selectedLine) this.selectHunk(hunkContainingSelectedLine) } @@ -65,199 +66,91 @@ export default class FilePatchSelection { } } - selectNextHunk (preserveTail) { - let nextHunkIndex = this.selections[0].head - if (nextHunkIndex < this.hunks.length - 1) nextHunkIndex++ - this.selectHunk(this.hunks[nextHunkIndex], preserveTail) - } - - selectPreviousHunk (preserveTail) { - let previousHunkIndex = this.selections[0].head - if (previousHunkIndex > 0) previousHunkIndex-- - this.selectHunk(this.hunks[previousHunkIndex], preserveTail) - } - - selectNextLine (preserveTail = false) { - if (this.selections.length === 0) { - this.selectFirstLine() - return - } - - let lineIndex = this.selections[0].head - let nextLineIndex = lineIndex - - while (lineIndex < this.lines.length - 1) { - lineIndex++ - if (this.lines[lineIndex].isChanged()) { - nextLineIndex = lineIndex - break - } - } - - this.selectLine(this.lines[nextLineIndex], preserveTail) + selectHunk (hunk, preserveTail = false) { + this.mode = 'hunk' + this.hunksSelection.selectItem(hunk, preserveTail) } - selectPreviousLine (preserveTail = false) { - if (this.selections.length === 0) { - this.selectLastLine() - return - } - - let lineIndex = this.selections[0].head - let previousLineIndex = lineIndex - - while (lineIndex > 0) { - lineIndex-- - if (this.lines[lineIndex].isChanged()) { - previousLineIndex = lineIndex - break - } - } - - this.selectLine(this.lines[previousLineIndex], preserveTail) + addOrSubtractHunkSelection (hunk) { + this.mode = 'hunk' + this.hunksSelection.addOrSubtractSelection(hunk) } selectAllHunks () { - this.selectFirstHunk() - this.selectLastHunk(true) + this.mode = 'hunk' + this.hunksSelection.selectAllItems() } selectFirstHunk (preserveTail) { - this.selectHunk(this.hunks[0], preserveTail) + this.mode = 'hunk' + this.hunksSelection.selectFirstItem(preserveTail) } selectLastHunk (preserveTail) { - this.selectHunk(this.hunks[this.hunks.length - 1], preserveTail) + this.mode = 'hunk' + this.hunksSelection.selectLastItem(preserveTail) } - selectAllLines () { - this.selectFirstLine() - this.selectLastLine(true) + selectNextHunk (preserveTail) { + this.mode = 'hunk' + this.hunksSelection.selectNextItem(preserveTail) } - selectFirstLine (preserveTail) { - for (let i = 0; i < this.lines.length; i++) { - const line = this.lines[i] - if (line.isChanged()) { - this.selectLine(line, preserveTail) - break - } - } + selectPreviousHunk (preserveTail) { + this.mode = 'hunk' + this.hunksSelection.selectPreviousItem(preserveTail) } - selectLastLine (preserveTail) { - for (let i = this.lines.length - 1; i > 0; i--) { - const line = this.lines[i] - if (line.isChanged()) { - this.selectLine(line, preserveTail) - break - } + getSelectedHunks (hunk) { + if (this.mode === 'line') { + const selectedHunks = new Set() + const selectedLines = this.getSelectedLines() + selectedLines.forEach(line => selectedHunks.add(this.hunksByLine.get(line))) + return selectedHunks + } else { + return this.hunksSelection.getSelectedItems() } } - selectHunk (hunk, preserveTail = false) { - this.mode = 'hunk' - this.selectItem(this.hunks, hunk, preserveTail, false) + getHeadHunk () { + if (this.mode === 'hunk') { + return this.hunksSelection.getHeadItem() + } } selectLine (line, preserveTail = false) { this.mode = 'line' - this.selectItem(this.lines, line, preserveTail, false) + this.linesSelection.selectItem(line, preserveTail) } - coalesce () { - if (this.selections.length === 0) return - - const mostRecent = this.selections[0] - let mostRecentStart = Math.min(mostRecent.head, mostRecent.tail) - let mostRecentEnd = Math.max(mostRecent.head, mostRecent.tail) - while (mostRecentStart > 0 && !this.lines[mostRecentStart - 1].isChanged()) { - mostRecentStart-- - } - while (mostRecentEnd < (this.lines.length - 1) && !this.lines[mostRecentEnd + 1].isChanged()) { - mostRecentEnd++ - } - - for (let i = 1; i < this.selections.length;) { - const current = this.selections[i] - const currentStart = Math.min(current.head, current.tail) - const currentEnd = Math.max(current.head, current.tail) - if (mostRecentStart <= currentEnd + 1 && currentStart - 1 <= mostRecentEnd) { - if (mostRecent.negate) { - const truncatedSelections = [] - if (current.head > current.tail) { - if (currentEnd > mostRecentEnd) { // suffix - truncatedSelections.push({tail: mostRecentEnd + 1, head: currentEnd}) - } - if (currentStart < mostRecentStart) { // prefix - truncatedSelections.push({tail: currentStart, head: mostRecentStart - 1}) - } - } else { - if (currentStart < mostRecentStart) { // prefix - truncatedSelections.push({head: currentStart, tail: mostRecentStart - 1}) - } - if (currentEnd > mostRecentEnd) { // suffix - truncatedSelections.push({head: mostRecentEnd + 1, tail: currentEnd}) - } - } - this.selections.splice(i, 1, ...truncatedSelections) - i += truncatedSelections.length - } else { - if (mostRecent.head > mostRecent.tail) { - mostRecent.head = Math.max(mostRecentEnd, currentEnd) - mostRecent.tail = Math.min(mostRecentStart, currentStart) - } else { - mostRecent.head = Math.min(mostRecentStart, currentStart) - mostRecent.tail = Math.max(mostRecentEnd, currentEnd) - } - this.selections.splice(i, 1) - } - } else { - i++ - } - } - - if (mostRecent.negate) this.selections.shift() + addOrSubtractLineSelection (line) { + this.mode = 'line' + this.linesSelection.addOrSubtractSelection(line) } - addHunkSelection (hunk) { - this.mode = 'hunk' - this.selectItem(this.hunks, hunk, false, true) + selectAllLines (preserveTail) { + this.mode = 'line' + this.linesSelection.selectAllItems(preserveTail) } - addOrSubtractLineSelection (line) { + selectFirstLine (preserveTail) { this.mode = 'line' - this.selectItem(this.lines, line, false, true) + this.linesSelection.selectFirstItem(preserveTail) } - selectItem (items, item, preserveTail, addOrSubtract) { - if (addOrSubtract && preserveTail) { - throw new Error('addOrSubtract and preserveTail cannot both be true at the same time') - } + selectLastLine (preserveTail) { + this.mode = 'line' + this.linesSelection.selectLastItem(preserveTail) + } - const itemIndex = items.indexOf(item) - if (preserveTail) { - this.selections[0].head = itemIndex - } else { - const selection = {head: itemIndex, tail: itemIndex} - if (addOrSubtract) { - if (this.getSelectedItems().has(item)) selection.negate = true - this.selections.unshift(selection) - } else { - this.selections = [selection] - } - } + selectNextLine (preserveTail = false) { + this.mode = 'line' + this.linesSelection.selectNextItem(preserveTail) } - getSelectedHunks (hunk) { - if (this.mode === 'line') { - const selectedHunks = new Set() - const selectedLines = this.getSelectedLines() - selectedLines.forEach(line => selectedHunks.add(this.hunksByLine.get(line))) - return selectedHunks - } else { - return this.getSelectedItems() - } + selectPreviousLine (preserveTail = false) { + this.mode = 'line' + this.linesSelection.selectPreviousItem(preserveTail) } getSelectedLines () { @@ -270,78 +163,54 @@ export default class FilePatchSelection { }) return selectedLines } else { - return this.getSelectedItems() - } - } - - getHeadHunk () { - if (this.mode === 'hunk' && this.selections.length > 0) { - return this.hunks[this.selections[0].head] + return this.linesSelection.getSelectedItems() } } getHeadLine () { - if (this.mode === 'line' && this.selections.length > 0) { - return this.lines[this.selections[0].head] - } - } - - getSelectedItems () { - const selectedItems = new Set() - const items = (this.mode === 'hunk') ? this.hunks : this.lines - for (let {head, tail, negate} of this.selections.slice().reverse()) { - let start = Math.min(head, tail) - let end = Math.max(head, tail) - for (let i = start; i <= end; i++) { - const item = items[i] - if (this.mode === 'hunk' || item.isChanged()) { - if (negate) { - selectedItems.delete(item) - } else { - selectedItems.add(item) - } - } - } + if (this.mode === 'line') { + return this.linesSelection.getHeadItem() } - return selectedItems } - updateHunks (hunks) { - const oldLines = this.lines - this.hunks = hunks - this.lines = [] + updateHunks (newHunks) { this.hunksByLine = new Map() - for (let hunk of hunks) { + const newLines = [] + for (let hunk of newHunks) { for (let line of hunk.lines) { - this.lines.push(line) + newLines.push(line) this.hunksByLine.set(line, hunk) } } - if (this.lines.length > 0) { - const oldSelectionStart = Math.min(this.selections[0].head, this.selections[0].tail) - let newSelectionHeadAndTail + // Update hunks, preserving selection index + this.hunksSelection.setItems(newHunks) - if (this.mode === 'hunk') { - newSelectionHeadAndTail = Math.min(this.hunks.length - 1, oldSelectionStart) - } else { - let changedLineCount = 0 - for (let i = 0; i < oldSelectionStart; i++) { - if (oldLines[i].isChanged()) changedLineCount++ - } + // Update lines, preserving selection index in *changed* lines + const oldLines = this.linesSelection.getItems() + let newSelectedLine + if (oldLines.length > 0 && newLines.length > 0) { + const oldSelectionStartIndex = this.linesSelection.getMostRecentSelectionStartIndex() + let changedLineCount = 0 + for (let i = 0; i < oldSelectionStartIndex; i++) { + if (oldLines[i].isChanged()) changedLineCount++ + } - for (let i = 0; i < this.lines.length; i++) { - if (this.lines[i].isChanged()) { - newSelectionHeadAndTail = i - if (changedLineCount === 0) break - changedLineCount-- - } + for (let i = 0; i < newLines.length; i++) { + const line = newLines[i] + if (line.isChanged()) { + newSelectedLine = line + if (changedLineCount === 0) break + changedLineCount-- } } - - this.selections = [{head: newSelectionHeadAndTail, tail: newSelectionHeadAndTail}] - } else { - this.selections = [] } + this.linesSelection.setItems(newLines) + if (newSelectedLine) this.linesSelection.selectItem(newSelectedLine) + } + + coalesce () { + this.hunksSelection.coalesce() + this.linesSelection.coalesce() } } diff --git a/lib/views/file-patch-view.js b/lib/views/file-patch-view.js index b041c912b5..f3be001ad0 100644 --- a/lib/views/file-patch-view.js +++ b/lib/views/file-patch-view.js @@ -7,8 +7,6 @@ import etch from 'etch' import HunkView from './hunk-view' import FilePatchSelection from './file-patch-selection' -const EMPTY_SET = new Set() - export default class FilePatchView { constructor (props) { this.props = props diff --git a/lib/views/list-selection.js b/lib/views/list-selection.js new file mode 100644 index 0000000000..1af2f30c30 --- /dev/null +++ b/lib/views/list-selection.js @@ -0,0 +1,211 @@ +/** @babel */ + +export default class ListSelection { + constructor (options = {}) { + if (options.isItemSelectable) this.isItemSelectable = options.isItemSelectable + this.setItems(options.items || []) + } + + isItemSelectable (item) { + return true + } + + setItems (items) { + let newSelectionIndex + if (this.selections && this.selections.length > 0) { + const [{head, tail}] = this.selections + newSelectionIndex = Math.min(head, tail, items.length - 1) + } else { + newSelectionIndex = 0 + } + + this.items = items + if (items.length > 0) { + this.selections = [{head: newSelectionIndex, tail: newSelectionIndex}] + } else { + this.selections = [] + } + } + + getItems () { + return this.items + } + + getLastItem () { + return this.items[this.items.length - 1] + } + + selectFirstItem (preserveTail) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i] + if (this.isItemSelectable(item)) { + this.selectItem(item, preserveTail) + break + } + } + } + + selectLastItem (preserveTail) { + for (let i = this.items.length - 1; i > 0; i--) { + const item = this.items[i] + if (this.isItemSelectable(item)) { + this.selectItem(item, preserveTail) + break + } + } + } + + selectAllItems () { + this.selectFirstItem() + this.selectLastItem(true) + } + + selectNextItem (preserveTail) { + if (this.selections.length === 0) { + this.selectFirstItem() + return + } + + let itemIndex = this.selections[0].head + let nextItemIndex = itemIndex + while (itemIndex < this.items.length - 1) { + itemIndex++ + if (this.isItemSelectable(this.items[itemIndex])) { + nextItemIndex = itemIndex + break + } + } + + this.selectItem(this.items[nextItemIndex], preserveTail) + } + + selectPreviousItem (preserveTail) { + if (this.selections.length === 0) { + this.selectLastItem() + return + } + + let itemIndex = this.selections[0].head + let previousItemIndex = itemIndex + + while (itemIndex > 0) { + itemIndex-- + if (this.isItemSelectable(this.items[itemIndex])) { + previousItemIndex = itemIndex + break + } + } + + this.selectItem(this.items[previousItemIndex], preserveTail) + } + + selectItem (item, preserveTail, addOrSubtract) { + if (addOrSubtract && preserveTail) { + throw new Error('addOrSubtract and preserveTail cannot both be true at the same time') + } + + const itemIndex = this.items.indexOf(item) + if (preserveTail) { + this.selections[0].head = itemIndex + } else { + const selection = {head: itemIndex, tail: itemIndex} + if (addOrSubtract) { + if (this.getSelectedItems().has(item)) selection.negate = true + this.selections.unshift(selection) + } else { + this.selections = [selection] + } + } + } + + addOrSubtractSelection (item) { + this.selectItem(item, false, true) + } + + coalesce () { + if (this.selections.length === 0) return + + const mostRecent = this.selections[0] + let mostRecentStart = Math.min(mostRecent.head, mostRecent.tail) + let mostRecentEnd = Math.max(mostRecent.head, mostRecent.tail) + while (mostRecentStart > 0 && !this.isItemSelectable(this.items[mostRecentStart - 1])) { + mostRecentStart-- + } + while (mostRecentEnd < (this.items.length - 1) && !this.isItemSelectable(this.items[mostRecentEnd + 1])) { + mostRecentEnd++ + } + + for (let i = 1; i < this.selections.length;) { + const current = this.selections[i] + const currentStart = Math.min(current.head, current.tail) + const currentEnd = Math.max(current.head, current.tail) + if (mostRecentStart <= currentEnd + 1 && currentStart - 1 <= mostRecentEnd) { + if (mostRecent.negate) { + const truncatedSelections = [] + if (current.head > current.tail) { + if (currentEnd > mostRecentEnd) { // suffix + truncatedSelections.push({tail: mostRecentEnd + 1, head: currentEnd}) + } + if (currentStart < mostRecentStart) { // prefix + truncatedSelections.push({tail: currentStart, head: mostRecentStart - 1}) + } + } else { + if (currentStart < mostRecentStart) { // prefix + truncatedSelections.push({head: currentStart, tail: mostRecentStart - 1}) + } + if (currentEnd > mostRecentEnd) { // suffix + truncatedSelections.push({head: mostRecentEnd + 1, tail: currentEnd}) + } + } + this.selections.splice(i, 1, ...truncatedSelections) + i += truncatedSelections.length + } else { + mostRecentStart = Math.min(mostRecentStart, currentStart) + mostRecentEnd = Math.max(mostRecentEnd, currentEnd) + if (mostRecent.head >= mostRecent.tail) { + mostRecent.head = mostRecentEnd + mostRecent.tail = mostRecentStart + } else { + mostRecent.head = mostRecentStart + mostRecent.tail = mostRecentEnd + } + this.selections.splice(i, 1) + } + } else { + i++ + } + } + + if (mostRecent.negate) this.selections.shift() + } + + getSelectedItems () { + const selectedItems = new Set() + for (let {head, tail, negate} of this.selections.slice().reverse()) { + let start = Math.min(head, tail) + let end = Math.max(head, tail) + for (let i = start; i <= end; i++) { + const item = this.items[i] + if (this.isItemSelectable(item)) { + if (negate) { + selectedItems.delete(item) + } else { + selectedItems.add(item) + } + } + } + } + return selectedItems + } + + getHeadItem () { + if (this.selections.length > 0) { + return this.items[this.selections[0].head] + } + } + + getMostRecentSelectionStartIndex () { + const selection = this.selections[0] + return Math.min(selection.head, selection.tail) + } +} diff --git a/lib/views/merge-conflict-list-item-view.js b/lib/views/merge-conflict-list-item-view.js index 8af74ddb69..d7bb5652e9 100644 --- a/lib/views/merge-conflict-list-item-view.js +++ b/lib/views/merge-conflict-list-item-view.js @@ -2,8 +2,6 @@ /** @jsx etch.dom */ import etch from 'etch' -import stateless from 'etch-stateless' - import {classNameForStatus} from '../helpers' const statusSymbolMap = { @@ -12,28 +10,33 @@ const statusSymbolMap = { modified: '*' } -export default stateless(etch, ({mergeConflict, selected, selectItem, selectionEnabled, ...others}) => { - let status = classNameForStatus[mergeConflict.getFileStatus()] - const className = selected ? 'is-selected' : '' +export default class FilePatchListItemView { + constructor (props) { + this.props = props + etch.initialize(this) + this.props.registerItemElement(this.props.mergeConflict, this.element) + } - let mousedOverItem = false - const selectItemIfSelectionEnabled = () => { - if (!mousedOverItem) { - if (selectionEnabled) selectItem(mergeConflict) - mousedOverItem = true - } + update (props) { + this.props = props + this.props.registerItemElement(this.props.mergeConflict, this.element) + return etch.update(this) } - return ( -
selectItemIfSelectionEnabled()} - onmouseup={() => selectItemIfSelectionEnabled()}> - - {mergeConflict.getPath()} - - {statusSymbolMap[mergeConflict.getOursStatus()]} - {statusSymbolMap[mergeConflict.getTheirsStatus()]} - -
- ) -}) + render () { + const {mergeConflict, selected, ...others} = this.props + const status = classNameForStatus[mergeConflict.getFileStatus()] + const className = selected ? 'is-selected' : '' + + return ( +
+ + {mergeConflict.getPath()} + + {statusSymbolMap[mergeConflict.getOursStatus()]} + {statusSymbolMap[mergeConflict.getTheirsStatus()]} + +
+ ) + } +} diff --git a/lib/views/staging-view.js b/lib/views/staging-view.js index df2368f2d8..7adb8dfdb5 100644 --- a/lib/views/staging-view.js +++ b/lib/views/staging-view.js @@ -2,270 +2,255 @@ /** @jsx etch.dom */ import etch from 'etch' -import {Disposable} from 'atom' +import {Disposable, CompositeDisposable} from 'atom' -import ListView from './list-view' -import FilePatch from '../models/file-patch' -import MergeConflict from '../models/merge-conflict' import FilePatchListItemView from './file-patch-list-item-view' import MergeConflictListItemView from './merge-conflict-list-item-view' -import MultiListCollection from '../multi-list-collection' +import CompositeListSelection from './composite-list-selection' import {shortenSha} from '../helpers' -export const ListTypes = { - STAGED: Symbol('LIST_STAGED'), - UNSTAGED: Symbol('LIST_UNSTAGED'), - CONFLICTS: Symbol('LIST_CONFLICTS') -} - -const ListNames = { - [ListTypes.STAGED]: 'staged', - [ListTypes.UNSTAGED]: 'unstaged', - [ListTypes.CONFLICTS]: 'conflicts' -} - export default class StagingView { constructor (props) { this.props = props - this.didSelectItem = this.didSelectItem.bind(this) - this.selectItem = this.selectItem.bind(this) - this.stageFilePatch = this.stageFilePatch.bind(this) - this.unstageFilePatch = this.unstageFilePatch.bind(this) - this.enableSelections = this.enableSelections.bind(this) - this.disableSelections = this.disableSelections.bind(this) - this.renderFilePatchListItem = this.renderFilePatchListItem.bind(this) - this.renderMergeConflictListItem = this.renderMergeConflictListItem.bind(this) - this.multiListCollection = new MultiListCollection([ - { key: ListTypes.UNSTAGED, items: this.props.unstagedChanges }, - { key: ListTypes.CONFLICTS, items: this.props.mergeConflicts || [] }, - { key: ListTypes.STAGED, items: this.props.stagedChanges } - ], this.didSelectItem) + this.mouseSelectionInProgress = false + this.listElementsByItem = new WeakMap() + this.registerItemElement = this.registerItemElement.bind(this) + this.mousedownOnItem = this.mousedownOnItem.bind(this) + this.mousemoveOnItem = this.mousemoveOnItem.bind(this) + this.mouseup = this.mouseup.bind(this) + this.selection = new CompositeListSelection({ + listsByKey: { + unstaged: this.props.unstagedChanges, + conflicts: this.props.mergeConflicts || [], + staged: this.props.stagedChanges + }, + idForItem: item => item.getPath() + }) + this.reportSelectedItem() etch.initialize(this) - this.subscriptions = atom.commands.add(this.element, { - 'core:move-up': this.selectPreviousFilePatch.bind(this), - 'core:move-down': this.selectNextFilePatch.bind(this), - 'core:select-up': this.selectPreviousFilePatch.bind(this, {addToExisting: true, stopAtBounds: true}), - 'core:select-down': this.selectNextFilePatch.bind(this, {addToExisting: true, stopAtBounds: true}), - 'core:confirm': this.confirmSelectedItems.bind(this), - 'git:focus-next-list': this.focusNextList.bind(this), - 'git:focus-previous-list': this.focusPreviousList.bind(this), - 'git:focus-diff-view': this.focusFilePatchView.bind(this) - }) - window.addEventListener('mouseup', this.disableSelections) - this.subscriptions.add(new Disposable(() => window.removeEventListener('mouseup', this.disableSelections))) + this.subscriptions = new CompositeDisposable() + this.subscriptions.add(atom.commands.add(this.element, { + 'core:move-up': () => this.selectPrevious(), + 'core:move-down': () => this.selectNext(), + 'core:select-up': () => this.selectPrevious(true), + 'core:select-down': () => this.selectNext(true), + 'core:select-all': () => this.selectAll(), + 'core:move-to-top': () => this.selectFirst(), + 'core:move-to-bottom': () => this.selectLast(), + 'core:select-to-top': () => this.selectFirst(true), + 'core:select-to-bottom': () => this.selectLast(true), + 'core:confirm': () => this.confirmSelectedItems(), + 'git:activate-next-list': () => this.activateNextList(), + 'git:activate-previous-list': () => this.activatePreviousList(), + 'git:focus-diff-view': () => this.focusFilePatchView() + })) + window.addEventListener('mouseup', this.mouseup) + this.subscriptions.add(new Disposable(() => window.removeEventListener('mouseup', this.mouseup))) } update (props) { this.props = props - this.multiListCollection.updateLists([ - { key: ListTypes.UNSTAGED, items: this.props.unstagedChanges }, - { key: ListTypes.CONFLICTS, items: this.props.mergeConflicts || [] }, - { key: ListTypes.STAGED, items: this.props.stagedChanges } - ]) - return etch.update(this) - } - - enableSelections () { - this.validItems = new Set(this.multiListCollection.getItemsForKey(this.getSelectedListKey())) - this.selectionEnabled = true - return etch.update(this) - } - - disableSelections () { - this.tail = null - this.validItems = null - this.selectionEnabled = false - return etch.update(this) - } - - selectList (listKey, {suppressCallback} = {}) { - if (this.multiListCollection.getItemsForKey(listKey).length) { - this.multiListCollection.selectKeys([listKey], {suppressCallback}) - } - this.enableSelections() + this.selection.updateLists({ + unstaged: this.props.unstagedChanges, + conflicts: this.props.mergeConflicts || [], + staged: this.props.stagedChanges + }) return etch.update(this) } - focusNextList () { - this.multiListCollection.selectNextList({wrap: true}) + activateNextList () { + this.selection.activateNextSelection() return etch.update(this) } - focusPreviousList () { - this.multiListCollection.selectPreviousList({wrap: true}) + activatePreviousList () { + this.selection.activatePreviousSelection() return etch.update(this) } confirmSelectedItems () { - this.disableSelections() - const itemPaths = Array.from(this.getSelectedItems()).map(item => item.getPath()) - const listKey = this.getSelectedListKey() - if (listKey === ListTypes.STAGED) { + const itemPaths = Array.from(this.selection.getSelectedItems()).map(item => item.getPath()) + if (this.selection.getActiveListKey() === 'staged') { return this.props.unstageFiles(itemPaths) } else { return this.props.stageFiles(itemPaths) } } - getSelectedListKey () { - return this.multiListCollection.getActiveListKey() - } - - getSelectedItems () { - return this.multiListCollection.getSelectedItems() + selectPrevious (preserveTail = false) { + this.selection.selectPreviousItem(preserveTail) + this.selection.coalesce() + return etch.update(this) } - stageFilePatch (filePatch) { - return this.props.stageFiles([filePatch.getPath()]) + selectNext (preserveTail = false) { + this.selection.selectNextItem(preserveTail) + this.selection.coalesce() + return etch.update(this) } - unstageFilePatch (filePatch) { - return this.props.unstageFiles([filePatch.getPath()]) + selectAll () { + this.selection.selectAllItems() + this.selection.coalesce() + return etch.update(this) } - selectPreviousFilePatch (options = {}) { - this.multiListCollection.selectPreviousItem(options) + selectFirst (preserveTail = false) { + this.selection.selectFirstItem(preserveTail) + this.selection.coalesce() return etch.update(this) } - selectNextFilePatch (options = {}) { - this.multiListCollection.selectNextItem(options) + selectLast (preserveTail = false) { + this.selection.selectLastItem(preserveTail) + this.selection.coalesce() return etch.update(this) } - selectItem (item) { - if (!this.validItems.has(item)) return - const selectedList = this.getSelectedListKey() - if (!this.tail) this.tail = {key: selectedList, item} - this.head = {key: selectedList, item} - this.multiListCollection.selectItemsAndKeysInRange(this.tail, this.head) - return etch.update(this) + writeAfterUpdate () { + this.reportSelectedItem() + const headItem = this.selection.getHeadItem() + if (headItem) this.listElementsByItem.get(headItem).scrollIntoViewIfNeeded() } - didSelectItem (item, listKey, {focus} = {}) { - if (!item || !this.isFocused()) return - if (item.constructor === FilePatch && this.props.didSelectFilePatch) { - const listName = ListNames[listKey] - this.props.didSelectFilePatch(item, listName, {focus}) - } else if (item.constructor === MergeConflict && this.props.didSelectMergeConflictFile) { - this.props.didSelectMergeConflictFile(item.getPath(), {focus}) + reportSelectedItem () { + if (this.selection.getActiveListKey() === 'conflicts') { + if (this.props.didSelectMergeConflictFile) { + this.props.didSelectMergeConflictFile(this.selection.getHeadItem()) + } + } else { + if (this.props.didSelectFilePatch) { + this.props.didSelectFilePatch(this.selection.getHeadItem()) + } } } - focusFilePatchView () { - const item = this.multiListCollection.getActiveItem() - const listKey = this.multiListCollection.getActiveListKey() - this.didSelectItem(item, listKey, {focus: true}) + async mousedownOnItem (event, item) { + if (event.detail >= 2) { + if (this.selection.listKeyForItem(item) === 'staged') { + await this.props.unstageFiles([item.getPath()]) + } else { + await this.props.stageFiles([item.getPath()]) + } + } else { + if (event.ctrlKey || event.metaKey) { + this.selection.addOrSubtractSelection(item) + } else { + this.selection.selectItem(item, event.shiftKey) + } + this.mouseSelectionInProgress = true + } + await etch.update(this) } - buildDebugData () { - const getPath = (fp) => fp ? fp.getNewPath() : '' - const multiListData = this.multiListCollection.toObject() - return { - ...multiListData, - lists: multiListData.lists.map(list => list.map(getPath)) + mousemoveOnItem (event, item) { + if (this.mouseSelectionInProgress) { + this.selection.selectItem(item, true) + return etch.update(this) + } else { + return Promise.resolve() } } - render () { - let stagedClassName = '' - let unstagedClassName = '' - let conflictsClassName = '' - const selectedList = this.getSelectedListKey() - if (selectedList === ListTypes.STAGED) { - stagedClassName = 'is-focused' - } else if (selectedList === ListTypes.UNSTAGED) { - unstagedClassName = 'is-focused' - } else if (selectedList === ListTypes.CONFLICTS) { - conflictsClassName = 'is-focused' - } + mouseup () { + this.mouseSelectionInProgress = false + this.selection.coalesce() + } - const mergeConflictsView = ( -
-
- - Merge Conflicts -
- this.selectList(ListTypes.CONFLICTS, {suppressCallback: true}) } - className='git-StagingView-list git-FilePatchListView' - ref='mergeConflictListView' - didConfirmItem={this.stageFilePatch} - items={this.multiListCollection.getItemsForKey(ListTypes.CONFLICTS)} - selectedItems={this.getSelectedItems()} - renderItem={this.renderMergeConflictListItem} - /> -
- ) + render () { + const selectedItems = this.selection.getSelectedItems() return ( -
this.disableSelections()}> -
+
+
Unstaged Changes
- this.selectList(ListTypes.UNSTAGED, {suppressCallback: true})} - className='git-StagingView-list git-FilePatchListView' - ref='unstagedChangesView' - didConfirmItem={this.stageFilePatch} - items={this.multiListCollection.getItemsForKey(ListTypes.UNSTAGED)} - selectedItems={this.getSelectedItems()} - renderItem={this.renderFilePatchListItem} - /> + +
+ { + this.props.unstagedChanges.map((filePatch) => ( + this.mousedownOnItem(event, filePatch)} + onmousemove={(event) => this.mousemoveOnItem(event, filePatch)} + selected={selectedItems.has(filePatch)} + /> + )) + } +
- { this.multiListCollection.getItemsForKey(ListTypes.CONFLICTS).length ? mergeConflictsView :