diff --git a/lib/common.js b/lib/common.js index 183a406563..2605325fd4 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,35 +1,7 @@ /* @flow */ -interface FromStringable { // eslint-disable-line - fromString(s: string): any; -} - export const DiffURI = 'atom://git/diff/' -export function createObjectsFromString (diffString: string, markerString: string, classToCreate: T): Array { - let objects = [] - let lines = diffString.split('\n') - let objectLines = null - - function createObject (lines) { - if (!lines) return - - let obj = classToCreate.fromString(lines.join('\n')) - objects.push(obj) - } - - for (let line of lines) { - if (line.startsWith(markerString)) { - createObject(objectLines) - objectLines = [] - } - if (objectLines) objectLines.push(line) - } - createObject(objectLines) - - return objects -} - export type ObjectMap = { [key: string]: V } export function ifNotNull (a: ?T, fn: (a: T) => U): ?U { diff --git a/lib/diff-component.js b/lib/diff-component.js index 254a6bd490..db75ce1b07 100644 --- a/lib/diff-component.js +++ b/lib/diff-component.js @@ -40,7 +40,7 @@ export default class DiffComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.diffViewModel.onDidChange(() => etch.update(this))) + this.subscriptions.add(this.diffViewModel.onDidChangeSelection(() => etch.update(this))) this.subscriptions.add(atom.commands.add(this.element, { 'core:move-up': () => this.diffViewModel.moveSelectionUp(), diff --git a/lib/diff-hunk.js b/lib/diff-hunk.js index 37c11750b1..3392f23c4e 100644 --- a/lib/diff-hunk.js +++ b/lib/diff-hunk.js @@ -1,10 +1,7 @@ /* @flow */ -import {Emitter, CompositeDisposable} from 'atom' import HunkLine from './hunk-line' -import EventTransactor from './event-transactor' -import type {Disposable} from 'atom' import type FileDiff from './file-diff' import type {ConvenientHunk} from 'nodegit' @@ -13,53 +10,24 @@ export type StageStatus = 'staged' | 'unstaged' | 'partial' // DiffHunk contains diff information for a single hunk within a single file. It // holds a list of HunkLine objects. export default class DiffHunk { - emitter: Emitter; - transactor: EventTransactor; - lineSubscriptions: CompositeDisposable; - isSyncing: boolean; - lines: Array; header: string; diff: FileDiff; constructor () { - this.emitter = new Emitter() - this.transactor = new EventTransactor(this.emitter, {hunk: this}) this.setLines([]) } - destroy () { - this.lineSubscriptions.dispose() - } - - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) - } - - didChange (event: ?Object) { - this.transactor.didChange(event) - } - getHeader (): string { return this.header } setHeader (header: string) { this.header = header - this.didChange() } getLines (): Array { return this.lines } setLines (lines: Array) { - if (this.lineSubscriptions) { - this.lineSubscriptions.dispose() - } - this.lineSubscriptions = new CompositeDisposable() this.lines = lines - - for (const line of lines) { - this.lineSubscriptions.add(line.onDidChange(this.didChange.bind(this))) - } - this.didChange() } getFirstChangedLine (): ?HunkLine { @@ -70,19 +38,15 @@ export default class DiffHunk { } stage () { - this.transactor.transact(() => { - for (let line of this.lines) { - line.stage() - } - }) + for (let line of this.lines) { + line.stage() + } } unstage () { - this.transactor.transact(() => { - for (let line of this.lines) { - line.unstage() - } - }) + for (let line of this.lines) { + line.unstage() + } } getStageStatus (): StageStatus { @@ -107,64 +71,23 @@ export default class DiffHunk { return 'unstaged' } - isSyncingState (): boolean { - return !!this.isSyncing - } - - syncState (fn: Function) { - this.isSyncing = true - fn() - this.isSyncing = false - } - toString (): string { - const lines = this.lines.map((line) => { return line.toString() }).join('\n') + const lines = this.lines.map(line => line.toString()).join('\n') return `HUNK ${this.getHeader()}\n${lines}` } - fromString (hunkStr: string) { - let linesStr = hunkStr.trim().split('\n') - let metadata = /HUNK (.+)/.exec(linesStr[0]) - if (!metadata) return null - - let [, header] = metadata - let lines = [] - for (let i = 1; i < linesStr.length; i++) { - if (!linesStr[i].trim()) continue - let line = HunkLine.fromString(linesStr[i]) - lines.push(line) - } - - this.syncState(() => { - this.transactor.transact(() => { - this.setHeader(header) - this.setLines(lines) - }) - }) - } - - static fromString (hunkStr): DiffHunk { - let diffHunk = new DiffHunk() - diffHunk.fromString(hunkStr) - return diffHunk - } - - async fromGitUtilsObject ({hunk, isStaged, diff}: {hunk: ConvenientHunk, isStaged: boolean, diff: FileDiff}): Promise { + async fromGitObject ({hunk, isStaged, diff}: {hunk: ConvenientHunk, isStaged: boolean, diff: FileDiff}): Promise { if (!hunk) return let lines = [] for (let line of (await hunk.lines())) { - let hunkLine = HunkLine.fromGitUtilsObject({line, isStaged, hunk: this}) + let hunkLine = HunkLine.fromGitObject({line, isStaged, hunk: this}) lines.push(hunkLine) } this.diff = diff - this.syncState(() => { - this.transactor.transact(() => { - this.setHeader(hunk.header()) - this.setLines(lines) - }) - }) + this.setHeader(hunk.header()) + this.setLines(lines) } } diff --git a/lib/diff-pane-item-component.js b/lib/diff-pane-item-component.js index 3280b03aad..d886b77d30 100644 --- a/lib/diff-pane-item-component.js +++ b/lib/diff-pane-item-component.js @@ -17,14 +17,8 @@ export default class DiffPaneItemComponent { subscriptions: CompositeDisposable; constructor (props: DiffPaneItemComponentProps) { - this.subscriptions = new CompositeDisposable() - this.acceptProps(props) - const onFocus = () => this.refs.diffComponent.focus() - this.element.addEventListener('focus', onFocus) - this.subscriptions.add(new Disposable(() => this.element.removeEventListener('focus', onFocus))) - atom.commands.add(this.element, 'core:copy', () => { // FIXME: I don't love that we have to implement this ourselves. const text = window.getSelection().toString() @@ -48,6 +42,14 @@ export default class DiffPaneItemComponent { etch.initialize(this) } + if (this.subscriptions) this.subscriptions.dispose() + this.subscriptions = new CompositeDisposable() + + const onFocus = () => this.refs.diffComponent.focus() + this.element.addEventListener('focus', onFocus) + this.subscriptions.add(new Disposable(() => this.element.removeEventListener('focus', onFocus))) + this.subscriptions.add(this.diffViewModel.onDidUpdate(() => etch.update(this))) + return updatePromise } diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index b977568d35..52e44da21b 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -1,22 +1,24 @@ /* @flow */ import {Emitter, CompositeDisposable} from 'atom' +import GitService from './git-service' import DiffSelection from './diff-selection' import DiffHunk from './diff-hunk' +import FileDiffViewModel from './file-diff-view-model' import {ifNotNull} from './common' import type {Disposable} from 'atom' -import type FileList from './file-list' import type FileDiff from './file-diff' -import type FileDiffViewModel from './file-diff-view-model' +import type FileListViewModel from './file-list-view-model' import type {SelectionMode} from './diff-selection' +import type HunkLine from './hunk-line' export default class DiffViewModel { + gitService: GitService; uri: string; pathName: string; deserializer: string; fileDiffViewModel: FileDiffViewModel; - fileList: FileList; pending: boolean; selectionMode: SelectionMode; emitter: Emitter; @@ -24,18 +26,22 @@ export default class DiffViewModel { selectionSubscriptions: CompositeDisposable; selectionState: Object; subscriptions: CompositeDisposable; + fileListViewModel: FileListViewModel; - constructor ({uri, pathName, deserializer, fileDiffViewModel, fileList, pending}: {uri: string, pathName: string, deserializer: string, fileDiffViewModel: FileDiffViewModel, fileList: FileList, pending: boolean}) { - this.subscriptions = new CompositeDisposable() + constructor ({uri, pathName, deserializer, pending, gitService, fileListViewModel}: {uri: string, pathName: string, deserializer: string, pending: boolean, gitService: GitService, fileListViewModel: FileListViewModel}) { // TODO: I kind of hate that the URI and deserializer string are in this // model. Worth creating another model on top of this? + this.subscriptions = new CompositeDisposable() + this.gitService = gitService this.uri = uri this.pathName = pathName this.deserializer = deserializer this.pending = pending - this.fileDiffViewModel = fileDiffViewModel - this.fileList = fileList - this.subscriptions.add(this.fileList.onDidChange(this.emitChangeEvent.bind(this))) + this.fileListViewModel = fileListViewModel + + this.update() + + this.subscriptions.add(fileListViewModel.getFileListStore().onDidUpdate(() => this.update())) // mode is stored here _and_ in the selection. Mouse interactions store // line-mode selections, but moving the keyboard interactions into line mode @@ -50,16 +56,31 @@ export default class DiffViewModel { this.selectionSubscriptions.dispose() } - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) + update () { + const fileDiff = this.fileListViewModel.getDiffForPathName(this.pathName) + this.fileDiffViewModel = new FileDiffViewModel(fileDiff) + + if (this.emitter) this.emitUpdatedEvent() } - onDidTerminatePendingState (callback: Function): Disposable { - return this.emitter.on('did-terminate-pending-state', callback) + onDidUpdate (callback: Function): Disposable { + return this.emitter.on('did-update', callback) } - emitChangeEvent () { - this.emitter.emit('did-change') + emitUpdatedEvent () { + this.emitter.emit('did-update') + } + + onDidChangeSelection (callback: Function): Disposable { + return this.emitter.on('did-change-selection', callback) + } + + emitChangedSelectionEvent () { + this.emitter.emit('did-change-selection') + } + + onDidTerminatePendingState (callback: Function): Disposable { + return this.emitter.on('did-terminate-pending-state', callback) } setSelection (selection: DiffSelection) { @@ -235,48 +256,50 @@ export default class DiffViewModel { } } - this.emitChangeEvent() - } - - toggleSelectedLinesStageStatus () { - for (let fileDiffIndex in this.selectionState) { - fileDiffIndex = parseInt(fileDiffIndex, 10) - let fileDiff = this.getFileDiffs()[fileDiffIndex] - let selectedHunkIndices = this.selectionState[fileDiffIndex] - // Transact on the fileDiff because they are shared between FileLists - fileDiff.transact(() => { - for (let diffHunkIndex in selectedHunkIndices) { - diffHunkIndex = parseInt(diffHunkIndex, 10) - let selectedHunk = fileDiff.getHunks()[diffHunkIndex] - let selectedLineIndices = selectedHunkIndices[diffHunkIndex] - if (selectedLineIndices instanceof Set) { - this.toggleLinesStageStatus(selectedHunk, selectedLineIndices) - } else if (selectedLineIndices) { - this.toggleHunkStageStatus(selectedHunk) - } - } - }) - } + this.emitChangedSelectionEvent() } - toggleLinesStageStatus (diffHunk: DiffHunk, lineIndices: Array) { - // This will just toggle each line. It's probably a stupid way to do it - let lines = diffHunk.getLines() - for (let lineIndex of lineIndices.values()) { - lines[lineIndex].setIsStaged(!lines[lineIndex].isStaged()) + getSelectedLines (): Array { + const lines = [] + + const fileDiff = this.getFileDiff() + const selectedHunkIndices = this.selectionState[0] + for (let diffHunkIndex in selectedHunkIndices) { + diffHunkIndex = parseInt(diffHunkIndex, 10) + + const selectedHunk = fileDiff.getHunks()[diffHunkIndex] + const hunkLines = selectedHunk.getLines() + + const selectedLineIndices = selectedHunkIndices[diffHunkIndex] + if (selectedLineIndices instanceof Set) { + for (let lineIndex of selectedLineIndices.values()) { + const line = hunkLines[lineIndex] + lines.push(line) + } + } else if (selectedLineIndices) { + lines.push(...hunkLines) + } } + + return lines } - toggleHunkStageStatus (diffHunk: DiffHunk) { - if (diffHunk.getStageStatus() === 'unstaged') { - diffHunk.stage() - } else { // staged and partial hunks will be fully unstaged - diffHunk.unstage() + async toggleSelectedLinesStageStatus (): Promise { + const lines = this.getSelectedLines() + + // TODO: This is less then ideal. + const stage = !lines[0].isStaged() + for (const line of lines) { + line.setIsStaged(stage) } + + return this.fileListViewModel.stageLines(lines, stage) } + getFileDiff (): FileDiff { return this.fileDiffViewModel.fileDiff } + getFileDiffs (): Array { - return this.fileList.getFiles() + return [this.fileDiffViewModel.fileDiff] } openFileAtSelection () { @@ -288,7 +311,7 @@ export default class DiffViewModel { const [firstPosition] = selection.getRange() const pathName = this.getFileDiffs()[firstPosition[0]].getNewPathName() - const lineOrHunk = this.fileList.getObjectAtPosition(firstPosition) + const lineOrHunk = this.fileListViewModel.getFileListStore().getObjectAtPosition(firstPosition) let lineObject if (lineOrHunk instanceof DiffHunk) { diff --git a/lib/event-transactor.js b/lib/event-transactor.js deleted file mode 100644 index 20f92c3af9..0000000000 --- a/lib/event-transactor.js +++ /dev/null @@ -1,72 +0,0 @@ -/* @flow */ - -import type {Emitter} from 'atom' - -let ID = 0 - -type Transaction = {didChange: boolean, eventName?: string, events: Array} - -// Implements event transactions--roll several events into one! -export default class EventTransactor { - emitter: Emitter; - id: number; - baseEvent: Object; - transactions: Array; - - constructor (emitter: Emitter, baseEvent: Object = {}) { - this.id = ++ID - this.emitter = emitter - this.baseEvent = baseEvent - this.transactions = [] - } - - transact (fn: Function) { - this.transactions.push({ - didChange: false, - events: [] - }) - fn() - const transaction = this.transactions.pop() - if (transaction && transaction.didChange) { - // $FlowFixMe: It's not clear how this works? - this.recordEvent(transaction.eventName, {events: transaction.events}) - } - } - - didChange (event: ?Object) { - this.recordEvent('did-change', event) - } - - recordEvent (eventName: string, event: ?Object) { - // TODO: we could roll up all the events at some point if we need to know - // what changed. - const transaction = this.getLastTransaction() - if (transaction) { - transaction.didChange = true - transaction.eventName = eventName // TODO: not ideal, but for now, everything is `did-change` - // $FlowFixMe: Sure - transaction.events.push(event) - } else { - // $FlowFixMe: Sure - this.emitEvent(eventName, event) - } - } - - emitEvent (eventName: string, event: Object) { - if (event) { - // TODO: the id will make sure to only roll up events from this - // transactor. Maybe not the best solution. - if (!event.events || (event.tid && event.tid !== this)) { - event = {events: [event]} - } - // $FlowFixMe: It's fiiiiine - event.tid = this.id - Object.assign(event, this.baseEvent) - } - this.emitter.emit(eventName, event) - } - - getLastTransaction (): ?Transaction { - return this.transactions[this.transactions.length - 1] - } -} diff --git a/lib/file-diff.js b/lib/file-diff.js index 556ab33a26..03d38b9571 100644 --- a/lib/file-diff.js +++ b/lib/file-diff.js @@ -2,12 +2,9 @@ import path from 'path' import DiffHunk from './diff-hunk' -import EventTransactor from './event-transactor' -import {createObjectsFromString, DiffURI} from './common' -import {Emitter, CompositeDisposable} from 'atom' +import {DiffURI} from './common' import {ifNotNull} from './common' -import type {Disposable} from 'atom' import type {ConvenientPatch, StatusFile} from 'nodegit' import type {StageStatus} from './diff-hunk' @@ -16,10 +13,7 @@ export type ChangeStatus = 'added' | 'deleted' | 'renamed' | 'modified' // FileDiff contains diff information for a single file. It holds a list of // DiffHunk objects. export default class FileDiff { - emitter: Emitter; - transactor: EventTransactor; hunks: Array; - hunkSubscriptions: CompositeDisposable; oldPathName: ?string; newPathName: ?string; size: number; @@ -30,12 +24,8 @@ export default class FileDiff { deleted: boolean; renamed: boolean; untracked: boolean; - isSyncing: boolean; constructor (options: {oldPathName?: string, newPathName?: string, changeStatus?: ChangeStatus} = {}) { - this.emitter = new Emitter() - this.transactor = new EventTransactor(this.emitter, {file: this}) - const {oldPathName, newPathName, changeStatus} = options this.setHunks([]) this.setOldPathName(oldPathName || 'unknown') @@ -43,31 +33,10 @@ export default class FileDiff { this.setChangeStatus(changeStatus || 'modified') } - destroy () { - this.hunkSubscriptions.dispose() - } - - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) - } - - didChange (event: ?Object) { - this.transactor.didChange(event) - } - getHunks (): Array { return this.hunks } setHunks (hunks: Array) { - if (this.hunkSubscriptions) { - this.hunkSubscriptions.dispose() - } - this.hunkSubscriptions = new CompositeDisposable() this.hunks = hunks - - for (const hunk of hunks) { - this.hunkSubscriptions.add(hunk.onDidChange(this.didChange.bind(this))) - } - this.didChange() } getOldFileName (): ?string { @@ -83,7 +52,6 @@ export default class FileDiff { setOldPathName (oldPathName: ?string) { this.oldPathName = oldPathName - this.didChange() } getNewFileName (): ?string { @@ -99,7 +67,6 @@ export default class FileDiff { setNewPathName (newPathName: ?string) { this.newPathName = newPathName - this.didChange() } getSize (): number { return this.size } @@ -141,39 +108,28 @@ export default class FileDiff { this.deleted = false break } - this.didChange() } getInIndex (): boolean { return this.inIndex } stage () { - this.transactor.transact(() => { - if (this.hunks.length > 0) { - for (let hunk of this.hunks) { - hunk.stage() - } - } else { - // In the case of a renamed file or a mode change, there aren't actually - // any changed lines but we do still need to emit a change. - this.inIndex = true - this.emitter.emit('did-change', {property: 'stageStatus', file: this}) + if (this.hunks.length > 0) { + for (let hunk of this.hunks) { + hunk.stage() } - }) + } else { + this.inIndex = true + } } unstage () { - this.transactor.transact(() => { - if (this.hunks.length > 0) { - for (let hunk of this.hunks) { - hunk.unstage() - } - } else { - // In the case of a renamed file or a mode change, there aren't actually - // any changed lines but we do still need to emit a change. - this.inIndex = false - this.emitter.emit('did-change', {property: 'stageStatus', file: this}) + if (this.hunks.length > 0) { + for (let hunk of this.hunks) { + hunk.unstage() } - }) + } else { + this.inIndex = false + } } toggleStageStatus () { @@ -229,62 +185,24 @@ export default class FileDiff { } } - transact (fn: Function) { - this.transactor.transact(fn) - } - - isSyncingState (): boolean { - return !!this.isSyncing - } - - syncState (fn: Function) { - this.isSyncing = true - fn() - this.isSyncing = false - } - toString (): string { - let hunks = this.hunks.map((hunk) => { return hunk.toString() }).join('\n') + let hunks = this.hunks.map(hunk => hunk.toString()).join('\n') return `FILE ${this.getNewPathName()} - ${this.getChangeStatus()} - ${this.getStageStatus()}\n${hunks}` } - fromString (diffStr: string) { - let metadata = /FILE (.+) - (.+) - (.+)/.exec(diffStr.trim().split('\n')[0]) - if (!metadata) return null - - let [, pathName, changeStatus] = metadata - // $FlowBug: This should type check but doesn't. - let hunks: Array = createObjectsFromString(diffStr, 'HUNK', DiffHunk) - - this.syncState(() => { - this.transactor.transact(() => { - this.setNewPathName(pathName) - this.setOldPathName(pathName) - this.setChangeStatus(changeStatus) - this.setHunks(hunks) - }) - }) - } - - static fromString (diffStr) { - let fileDiff = new FileDiff() - fileDiff.fromString(diffStr) - return fileDiff - } - async createHunksFromDiff (diff: ConvenientPatch, isStaged: boolean): Promise> { if (!diff) return [] const hunks = [] for (const hunk of (await diff.hunks())) { let diffHunk = new DiffHunk() - await diffHunk.fromGitUtilsObject({hunk, isStaged, diff: this}) + await diffHunk.fromGitObject({hunk, isStaged, diff: this}) hunks.push(diffHunk) } return hunks } - async fromGitUtilsObject ({diff, stagedDiff, unstagedDiff, statusFile}: {diff: ConvenientPatch, stagedDiff: ConvenientPatch, unstagedDiff: ConvenientPatch, statusFile: StatusFile}): Promise { + async fromGitObject ({diff, stagedDiff, unstagedDiff, statusFile}: {diff: ConvenientPatch, stagedDiff: ConvenientPatch, unstagedDiff: ConvenientPatch, statusFile: StatusFile}): Promise { if (!diff) return const stagedHunks = await this.createHunksFromDiff(stagedDiff, true) @@ -305,18 +223,14 @@ export default class FileDiff { } }) - this.syncState(() => { - this.transactor.transact(() => { - this.size = diff.size() - this.renamed = diff.isRenamed() - this.added = diff.isAdded() - this.untracked = diff.isUntracked() - this.deleted = diff.isDeleted() - this.mode = ifNotNull(diff.newFile(), f => f.mode()) || ifNotNull(diff.oldFile(), f => f.mode()) || 0 - this.setOldPathName(ifNotNull(diff.oldFile(), f => f.path())) - this.setNewPathName(ifNotNull(diff.newFile(), f => f.path())) - this.setHunks(hunks) - }) - }) + this.size = diff.size() + this.renamed = diff.isRenamed() + this.added = diff.isAdded() + this.untracked = diff.isUntracked() + this.deleted = diff.isDeleted() + this.mode = ifNotNull(diff.newFile(), f => f.mode()) || ifNotNull(diff.oldFile(), f => f.mode()) || 0 + this.setOldPathName(ifNotNull(diff.oldFile(), f => f.path())) + this.setNewPathName(ifNotNull(diff.newFile(), f => f.path())) + this.setHunks(hunks) } } diff --git a/lib/file-list-component.js b/lib/file-list-component.js index 5225e59db9..541ad8f48d 100644 --- a/lib/file-list-component.js +++ b/lib/file-list-component.js @@ -8,16 +8,13 @@ import CommitBoxComponent from './commit-box-component' import CommitBoxViewModel from './commit-box-view-model' // $FlowFixMe: Yes, we know this isn't a React component :\ import FileSummaryComponent from './file-summary-component' -import FileDiffViewModel from './file-diff-view-model' import type FileListViewModel from './file-list-view-model' -import type FileList from './file-list' type FileListComponentProps = {fileListViewModel: FileListViewModel} export default class FileListComponent { - fileListViewModel: FileListViewModel; - fileList: FileList; + viewModel: FileListViewModel; element: HTMLElement; commitBoxViewModel: CommitBoxViewModel; subscriptions: CompositeDisposable; @@ -33,8 +30,8 @@ export default class FileListComponent { } acceptProps ({fileListViewModel}: FileListComponentProps): Promise { - this.fileList = fileListViewModel.getFileList() - this.fileListViewModel = fileListViewModel + this.viewModel = fileListViewModel + this.commitBoxViewModel = fileListViewModel.commitBoxViewModel let updatePromise = Promise.resolve() @@ -46,15 +43,15 @@ export default class FileListComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.fileList.onDidChange(() => etch.update(this))) - this.subscriptions.add(this.fileListViewModel.onDidChange(() => this.selectionDidChange())) + this.subscriptions.add(this.viewModel.getFileListStore().onDidUpdate(() => etch.update(this))) + this.subscriptions.add(this.viewModel.onSelectionChanged(() => this.selectionDidChange())) this.subscriptions.add(atom.commands.add(this.element, { - 'core:move-up': () => this.fileListViewModel.moveSelectionUp(), - 'core:move-down': () => this.fileListViewModel.moveSelectionDown(), - 'core:confirm': () => this.fileListViewModel.toggleSelectedFilesStageStatus(), - 'git:open-diff': () => this.fileListViewModel.openSelectedFileDiff(), - 'git:open-file': () => this.fileListViewModel.openFile() + 'core:move-up': () => this.viewModel.moveSelectionUp(), + 'core:move-down': () => this.viewModel.moveSelectionDown(), + 'core:confirm': () => this.viewModel.toggleSelectedFilesStageStatus(), + 'git:open-diff': () => this.viewModel.openSelectedFileDiff(), + 'git:open-file': () => this.viewModel.openFile() })) this.subscriptions.add(atom.commands.add('atom-workspace', { @@ -69,7 +66,7 @@ export default class FileListComponent { } async selectionDidChange (): Promise { - await this.fileListViewModel.openSelectedFileDiff() + await this.viewModel.openSelectedFileDiff() this.focus() return etch.update(this) @@ -80,17 +77,13 @@ export default class FileListComponent { } render () { - const fileDiffViewModels = this.fileList.getFiles().map(fileDiff => { - return new FileDiffViewModel(fileDiff) - }) - return (
Changes
{ - fileDiffViewModels.map((viewModel, index) => + this.viewModel.getFileDiffViewModels().map((viewModel, index) => this.onClickFileSummary(c)} @@ -105,7 +98,7 @@ export default class FileListComponent { onClickFileSummary (component: FileSummaryComponent) { const index = component.getIndex() - this.fileListViewModel.setSelectedIndex(index) + this.viewModel.setSelectedIndex(index) } onDoubleClickFileSummary (component: FileSummaryComponent) { @@ -114,6 +107,7 @@ export default class FileListComponent { onToggleFileSummary (component: FileSummaryComponent) { const index = component.getIndex() - this.fileList.getFiles()[index].toggleStageStatus() + const file = this.viewModel.getFileAtIndex(index) + this.viewModel.toggleFileStageStatus(file) } } diff --git a/lib/file-list-store.js b/lib/file-list-store.js new file mode 100644 index 0000000000..baec533a46 --- /dev/null +++ b/lib/file-list-store.js @@ -0,0 +1,157 @@ +/* @flow */ + +import {Emitter} from 'atom' +import FileDiff from './file-diff' +import GitService from './git-service' + +import type {Disposable} from 'atom' +import type {ObjectMap} from './common' +import type HunkLine from './hunk-line' +import type DiffHunk from './diff-hunk' + +export default class FileListStore { + gitService: GitService; + fileCache: ObjectMap; + files: Array; + emitter: Emitter; + + constructor (gitService: GitService) { + this.emitter = new Emitter() + this.gitService = gitService + this.fileCache = {} + this.setFiles([]) + } + + onDidUpdate (callback: Function): Disposable { + return this.emitter.on('did-update', callback) + } + + emitUpdateEvent () { + this.emitter.emit('did-update') + } + + openFile (file: FileDiff): Promise { + const pathName = file.getNewPathName() + if (pathName) { + return atom.workspace.open(pathName, {pending: true}) + } else { + return Promise.resolve() + } + } + + openFileDiff (file: FileDiff): Promise { + return file.openDiff({pending: true}) + } + + setFiles (files: Array) { + this.files = files + for (const file of files) { + this.addFileToCache(file) + } + } + + getFiles (): Array { + return this.files + } + + // position = [0, 2] will get you the third hunk in the first file. + // position = [0, 2, 1] will get you the 2nd line in the third hunk in the first file. + getObjectAtPosition (position: [number, number, ?number]): HunkLine | DiffHunk { + const [fileIndex, hunkIndex, lineIndex] = position + + const file = this.getFiles()[fileIndex] + const hunk = file.getHunks()[hunkIndex] + if (lineIndex != null) { + return hunk.getLines()[lineIndex] + } else { + return hunk + } + } + + // The file cache should allow all UI elements usage of the same FileDiff + // models. Sometimes it's a bit of a chicken and egg problem, and it happens + // when a tab is deserialized. + // + // * Let's say there is a Diff tab being deserialized for `config.js`. + // * The deserializer runs before nodegit knows the state of things, but the + // tab needs a model. The tab will use `getOrCreateFileFromPathName` to get + // the model. + // * Then and the FileDiff::loadFromGit is called and there are changes + // in `config.js` + // * `loadFromGit` will use + // `getOrCreateFileFromPathName('config.js')`, which will grab the same model + // that the Diff tab is using. + // * The model will be updated from the nodegit state and the Diff tab will + // update properly. + addFileToCache (file: FileDiff) { + const oldPathName = file.getOldPathName() + if (oldPathName !== file.getNewPathName() && oldPathName && this.fileCache[oldPathName]) { + delete this.fileCache[oldPathName] + } + + const newPathName = file.getNewPathName() + if (newPathName) { + this.fileCache[newPathName] = file + } + } + + getFileFromPathName (pathName: string): ?FileDiff { + return this.fileCache[pathName] + } + + getOrCreateFileFromPathName (pathName: string): FileDiff { + let file = this.getFileFromPathName(pathName) + if (!file) { + file = new FileDiff({newPathName: pathName, oldPathName: pathName}) + this.addFileToCache(file) + } + return file + } + + toString (): string { + return this.files.map(file => file.toString()).join('\n') + } + + async loadFromGit (): Promise { + let files = [] + + const statuses = await this.gitService.getStatuses() + + let unifiedDiffs = await this.gitService.getDiffs('all') + // TODO: It's a bummer these lines happen sequentially + const stagedDiffs = await this.gitService.getDiffs('staged') + const unstagedDiffs = await this.gitService.getDiffs('unstaged') + + const stagedDiffsByName = {} + for (const diff of stagedDiffs) { + // TODO: Old path is probably not always right. + // $FlowFixMe + stagedDiffsByName[diff.oldFile().path()] = diff + } + + const unstagedDiffsByName = {} + for (const diff of unstagedDiffs) { + // TODO: Old path is probably not always right. + // $FlowFixMe + unstagedDiffsByName[diff.oldFile().path()] = diff + } + + for (let diff of unifiedDiffs) { + // $FlowFixMe + const statusFile = statuses[diff.oldFile().path()] || statuses[diff.newFile().path()] + + // $FlowFixMe + let fileDiff = this.getOrCreateFileFromPathName(diff.newFile().path()) + // $FlowFixMe + const stagedDiff = stagedDiffsByName[diff.newFile().path()] + // $FlowFixMe + const unstagedDiff = unstagedDiffsByName[diff.oldFile().path()] + await fileDiff.fromGitObject({diff, stagedDiff, unstagedDiff, statusFile}) + files.push(fileDiff) + } + + this.setFiles(files) + + this.emitUpdateEvent() + } +} diff --git a/lib/file-list-view-model.js b/lib/file-list-view-model.js index dfa804ad80..aa1a001b51 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -1,56 +1,56 @@ /* @flow */ import {Emitter, CompositeDisposable} from 'atom' +import GitService from './git-service' +import FileDiffViewModel from './file-diff-view-model' import CommitBoxViewModel from './commit-box-view-model' import type {Disposable} from 'atom' -import type FileList from './file-list' +import type FileListStore from './file-list-store' import type FileDiff from './file-diff' +import type HunkLine from './hunk-line' +import type {ObjectMap} from './common' export default class FileListViewModel { - fileList: FileList; + gitService: GitService; + fileListStore: FileListStore; selectedIndex: number; emitter: Emitter; commitBoxViewModel: CommitBoxViewModel; subscriptions: CompositeDisposable; - constructor (fileList: FileList) { - this.fileList = fileList + constructor (fileListStore: FileListStore, gitService: GitService) { + this.gitService = gitService + this.fileListStore = fileListStore this.selectedIndex = 0 this.subscriptions = new CompositeDisposable() this.emitter = new Emitter() - this.commitBoxViewModel = new CommitBoxViewModel(fileList.gitService) + this.commitBoxViewModel = new CommitBoxViewModel(gitService) this.subscriptions.add(this.commitBoxViewModel.onDidCommit(() => this.emitDidCommitEvent())) - this.subscriptions.add(this.fileList.onDidUserChange(() => this.emitUserChangeEvent())) } destroy () { this.subscriptions.dispose() } - async update (): Promise { - return Promise - .all([ - this.commitBoxViewModel.update(), - this.fileList.loadFromGitUtils() - ]) - .then(() => { return }) + update (): Promise { + return this.commitBoxViewModel.update() } - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) + onDidStage (callback: Function): Disposable { + return this.emitter.on('did-stage', callback) } - emitChangeEvent () { - this.emitter.emit('did-change') + emitStagedEvent () { + this.emitter.emit('did-stage') } - onDidUserChange (callback: Function): Disposable { - return this.emitter.on('did-user-change', callback) + onSelectionChanged (callback: Function): Disposable { + return this.emitter.on('selection-changed', callback) } - emitUserChangeEvent () { - this.emitter.emit('did-user-change') + emitSelectionChangedEvent () { + this.emitter.emit('selection-changed') } onDidCommit (callback: Function): Disposable { @@ -61,8 +61,8 @@ export default class FileListViewModel { this.emitter.emit('did-commit') } - getFileList (): FileList { - return this.fileList + getFileListStore (): FileListStore { + return this.fileListStore } getSelectedIndex (): number { @@ -71,23 +71,27 @@ export default class FileListViewModel { setSelectedIndex (index: number) { this.selectedIndex = index - this.emitChangeEvent() + this.emitSelectionChangedEvent() } getSelectedFile (): FileDiff { - return this.fileList.getFiles()[this.selectedIndex] + return this.getFileAtIndex(this.selectedIndex) } openSelectedFileDiff (): Promise { - return this.fileList.openFileDiff(this.getSelectedFile()) + return this.fileListStore.openFileDiff(this.getSelectedFile()) + } + + getFileAtIndex (index: number): FileDiff { + return this.fileListStore.getFiles()[index] } getDiffForPathName (name: string): FileDiff { - return this.getFileList().getOrCreateFileFromPathName(name) + return this.getFileListStore().getOrCreateFileFromPathName(name) } openFile (): Promise { - return this.fileList.openFile(this.getSelectedFile()) + return this.fileListStore.openFile(this.getSelectedFile()) } moveSelectionUp () { @@ -95,12 +99,65 @@ export default class FileListViewModel { } moveSelectionDown () { - const filesLengthIndex = this.fileList.getFiles().length - 1 + const filesLengthIndex = this.fileListStore.getFiles().length - 1 this.setSelectedIndex(Math.min(this.selectedIndex + 1, filesLengthIndex)) } - toggleSelectedFilesStageStatus () { - const file = this.getSelectedFile() + toggleFileStageStatus (file: FileDiff): Promise { + let stagePromise = null + if (file.getStageStatus() === 'unstaged') { + stagePromise = this.gitService.stageFile(file) + } else { + stagePromise = this.gitService.unstageFile(file) + } + // TODO: Handle errors. + file.toggleStageStatus() + + this.emitStagedEvent() + return stagePromise.then(_ => { return }) + } + + toggleSelectedFilesStageStatus (): Promise { + const file = this.getSelectedFile() + return this.toggleFileStageStatus(file) + } + + async stageLines (lines: Array, stage: boolean): Promise { + if (lines.length === 0) return Promise.resolve() + + const groupedLines = this.groupedLinesByHunk(lines) + for (const line of lines) { + line.setIsStaged(stage) + } + + const patches = await this.gitService.calculatePatchTexts(groupedLines, stage) + const fileDiff = lines[0].hunk.diff + if (stage) { + await this.gitService.stagePatches(fileDiff, patches) + } else { + await this.gitService.unstagePatches(fileDiff, patches) + } + + // TODO: handle errors + + this.emitStagedEvent() + } + + groupedLinesByHunk (lines: Array): ObjectMap> { + return lines.reduce((linesByHunk, line) => { + let lines = linesByHunk[line.hunk.toString()] + if (!lines) { + lines = [] + linesByHunk[line.hunk.toString()] = lines + } + + lines.push(line) + return linesByHunk + }, {}) + } + + getFileDiffViewModels (): Array { + return this.fileListStore.getFiles().map(fileDiff => new FileDiffViewModel(fileDiff)) } } diff --git a/lib/file-list.js b/lib/file-list.js deleted file mode 100644 index 85162524bf..0000000000 --- a/lib/file-list.js +++ /dev/null @@ -1,298 +0,0 @@ -/* @flow */ - -import {CompositeDisposable, Emitter} from 'atom' -import FileDiff from './file-diff' -import GitService from './git-service' -import EventTransactor from './event-transactor' - -import type {ObjectMap} from './common' -import type {Disposable} from 'atom' -import type HunkLine from './hunk-line' -import type DiffHunk from './diff-hunk' - -// FileList contains a collection of FileDiff objects -export default class FileList { - gitService: GitService; - emitter: Emitter; - transactor: EventTransactor; - fileCache: ObjectMap; - files: Array; - fileSubscriptions: CompositeDisposable; - isSyncing: boolean; - subscriptions: CompositeDisposable; - - constructor (files: Array, gitService: GitService, {stageOnChange}: {stageOnChange?: boolean} = {}) { - this.subscriptions = new CompositeDisposable() - // TODO: Rather than this `stageOnChange` bool, there should probably be a - // new object that just handles the connection to nodegit. Cause there are - // several of these objects in the system, but only one of them handles - // writing to the index. - this.gitService = gitService - this.emitter = new Emitter() - this.transactor = new EventTransactor(this.emitter, {fileList: this}) - this.fileCache = {} - this.setFiles(files || []) - if (stageOnChange) { - this.subscriptions.add(this.onDidChange(this.handleDidChange.bind(this))) - } - } - - destroy () { - this.subscriptions.dispose() - this.fileSubscriptions.dispose() - } - - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) - } - - didChange (event: ?Object) { - this.transactor.didChange(event) - } - - onDidUserChange (callback: Function): Disposable { - return this.emitter.on('did-user-change', callback) - } - - didUserChange () { - this.emitter.emit('did-user-change') - } - - async handleDidChange (event: Object): Promise { - if (!(event && event.events && event.fileList === this)) return - if (this.isSyncingState()) return - - const extractedLines = this.extractLinesFromEventByHunk(event) - if (extractedLines) { - const {stagableLinesByHunk, fileDiff, stage} = extractedLines - await this.stageLines(fileDiff, stagableLinesByHunk, stage) - this.didUserChange() - return - } - - const rootEvent = event.events[0] - if (rootEvent && rootEvent.property === 'stageStatus' && rootEvent.file) { - const file: FileDiff = rootEvent.file - // In both cases we end up here (renamed, mode change), we know we have a - // new file name. - if (file.inIndex) { - // $FlowSilence - await this.gitService.stagePath(file.getNewFileName()) - } else { - // $FlowSilence - await this.gitService.unstagePath(file.getNewFileName()) - } - - this.didUserChange() - return - } - } - - async stageLines (fileDiff: FileDiff, stagableLinesPerHunk: ObjectMap<{linesToStage: Array, linesToUnstage: Array}>, stage: boolean): Promise { - const patches = await this.gitService.calculatePatchTexts(stagableLinesPerHunk, stage) - await this.stagePatches(fileDiff, patches, stage) - } - - extractLinesFromEventByHunk (event: Object): ?{stagableLinesByHunk: ObjectMap<{linesToStage: Array, linesToUnstage: Array}>, fileDiff: FileDiff, stage: boolean} { - let stagableLinesByHunk = {} - let fileDiff = null - let stage = true - for (let fileEvent of event.events) { - if (!fileEvent) continue - - fileDiff = fileEvent.file - - const fileEvents = fileEvent.events - if (!fileEvents) { - return null - } - - for (let hunkEvent of fileEvents) { - if (!hunkEvent) continue - - const hunk = hunkEvent.hunk - if (!stagableLinesByHunk[hunk]) { - stagableLinesByHunk[hunk] = { - linesToStage: [], - linesToUnstage: [] - } - } - const stagableLines = stagableLinesByHunk[hunk] - for (let lineEvent of hunkEvent.events) { - if (lineEvent.line && lineEvent.property === 'isStaged') { - if (lineEvent.line.isStaged()) { - stagableLines.linesToStage.push(lineEvent.line) - stage = true - } else { - stagableLines.linesToUnstage.push(lineEvent.line) - stage = false - } - } - - if (stagableLines.linesToStage.length > 0 && stagableLines.linesToUnstage.length > 0) { - throw new Error("Can't toggle stagedness on a selection that has mixed stage (yet) :(") - } - } - } - } - - // $FlowSilence: We know `fileDiff` won't be null by the time we get here. - return {stagableLinesByHunk, fileDiff, stage} - } - - openFile (file: FileDiff): Promise { - const pathName = file.getNewPathName() - if (pathName) { - return atom.workspace.open(pathName, {pending: true}) - } else { - return Promise.resolve() - } - } - - openFileDiff (file: FileDiff): Promise { - return file.openDiff({pending: true}) - } - - setFiles (files: Array) { - if (this.fileSubscriptions) { - this.fileSubscriptions.dispose() - } - this.fileSubscriptions = new CompositeDisposable() - this.files = files - for (const file of files) { - this.fileSubscriptions.add(file.onDidChange(this.didChange.bind(this))) - this.addFileToCache(file) - } - this.didChange() - } - - getFiles (): Array { - return this.files - } - - // position = [0, 2] will get you the third hunk in the first file. - // position = [0, 2, 1] will get you the 2nd line in the third hunk in the first file. - getObjectAtPosition (position: [number, number, ?number]): HunkLine | DiffHunk { - const [fileIndex, hunkIndex, lineIndex] = position - - const file = this.getFiles()[fileIndex] - const hunk = file.getHunks()[hunkIndex] - if (lineIndex != null) { - return hunk.getLines()[lineIndex] - } else { - return hunk - } - } - - // The file cache should allow all UI elements usage of the same FileDiff - // models. Sometimes it's a bit of a chicken and egg problem, and it happens - // when a tab is deserialized. - // - // * Let's say there is a Diff tab being deserialized for `config.js`. - // * The deserializer runs before nodegit knows the state of things, but the - // tab needs a model. The tab will use `getOrCreateFileFromPathName` to get - // the model. - // * Then and the FileDiff::loadFromGitUtils is called and there are changes - // in `config.js` - // * `loadFromGitUtils` will use - // `getOrCreateFileFromPathName('config.js')`, which will grab the same model - // that the Diff tab is using. - // * The model will be updated from the nodegit state and the Diff tab will - // update properly. - addFileToCache (file: FileDiff) { - const oldPathName = file.getOldPathName() - if (oldPathName !== file.getNewPathName() && oldPathName && this.fileCache[oldPathName]) { - delete this.fileCache[oldPathName] - } - - const newPathName = file.getNewPathName() - if (newPathName) { - this.fileCache[newPathName] = file - } - } - - getFileFromPathName (pathName: string): ?FileDiff { - return this.fileCache[pathName] - } - - getOrCreateFileFromPathName (pathName: string): FileDiff { - let file = this.getFileFromPathName(pathName) - if (!file) { - file = new FileDiff({newPathName: pathName, oldPathName: pathName}) - this.addFileToCache(file) - } - return file - } - - isSyncingState (): boolean { - if (this.isSyncing) { - return true - } - - for (let file of this.files) { - if (file.isSyncingState()) { - return true - } - } - - return false - } - - toString (): string { - return this.files.map(file => file.toString()).join('\n') - } - - async stagePatches (fileDiff: FileDiff, patches: Array, stage: boolean): Promise { - if (stage) { - return this.gitService.stagePatches(fileDiff, patches) - } else { - return this.gitService.unstagePatches(fileDiff, patches) - } - } - - async loadFromGitUtils (): Promise { - let files = [] - this.isSyncing = true - - // FIXME: for now, we need to get the stati for the diff stuff to work. :/ - await this.gitService.getStatuses() - - let unifiedDiffs = await this.gitService.getDiffs('all') - // TODO: It's a bummer these lines happen sequentially - const stagedDiffs = await this.gitService.getDiffs('staged') - const unstagedDiffs = await this.gitService.getDiffs('unstaged') - - const stagedDiffsByName = {} - for (const diff of stagedDiffs) { - // TODO: Old path is probably not always right. - // $FlowFixMe - stagedDiffsByName[diff.oldFile().path()] = diff - } - - const unstagedDiffsByName = {} - for (const diff of unstagedDiffs) { - // TODO: Old path is probably not always right. - // $FlowFixMe - unstagedDiffsByName[diff.oldFile().path()] = diff - } - - for (let diff of unifiedDiffs) { - // $FlowFixMe - const statusFile = this.gitService.getStatus(diff.oldFile().path()) || this.gitService.getStatus(diff.newFile().path()) - - // $FlowFixMe - let fileDiff = this.getOrCreateFileFromPathName(diff.newFile().path()) - // $FlowFixMe - const stagedDiff = stagedDiffsByName[diff.newFile().path()] - // $FlowFixMe - const unstagedDiff = unstagedDiffsByName[diff.oldFile().path()] - await fileDiff.fromGitUtilsObject({diff, stagedDiff, unstagedDiff, statusFile}) - files.push(fileDiff) - } - - this.transactor.transact(() => { - this.setFiles(files) - }) - this.isSyncing = false - } -} diff --git a/lib/git-package.js b/lib/git-package.js index 0989399e3a..3f61507246 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -1,11 +1,10 @@ /* @flow */ import {CompositeDisposable} from 'atom' -import FileList from './file-list' +import FileListStore from './file-list-store' import FileListViewModel from './file-list-view-model' import DiffViewModel from './diff-view-model' import StatusBarViewModel from './status-bar-view-model' -import FileDiffViewModel from './file-diff-view-model' import GitService from './git-service' import {DiffURI} from './common' @@ -41,9 +40,7 @@ export default class GitPackage { this.openDiffForActiveEditor() }) - atom.commands.add('atom-workspace', 'git:refresh-status', () => { - this.update() - }) + atom.commands.add('atom-workspace', 'git:refresh-status', () => this.update()) this.update() @@ -67,17 +64,18 @@ export default class GitPackage { return atom.project.getRepositories().length > 0 && atom.project.getRepositories()[0] } - update (): Promise { + async update (): Promise { const promises = [] promises.push(this.getFileListViewModel().update()) + promises.push(this.getFileListStore().loadFromGit()) const statusBarTile = this.statusBarTile if (statusBarTile) { promises.push(statusBarTile.getItem().update()) } - return Promise.all(promises).then(() => { return }) + await Promise.all(promises) } serialize (): GitState { @@ -94,15 +92,19 @@ export default class GitPackage { getFileListViewModel (): FileListViewModel { if (!this.fileListViewModel) { - const fileList = new FileList([], this.getGitService(), {stageOnChange: true}) - this.fileListViewModel = new FileListViewModel(fileList) - this.subscriptions.add(this.fileListViewModel.onDidUserChange(() => this.update())) + const fileListStore = new FileListStore(this.getGitService()) + this.fileListViewModel = new FileListViewModel(fileListStore, this.getGitService()) this.subscriptions.add(this.fileListViewModel.onDidCommit(() => this.onDidCommit())) + this.subscriptions.add(this.fileListViewModel.onDidStage(() => this.update())) } return this.fileListViewModel } + getFileListStore (): FileListStore { + return this.getFileListViewModel().getFileListStore() + } + getDiffPaneItems (): Array<{pane: Pane, item: DiffViewModel}> { const panes = atom.workspace.getPanes() const itemsToClose = [] @@ -199,15 +201,15 @@ export default class GitPackage { createDiffPaneItem ({uri, pending}: {uri: string, pending: boolean}): DiffViewModel { const pathName = uri.replace(DiffURI, '') - const fileDiff = this.getFileListViewModel().getDiffForPathName(pathName) - const fileDiffViewModel = new FileDiffViewModel(fileDiff) + const gitService = this.getGitService() + const fileListViewModel = this.getFileListViewModel() return new DiffViewModel({ + gitService, + fileListViewModel, uri, pathName, - fileDiffViewModel, - pending: !!pending, - deserializer: 'GitDiffPaneItem', - fileList: new FileList([fileDiff], this.getGitService(), {stageOnChange: false}) + pending, + deserializer: 'GitDiffPaneItem' }) } } diff --git a/lib/git-service.js b/lib/git-service.js index c143a46ed2..a7b144f26b 100644 --- a/lib/git-service.js +++ b/lib/git-service.js @@ -22,7 +22,6 @@ type Diffs = { export default class GitService { repoPath: string; diffsPromise: Promise; - statuses: ObjectMap; gitRepo: GitRepositoryAsync; subscriptions: CompositeDisposable; emitter: Emitter; @@ -30,7 +29,6 @@ export default class GitService { constructor (gitRepo: GitRepositoryAsync) { this.subscriptions = new CompositeDisposable() this.emitter = new Emitter() - this.statuses = {} this.gitRepo = gitRepo this.repoPath = gitRepo.openedPath @@ -160,81 +158,68 @@ export default class GitService { .open(this.repoPath) .then(repo => repo.getStatusExt(opts)) .then(statuses => { - this.statuses = {} + const statusesByPath = {} for (let status of statuses) { - this.statuses[status.path()] = status + statusesByPath[status.path()] = status } - return this.statuses + return statusesByPath }) } - getStatus (path: string): StatusFile { - return this.statuses[path] - } - - stagePath (path: string): Promise { - return this.stageAllPaths([path]) - } - - stageAllPaths (paths: Array): Promise { + stageFile (file: FileDiff): Promise { return Git.Repository .open(this.repoPath) .then(repo => repo.openIndex()) .then(index => { - for (let path of paths) { - const status = this.statuses[path] - - if (status.isDeleted()) { - index.removeByPath(path) - } else if (status.isRenamed()) { - index.removeByPath(status.indexToWorkdir().oldFile().path()) - index.addByPath(path) - } else { - index.addByPath(path) - } + if (file.isDeleted()) { + // $FlowSilence + index.removeByPath(file.getOldFileName()) + } else if (file.isRenamed()) { + // $FlowSilence + index.removeByPath(file.getOldFileName()) + // $FlowSilence + index.addByPath(file.getNewFileName()) + } else { + // $FlowSilence + index.addByPath(file.getNewFileName()) } return index.write() }) } - unstagePath (path: string): Promise { - return this.unstageAllPaths([path]) - } - - unstageAllPaths (paths: Array): Promise { - const data = {} - - return Git.Repository.open(this.repoPath).then(repo => { - data.repo = repo - - if (repo.isEmpty()) { - return repo.openIndex().then(index => { - for (let path of paths) { - index.removeByPath(path) - } - - return index.write() - }) - } else { - return repo.getHeadCommit().then(commit => { - const promises = [] - for (let path of paths) { - const status = this.statuses[path] - if (status.isRenamed()) { - const oldFilePath = status.headToIndex().oldFile().path() - promises.push(Git.Reset.default(data.repo, commit, oldFilePath)) - promises.push(Git.Reset.default(data.repo, commit, path)) - } else { - promises.push(Git.Reset.default(data.repo, commit, path)) - } - } - - return Promise.all(promises).then(() => 0) - }) - } - }) + unstageFile (file: FileDiff): Promise { + return Git.Repository + .open(this.repoPath) + .then(repo => { + if (repo.isEmpty()) { + return repo + .openIndex() + .then(index => { + // $FlowSilence + index.removeByPath(file.getNewFileName()) + return index.write() + }) + } else { + return repo + .getHeadCommit() + .then(commit => { + const promises = [] + if (file.isRenamed()) { + // $FlowSilence + promises.push(Git.Reset.default(repo, commit, file.getOldFileName())) + // $FlowSilence + promises.push(Git.Reset.default(repo, commit, file.getNewFileName())) + } else { + // $FlowSilence + promises.push(Git.Reset.default(repo, commit, file.getNewFileName())) + } + + return Promise.all(promises).then(() => 0) + }) + } + }) } wordwrap (str: ?string): ?string { @@ -290,16 +275,14 @@ export default class GitService { } } - calculatePatchTexts (selectedLinesByHunk: ObjectMap<{linesToStage: Array, linesToUnstage: Array}>, stage: boolean): Promise> { + calculatePatchTexts (selectedLinesByHunk: ObjectMap>, stage: boolean): Promise> { let offset = 0 const patches = [] for (let hunkString in selectedLinesByHunk) { - const {linesToStage, linesToUnstage} = selectedLinesByHunk[hunkString] - - const linesToUse = (linesToStage.length > 0 ? linesToStage : linesToUnstage) - const hunk = linesToUse[0].hunk - const result = this._calculatePatchText(hunk, linesToUse, offset, stage) + const lines = selectedLinesByHunk[hunkString] + const hunk = lines[0].hunk + const result = this._calculatePatchText(hunk, lines, offset, stage) offset += result.offset patches.push(result.patchText) } @@ -442,8 +425,7 @@ export default class GitService { return data.index.write() }) .catch(error => { - console.log(error.message) - return console.log(error.stack) + console.error(error) }) } @@ -481,8 +463,7 @@ export default class GitService { if (!newContent && fileDiff.isAdded()) { // If the file's been added then we know it has a new path. - // $FlowSilence - return this.unstagePath(newPath) + return this.unstageFile(fileDiff) } else { const buffer = new Buffer(newContent) const oid = data.repo.createBlobFromBuffer(buffer) diff --git a/lib/hunk-line-component.js b/lib/hunk-line-component.js index 5b0eedff96..e0f3d9ff12 100644 --- a/lib/hunk-line-component.js +++ b/lib/hunk-line-component.js @@ -41,7 +41,6 @@ export default class HunkLineComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.hunkLine.onDidChange(() => etch.update(this))) let updatePromise = Promise.resolve() if (this.element) { diff --git a/lib/hunk-line.js b/lib/hunk-line.js index c4e292538f..263fb73427 100644 --- a/lib/hunk-line.js +++ b/lib/hunk-line.js @@ -1,8 +1,5 @@ /* @flow */ -import {Emitter} from 'atom' - -import type {Disposable} from 'atom' import type DiffHunk from './diff-hunk' export default class HunkLine { @@ -11,13 +8,9 @@ export default class HunkLine { oldLineNumber: ?number; newLineNumber: ?number; staged: boolean; - emitter: Emitter; - isSyncing: boolean; hunk: DiffHunk; constructor (options: {content?: string, lineOrigin?: string, staged?: boolean, oldLineNumber?: number, newLineNumber?: number} = {}) { - this.emitter = new Emitter() - let {content, lineOrigin, staged, oldLineNumber, newLineNumber} = options this.staged = staged || false this.content = content || '' @@ -26,10 +19,6 @@ export default class HunkLine { this.newLineNumber = newLineNumber } - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) - } - getContent (): string { return this.content } getLineOrigin (): string { return this.lineOrigin } @@ -43,16 +32,7 @@ export default class HunkLine { unstage () { this.setIsStaged(false) } setIsStaged (isStaged: boolean) { - if (this.isStagable()) { - const previousValue = this.staged - const newValue = isStaged - if (previousValue !== newValue) { - this.staged = isStaged - this.emitChangeEvent({property: 'isStaged', line: this}) - } - } else { - return Promise.resolve() - } + this.staged = isStaged } isStagable (): boolean { return this.isChanged() } @@ -73,20 +53,6 @@ export default class HunkLine { isContextNewlineAtEOF (): boolean { return this.lineOrigin === '=' } - emitChangeEvent (event: ?Object) { - this.emitter.emit('did-change', event) - } - - isSyncingState (): boolean { - return !!this.isSyncing - } - - syncState (fn: Function) { - this.isSyncing = true - fn() - this.isSyncing = false - } - toString (): string { const oldLine = this.getOldLineNumber() || '---' const newLine = this.getNewLineNumber() || '---' @@ -94,31 +60,7 @@ export default class HunkLine { return `${staged} ${oldLine} ${newLine} ${this.getLineOrigin() || ' '} ${this.getContent()}` } - fromString (str: string) { - let lineMatch = /([ ✓]) ([\d]+|---) ([\d]+|---)(?: ([ +-]) (.+))?$/.exec(str) - if (!lineMatch) return null - - let [, staged, oldLine, newLine, lineOrigin, content] = lineMatch - - this.syncState(() => { - this.content = content || '' - this.lineOrigin = lineOrigin - this.setIsStaged(staged === '✓') - - this.oldLineNumber = oldLine === '---' ? null : parseInt(oldLine, 10) - this.newLineNumber = newLine === '---' ? null : parseInt(newLine, 10) - - this.emitChangeEvent() - }) - } - - static fromString (str: string): HunkLine { - const hunkLine = new HunkLine() - hunkLine.fromString(str) - return hunkLine - } - - fromGitUtilsObject ({line, isStaged, hunk}: {line: Object, isStaged: boolean, hunk: DiffHunk}) { + fromGitObject ({line, isStaged, hunk}: {line: Object, isStaged: boolean, hunk: DiffHunk}) { if (!line) return this.hunk = hunk @@ -136,15 +78,11 @@ export default class HunkLine { } else { this.staged = false } - - this.syncState(() => { - this.emitChangeEvent() - }) } - static fromGitUtilsObject (obj): HunkLine { + static fromGitObject (obj): HunkLine { const hunkLine = new HunkLine() - hunkLine.fromGitUtilsObject(obj) + hunkLine.fromGitObject(obj) return hunkLine } } diff --git a/script/cibuild b/script/cibuild index f94e451d17..265d4ee034 100755 --- a/script/cibuild +++ b/script/cibuild @@ -54,6 +54,7 @@ echo "Downloading package dependencies..." "$APM_SCRIPT_PATH" rebuild-module-cache "$APM_SCRIPT_PATH" clean "$APM_SCRIPT_PATH" install +"$APM_SCRIPT_PATH" install TEST_PACKAGES="${APM_TEST_PACKAGES:=none}" diff --git a/spec/commit-box-view-model-spec.js b/spec/commit-box-view-model-spec.js index 5906cced87..2fc0e18325 100644 --- a/spec/commit-box-view-model-spec.js +++ b/spec/commit-box-view-model-spec.js @@ -10,6 +10,16 @@ import {SummaryPreferredLength} from '../lib/commit-box-view-model' import {copyRepository} from './helpers' import {waitsForPromise, it} from './async-spec-helpers' +function stageFile (repoPath, filePath) { + return GitRepositoryAsync.Git.Repository + .open(repoPath) + .then(repo => repo.openIndex()) + .then(index => { + index.addByPath(filePath) + return index.write() + }) +} + describe('CommitBoxViewModel', () => { let viewModel let gitService @@ -48,7 +58,7 @@ describe('CommitBoxViewModel', () => { let statuses = await gitService.getStatuses() expect(statuses[newFileName]).not.toBeUndefined() - await gitService.stagePath(newFileName) + await stageFile(repoPath, newFileName) await viewModel.commit('hey there') statuses = await gitService.getStatuses() diff --git a/spec/diff-component-spec.js b/spec/diff-component-spec.js index 68d4eed314..218a3ab1b1 100644 --- a/spec/diff-component-spec.js +++ b/spec/diff-component-spec.js @@ -1,19 +1,11 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import DiffViewModel from '../lib/diff-view-model' import DiffComponent from '../lib/diff-component' -import FileList from '../lib/file-list' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, buildMouseEvent, copyRepository} from './helpers' - -function createDiffs (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - return new DiffViewModel({fileList: new FileList(fileDiffs, gitService)}) -} +import {beforeEach} from './async-spec-helpers' +import {createDiffViewModel, buildMouseEvent} from './helpers' describe('DiffComponent', function () { - let viewModel, component, element, gitService + let viewModel, component, element function getLineNumberAtPosition (position) { let [fileIndex, hunkIndex, lineIndex] = position @@ -23,18 +15,15 @@ describe('DiffComponent', function () { return lineElement.querySelector('.old-line-number') } - beforeEach(function () { - const repoPath = copyRepository() - - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createDiffViewModel('src/config.coffee', 'dummy-atom') component = new DiffComponent({diffViewModel: viewModel}) element = component.element jasmine.attachToDOM(component.element) }) it('renders correctly', function () { - expect(element.querySelectorAll('.git-file-diff')).toHaveLength(2) + expect(element.querySelectorAll('.git-file-diff')).toHaveLength(1) expect(element.querySelector('.git-diff-hunk')).toBeDefined() expect(element.querySelector('.git-hunk-line')).toBeDefined() }) diff --git a/spec/diff-hunk-spec.js b/spec/diff-hunk-spec.js index 0ff4429e74..a0a1f59262 100644 --- a/spec/diff-hunk-spec.js +++ b/spec/diff-hunk-spec.js @@ -1,25 +1,13 @@ /** @babel */ -import DiffHunk from '../lib/diff-hunk' +import {beforeEach} from './async-spec-helpers' +import {createDiffViewModel} from './helpers' describe('DiffHunk', function () { let diffHunk - beforeEach(function () { - diffHunk = DiffHunk.fromString(HunkStr) - }) - - it('roundtrips toString and fromString', function () { - expect(diffHunk.toString()).toEqual(HunkStr) - }) - - it('emits an event when created from a string on an empty object', function () { - let changeHandler = jasmine.createSpy() - diffHunk = new DiffHunk() - diffHunk.onDidChange(changeHandler) - - diffHunk.fromString(HunkStr) - expect(changeHandler).toHaveBeenCalled() - expect(diffHunk.toString()).toEqual(HunkStr) + beforeEach(async () => { + const viewModel = await createDiffViewModel('src/config.coffee', 'dummy-atom') + diffHunk = viewModel.getFileDiff().getHunks()[0] }) it('stages all lines with ::stage() and unstages all lines with ::unstage()', function () { @@ -47,51 +35,4 @@ describe('DiffHunk', function () { diffHunk.getLines()[3].unstage() expect(diffHunk.getStageStatus()).toBe('unstaged') }) - - it('emits one change event when the hunk is staged', function () { - let changeHandler = jasmine.createSpy() - diffHunk.onDidChange(changeHandler) - - diffHunk.stage() - expect(changeHandler.callCount).toBe(1) - let args = changeHandler.mostRecentCall.args - expect(args[0].hunk).toBe(diffHunk) - expect(args[0].events).toHaveLength(3) - expect(args[0].events[0].line).toBe(diffHunk.getLines()[3]) - }) - - it('emits a change event when a line is staged', function () { - let changeHandler = jasmine.createSpy() - diffHunk.onDidChange(changeHandler) - - diffHunk.getLines()[3].stage() - expect(changeHandler).toHaveBeenCalled() - let args = changeHandler.mostRecentCall.args - expect(args[0].hunk).toBe(diffHunk) - expect(args[0].events).toHaveLength(1) - expect(args[0].events[0].line).toBe(diffHunk.getLines()[3]) - }) - - it('emits events when the header and lines change', function () { - let changeHandler = jasmine.createSpy() - diffHunk.onDidChange(changeHandler) - - diffHunk.setHeader('@@ -85,9 +85,6 @@ someline ok yeah') - expect(changeHandler).toHaveBeenCalled() - - changeHandler.reset() - diffHunk.setLines([]) - expect(changeHandler).toHaveBeenCalled() - }) }) - -const HunkStr = `HUNK @@ -85,9 +85,6 @@ ScopeDescriptor = require './scope-descriptor' - 85 85 # - 86 86 # ## Config Schemas - 87 87 # - 88 --- - # We use [json schema](http://json-schema.org) which allows you to define your value's - 89 --- - # default, the type it should be, etc. A simple example: - 90 --- - # - 91 88 # \`\`\`coffee - 92 89 # # We want to provide an \`enableThing\`, and a \`thingVolume\` - 93 90 # config:` diff --git a/spec/diff-selection-spec.js b/spec/diff-selection-spec.js index 51fe209313..5f0458b52f 100644 --- a/spec/diff-selection-spec.js +++ b/spec/diff-selection-spec.js @@ -1,25 +1,14 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' import DiffSelection from '../lib/diff-selection' -import DiffViewModel from '../lib/diff-view-model' -import FileList from '../lib/file-list' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' - -function createDiffs (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - return new DiffViewModel({fileList: new FileList(fileDiffs, gitService)}) -} +import {beforeEach} from './async-spec-helpers' +import {createDiffViewModel} from './helpers' describe('DiffSelection', function () { let viewModel - let gitService - beforeEach(function () { - const repoPath = copyRepository() - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createDiffViewModel() }) it('can be created without options', function () { diff --git a/spec/diff-view-model-spec.js b/spec/diff-view-model-spec.js index 0b9f2ba84f..006f7a8bbf 100644 --- a/spec/diff-view-model-spec.js +++ b/spec/diff-view-model-spec.js @@ -1,17 +1,14 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import DiffViewModel from '../lib/diff-view-model' +import path from 'path' +import fs from 'fs-plus' import DiffSelection from '../lib/diff-selection' -import FileList from '../lib/file-list' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' - -function createDiffs (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - let viewModel = new DiffViewModel({fileList: new FileList(fileDiffs, gitService, {stageOnChange: true})}) - spyOn(viewModel.fileList, 'stageLines') - return viewModel +import DiffViewModel from '../lib/diff-view-model' +import {it, beforeEach} from './async-spec-helpers' +import {createDiffViewModel, createFileListViewModel} from './helpers' + +function createDiffs () { + return createDiffViewModel('src/config.coffee', 'dummy-atom') } function expectHunkToBeSelected (isSelected, viewModel, fileDiffIndex, diffHunkIndex) { @@ -27,23 +24,16 @@ function expectLineToBeSelected (isSelected, viewModel, fileDiffIndex, diffHunkI describe('DiffViewModel', function () { let viewModel - let gitService - - beforeEach(() => { - const repoPath = copyRepository() - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - }) describe('selecting diffs', function () { - beforeEach(function () { - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createDiffs() }) it('initially selects the first hunk', function () { expectHunkToBeSelected(true, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) }) describe('selecting hunks', function () { @@ -53,25 +43,16 @@ describe('DiffViewModel', function () { expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.moveSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) - - viewModel.moveSelectionDown() - expectHunkToBeSelected(false, viewModel, 0, 0) - expectHunkToBeSelected(false, viewModel, 0, 1) - expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) viewModel.moveSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) - expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) + expectHunkToBeSelected(true, viewModel, 0, 2) }) }) @@ -80,35 +61,24 @@ describe('DiffViewModel', function () { viewModel.moveSelectionDown() viewModel.moveSelectionDown() viewModel.moveSelectionDown() - viewModel.moveSelectionDown() - expectHunkToBeSelected(false, viewModel, 0, 0) - expectHunkToBeSelected(false, viewModel, 0, 1) - expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) - - viewModel.moveSelectionUp() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.moveSelectionUp() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.moveSelectionUp() expectHunkToBeSelected(true, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.moveSelectionUp() expectHunkToBeSelected(true, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) }) }) @@ -118,55 +88,36 @@ describe('DiffViewModel', function () { expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.expandSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.expandSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) - - viewModel.expandSelectionDown() - expectHunkToBeSelected(false, viewModel, 0, 0) - expectHunkToBeSelected(true, viewModel, 0, 1) - expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) - - viewModel.expandSelectionUp() - expectHunkToBeSelected(false, viewModel, 0, 0) - expectHunkToBeSelected(true, viewModel, 0, 1) - expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.expandSelectionUp() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.expandSelectionUp() expectHunkToBeSelected(true, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.expandSelectionUp() expectHunkToBeSelected(true, viewModel, 0, 0) expectHunkToBeSelected(true, viewModel, 0, 1) expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) viewModel.moveSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) expectHunkToBeSelected(true, viewModel, 0, 2) - expectHunkToBeSelected(false, viewModel, 1, 0) }) }) }) @@ -470,8 +421,7 @@ describe('DiffViewModel', function () { viewModel.moveSelectionDown() expectHunkToBeSelected(false, viewModel, 0, 0) expectHunkToBeSelected(false, viewModel, 0, 1) - expectHunkToBeSelected(false, viewModel, 0, 2) - expectHunkToBeSelected(true, viewModel, 1, 0) + expectHunkToBeSelected(true, viewModel, 0, 2) }) it('keeps the last selection when ::toggleSelectionMode() is called', function () { @@ -487,70 +437,9 @@ describe('DiffViewModel', function () { }) }) - describe('staging diffs', function () { - beforeEach(function () { - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) - }) - - it('stages and unstages the selected hunk', function () { - expectHunkToBeSelected(true, viewModel, 0, 0) - expect(viewModel.getFileDiffs()[0].getStageStatus()).toBe('unstaged') - - viewModel.toggleSelectedLinesStageStatus() - expect(viewModel.getFileDiffs()[0].getStageStatus()).toBe('partial') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getStageStatus()).toBe('staged') - - viewModel.toggleSelectedLinesStageStatus() - expect(viewModel.getFileDiffs()[0].getStageStatus()).toBe('unstaged') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getStageStatus()).toBe('unstaged') - }) - - it('stages and unstages the selected line', function () { - viewModel.setSelectionMode('line') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getLines()[3].isStaged()).toBe(false) - - viewModel.toggleSelectedLinesStageStatus() - expect(viewModel.getFileDiffs()[0].getStageStatus()).toBe('partial') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getStageStatus()).toBe('partial') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getLines()[3].isStaged()).toBe(true) - - viewModel.toggleSelectedLinesStageStatus() - expect(viewModel.getFileDiffs()[0].getStageStatus()).toBe('unstaged') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getStageStatus()).toBe('unstaged') - expect(viewModel.getFileDiffs()[0].getHunks()[0].getLines()[3].isStaged()).toBe(false) - }) - - it('emits only one event for all lines staged', function () { - let selection = new DiffSelection(viewModel, { - mode: 'line', - headPosition: [0, 0, 5], - tailPosition: [0, 0, 4] - }) - - viewModel.setSelection(selection) - viewModel.toggleSelectedLinesStageStatus() - expect(viewModel.fileList.stageLines.callCount).toBe(1) - }) - }) - - describe('handling events from the fileList', function () { - beforeEach(function () { - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) - }) - - it('emits an event when the fileDiff is updated', function () { - let changeHandler = jasmine.createSpy() - viewModel.onDidChange(changeHandler) - const fileDiff = viewModel.getFileDiffs()[0] - - fileDiff.fromString(FileStr) - expect(changeHandler).toHaveBeenCalled() - }) - }) - describe('opening the selected file', function () { - beforeEach(function () { - viewModel = createDiffs('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createDiffs() spyOn(atom.workspace, 'open') }) @@ -561,7 +450,7 @@ describe('DiffViewModel', function () { expect(atom.workspace.open).toHaveBeenCalled() const args = atom.workspace.open.mostRecentCall.args expect(args[0]).toBe('src/config.coffee') - expect(args[1]).toEqual({initialLine: 433}) + expect(args[1]).toEqual({initialLine: 440}) }) it('opens the file to the first selected line in line mode when ::openFileAtSelection() is called', function () { @@ -576,12 +465,130 @@ describe('DiffViewModel', function () { expect(atom.workspace.open).toHaveBeenCalled() const args = atom.workspace.open.mostRecentCall.args expect(args[0]).toBe('src/config.coffee') - expect(args[1]).toEqual({initialLine: 515}) + expect(args[1]).toEqual({initialLine: 554}) }) }) -}) -const FileStr = `FILE src/config.coffee - modified - unstaged - HUNK @@ -85,9 +85,6 @@ ScopeDescriptor = require - 87 87 # - 88 --- - # We use [json schema]` + describe('staging', () => { + let fileListViewModel + let gitService + let filePath + let repoPath + let toggleAll + let refresh + let expectStatus + + beforeEach(async () => { + fileListViewModel = await createFileListViewModel('dummy-atom') + gitService = fileListViewModel.gitService + + viewModel = new DiffViewModel({pathName: 'src/config.coffee', fileListViewModel, gitService}) + + repoPath = viewModel.gitService.repoPath + filePath = path.join(repoPath, 'src/config.coffee') + + toggleAll = async () => { + let selection = new DiffSelection(viewModel, { + mode: 'hunk', + headPosition: [0, 0], + tailPosition: [0, 0] + }) + viewModel.setSelection(selection) + await viewModel.toggleSelectedLinesStageStatus() + } + + refresh = async () => { + await viewModel.fileListViewModel.getFileListStore().loadFromGit() + } + + expectStatus = (expectedStatus) => { + expect(viewModel.getFileDiff().getStageStatus()).toBe(expectedStatus) + } + }) + + describe('.stage()/.unstage()', () => { + it('stages/unstages the entirety of a modified file', async () => { + fs.writeFileSync(filePath, "oh the files, they are a'changin'") + + await refresh() + expectStatus('unstaged') + + await toggleAll() + await refresh() + expectStatus('staged') + + await toggleAll() + await refresh() + expectStatus('unstaged') + }) + + it('stages/unstages the entirety of a modified file that ends in a newline', async () => { + fs.writeFileSync(filePath, "oh the files, they are a'changin'\n") + + await refresh() + expectStatus('unstaged') + + await toggleAll() + await refresh() + expectStatus('staged') + + await toggleAll() + await refresh() + expectStatus('unstaged') + }) + + it('stages/unstages the entirety of a deleted file', async () => { + fs.removeSync(filePath) + + await refresh() + expectStatus('unstaged') + + await toggleAll() + await refresh() + expectStatus('staged') + + await toggleAll() + await refresh() + expectStatus('unstaged') + }) + + it('stages/unstages the entirety of a new file', async () => { + const newFileName = 'REAMDE.md' + const newFilePath = path.join(repoPath, newFileName) + fs.writeFileSync(newFilePath, 'a whole new world') + + viewModel = new DiffViewModel({pathName: newFileName, fileListViewModel, gitService}) + + await refresh() + expectStatus('unstaged') + + await toggleAll() + await refresh() + expectStatus('staged') + + await toggleAll() + await refresh() + expectStatus('unstaged') + }) + + it('stages/unstages the entirety of a new file that ends in a newline', async () => { + const newFileName = 'REAMDE.md' + const newFilePath = path.join(repoPath, newFileName) + fs.writeFileSync(newFilePath, 'a whole new world\na new fantastic POV\n') + + viewModel = new DiffViewModel({pathName: newFileName, fileListViewModel, gitService}) + + await refresh() + expectStatus('unstaged') + + await toggleAll() + await refresh() + expectStatus('staged') + + await toggleAll() + await refresh() + expectStatus('unstaged') + }) + }) + }) +}) diff --git a/spec/event-transactor-spec.js b/spec/event-transactor-spec.js deleted file mode 100644 index edda1ddbd5..0000000000 --- a/spec/event-transactor-spec.js +++ /dev/null @@ -1,60 +0,0 @@ -/** @babel */ - -import EventTransactor from '../lib/event-transactor' - -describe('EventTransactor', function () { - let emitter, transactor - - beforeEach(function () { - emitter = { - emit: jasmine.createSpy('emit') - } - transactor = new EventTransactor(emitter) - }) - - it('emits one event when didChange is called', function () { - transactor.didChange() - expect(emitter.emit.callCount).toBe(1) - let args = emitter.emit.mostRecentCall.args - expect(args[0]).toBe('did-change') - }) - - it('emits one event when didChange is called multiple times in an transaction', function () { - transactor.transact(() => { - transactor.didChange({line: 1}) - transactor.didChange({line: 2}) - transactor.didChange({line: 3}) - }) - - expect(emitter.emit.callCount).toBe(1) - let args = emitter.emit.mostRecentCall.args - let eventObj = args[1] - expect(args[0]).toBe('did-change') - expect(eventObj.events).toHaveLength(3) - expect(eventObj.events[0].line).toBe(1) - expect(eventObj.events[2].line).toBe(3) - }) - - it('emits one event when transactions are nested', function () { - transactor.transact(() => { - transactor.transact(() => { - transactor.didChange() - transactor.didChange() - transactor.didChange() - }) - transactor.transact(() => { - transactor.didChange() - transactor.didChange() - transactor.didChange() - }) - }) - expect(emitter.emit.callCount).toBe(1) - let args = emitter.emit.mostRecentCall.args - expect(args[0]).toBe('did-change') - }) - - it('does not emit when an empty transaction is executed', function () { - transactor.transact(() => {}) - expect(emitter.emit).not.toHaveBeenCalled() - }) -}) diff --git a/spec/file-diff-spec.js b/spec/file-diff-spec.js index d3d8c12555..decb453b40 100644 --- a/spec/file-diff-spec.js +++ b/spec/file-diff-spec.js @@ -1,184 +1,13 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import path from 'path' -import fs from 'fs-plus' - -import FileDiff from '../lib/file-diff' -import FileList from '../lib/file-list' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' -import {it, runs} from './async-spec-helpers' +import {createDiffViewModel} from './helpers' +import {it} from './async-spec-helpers' describe('FileDiff', function () { - it('roundtrips toString and fromString', function () { - let file = FileStr - let fileDiff = FileDiff.fromString(file) - expect(fileDiff.toString()).toEqual(file) - }) - - describe('staging', () => { - const fileName = 'README.md' - let repoPath - let filePath - let fileList - - let getDiff - let callAndWaitForEvent - - beforeEach(() => { - repoPath = copyRepository() - - const gitService = new GitService(GitRepositoryAsync.open(repoPath)) - - fileList = new FileList([], gitService, {stageOnChange: true}) - - filePath = path.join(repoPath, fileName) - - getDiff = async (fileName) => { - await fileList.loadFromGitUtils() - - const diff = fileList.getFileFromPathName(fileName) - expect(diff).toBeDefined() - - return diff - } - - callAndWaitForEvent = (fn) => { - const changeHandler = jasmine.createSpy() - runs(async () => { - fileList.onDidUserChange(changeHandler) - await fn() - }) - waitsFor(() => changeHandler.callCount === 1) - } - }) - - describe('.stage()/.unstage()', () => { - it('stages/unstages all hunks in a modified file', async () => { - fs.writeFileSync(filePath, "oh the files, they are a'changin'") - - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) - - it('stages/unstages all hunks in a modified file that ends in a newline', async () => { - fs.writeFileSync(filePath, "oh the files, they are a'changin'\n") - - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) - - it('stages/unstages all hunks in a renamed file', () => { - const newFileName = 'REAMDE.md' - const newFilePath = path.join(repoPath, newFileName) - fs.moveSync(filePath, newFilePath) - - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) + it('returns "partial" from getStageStatus() when some of the hunks are staged', async () => { + const viewModel = await createDiffViewModel('src/config.coffee', 'dummy-atom') - it('stages/unstages all hunks in a deleted file', () => { - fs.removeSync(filePath) - - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(fileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) - - it('stages/unstages all hunks in a new file', () => { - const newFileName = 'REAMDE.md' - const newFilePath = path.join(repoPath, newFileName) - fs.writeFileSync(newFilePath, 'a whole new world') - - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) - - it('stages/unstages all hunks in a new file that ends in a newline', () => { - const newFileName = 'REAMDE.md' - const newFilePath = path.join(repoPath, newFileName) - fs.writeFileSync(newFilePath, 'a whole new world\na new fantastic POV\n') - - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - diff.stage() - }) - callAndWaitForEvent(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('staged') - diff.unstage() - }) - runs(async () => { - const diff = await getDiff(newFileName) - expect(diff.getStageStatus()).toBe('unstaged') - }) - }) - }) - }) - - it('returns "partial" from getStageStatus() when some of the hunks are staged', function () { - let fileDiff = createFileDiffsFromPath('fixtures/two-file-diff.txt')[0] + let fileDiff = viewModel.getFileDiffs()[0] expect(fileDiff.getStageStatus()).toBe('unstaged') fileDiff.getHunks()[1].stage() @@ -187,45 +16,4 @@ describe('FileDiff', function () { fileDiff.getHunks()[1].unstage() expect(fileDiff.getStageStatus()).toBe('unstaged') }) - - it('emits an event when FileDiff::fromString() is called', function () { - let changeHandler = jasmine.createSpy() - let file = FileStr - let fileDiff = new FileDiff() - fileDiff.onDidChange(changeHandler) - - fileDiff.fromString(file) - expect(changeHandler).toHaveBeenCalled() - expect(fileDiff.toString()).toEqual(file) - }) - - it('emits one change event when the file is staged', function () { - let changeHandler = jasmine.createSpy() - let fileDiff = createFileDiffsFromPath('fixtures/two-file-diff.txt')[0] - fileDiff.onDidChange(changeHandler) - - fileDiff.stage() - expect(changeHandler.callCount).toBe(1) - }) - - it('emits a change event when a hunk is staged', function () { - let changeHandler = jasmine.createSpy() - let fileDiff = createFileDiffsFromPath('fixtures/two-file-diff.txt')[0] - fileDiff.onDidChange(changeHandler) - - fileDiff.getHunks()[1].stage() - expect(changeHandler).toHaveBeenCalled() - }) }) - -const FileStr = `FILE src/config.coffee - modified - unstaged -HUNK @@ -85,9 +85,6 @@ ScopeDescriptor = require './scope-descriptor' - 85 85 # - 86 86 # ## Config Schemas - 87 87 # - 88 --- - # We use [json schema](http://json-schema.org) which allows you to define your value's - 89 --- - # default, the type it should be, etc. A simple example: - 90 --- - # - 91 88 # \`\`\`coffee - 92 89 # # We want to provide an \`enableThing\`, and a \`thingVolume\` - 93 90 # config:` diff --git a/spec/file-diff-view-model-spec.js b/spec/file-diff-view-model-spec.js index ca410bf299..9de1fad75f 100644 --- a/spec/file-diff-view-model-spec.js +++ b/spec/file-diff-view-model-spec.js @@ -3,16 +3,16 @@ import path from 'path' import fs from 'fs-plus' import {GitRepositoryAsync} from 'atom' -import FileList from '../lib/file-list' +import FileListStore from '../lib/file-list-store' import GitService from '../lib/git-service' import FileDiffViewModel from '../lib/file-diff-view-model' import {copyRepository} from './helpers' import {waitsForPromise} from './async-spec-helpers' async function createDiffViewModel (gitService, fileName) { - const fileList = new FileList([], gitService, {stageOnChange: false}) - await fileList.loadFromGitUtils() - const fileDiff = fileList.getFileFromPathName(fileName) + const fileListStore = new FileListStore(gitService) + await fileListStore.loadFromGit() + const fileDiff = fileListStore.getFileFromPathName(fileName) expect(fileDiff).toBeDefined() return new FileDiffViewModel(fileDiff) diff --git a/spec/file-list-component-spec.js b/spec/file-list-component-spec.js index c5afc7a93f..a50d675665 100644 --- a/spec/file-list-component-spec.js +++ b/spec/file-list-component-spec.js @@ -1,21 +1,12 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' import etch from 'etch' -import FileList from '../lib/file-list' -import FileListViewModel from '../lib/file-list-view-model' import FileListComponent from '../lib/file-list-component' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, buildMouseEvent, copyRepository} from './helpers' - -function createFileList (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - let fileList = new FileList(fileDiffs, gitService) - return new FileListViewModel(fileList) -} +import {beforeEach} from './async-spec-helpers' +import {createFileListViewModel, buildMouseEvent} from './helpers' describe('FileListComponent', function () { - let viewModel, component, element, gitService + let viewModel, component, element function getFileElements () { return element.querySelectorAll('.git-FileSummary') @@ -25,10 +16,8 @@ describe('FileListComponent', function () { return getFileElements()[index] } - beforeEach(function () { - const repoPath = copyRepository() - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - viewModel = createFileList('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createFileListViewModel() component = new FileListComponent({fileListViewModel: viewModel}) element = component.element jasmine.attachToDOM(component.element) @@ -73,7 +62,7 @@ describe('FileListComponent', function () { }) describe('mouse clicks', () => { - const expectedURI = 'atom://git/diff/src/config.coffee' + const expectedURI = 'atom://git/diff/README.md' it("displays the file's diff in the pending state on single click", () => { spyOn(atom.workspace, 'open') diff --git a/spec/file-list-spec.js b/spec/file-list-spec.js deleted file mode 100644 index 1e7404fd73..0000000000 --- a/spec/file-list-spec.js +++ /dev/null @@ -1,98 +0,0 @@ -/** @babel */ - -import {GitRepositoryAsync} from 'atom' -import FileList from '../lib/file-list' -import FileDiff from '../lib/file-diff' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' -import {it, beforeEach} from './async-spec-helpers' - -function createFileList (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - return new FileList(fileDiffs, gitService) -} - -describe('FileList', function () { - let fileList - let gitService - - beforeEach(() => { - const repoPath = copyRepository() - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - }) - - it('emits a change event when a file is staged', function () { - fileList = createFileList('fixtures/two-file-diff.txt', gitService) - let changeHandler = jasmine.createSpy() - fileList.onDidChange(changeHandler) - - let fileDiff = fileList.getFiles()[0] - fileDiff.stage() - expect(changeHandler.callCount).toBe(1) - let args = changeHandler.mostRecentCall.args - expect(args[0].fileList).toBe(fileList) - expect(args[0].events).toHaveLength(1) - expect(args[0].events[0].file).toBe(fileDiff) - }) - - it('emits a change event when a file is staged', function () { - fileList = createFileList('fixtures/two-file-diff.txt', gitService) - let changeHandler = jasmine.createSpy() - fileList.onDidChange(changeHandler) - - fileList.getFiles()[0].getHunks()[0].stage() - let args = changeHandler.mostRecentCall.args - expect(args[0].fileList).toBe(fileList) - expect(args[0].events).toHaveLength(1) - expect(args[0].events[0].file).toBe(fileList.getFiles()[0]) - }) - - it('opens a new diff item as pending when openFileDiff is called', function () { - spyOn(atom.workspace, 'open') - fileList.openFileDiff(fileList.getFiles()[0]) - - let args = atom.workspace.open.mostRecentCall.args - expect(args[0]).toContain('config.coffee') - expect(args[1].pending).toBe(true) - }) - - it('opens a file for editing when openFile is called', function () { - spyOn(atom.workspace, 'open') - fileList.openFile(fileList.getFiles()[0]) - - let args = atom.workspace.open.mostRecentCall.args - expect(args[0]).toEqual('src/config.coffee') - expect(args[1].pending).toBe(true) - }) - - describe('the file cache', function () { - let fileDiffA, fileDiffB - beforeEach(function () { - fileDiffA = new FileDiff({ - oldPathName: 'src/a.js', - newPathName: 'src/a.js' - }) - fileDiffB = new FileDiff({ - oldPathName: 'src/b.js', - newPathName: 'src/b.js' - }) - fileList = new FileList([fileDiffA, fileDiffB], gitService) - }) - - it('creates and retreives fileDiffs', function () { - expect(fileList.getFileFromPathName(fileDiffA.getNewPathName())).toBe(fileDiffA) - expect(fileList.getFileFromPathName(fileDiffB.getNewPathName())).toBe(fileDiffB) - - let fileDiffC = fileList.getOrCreateFileFromPathName('src/c.js') - expect(fileList.getFileFromPathName(fileDiffC.getNewPathName())).toBe(fileDiffC) - }) - - it('moves model to the new file key when a file is renamed', function () { - fileDiffA.setNewPathName('src/a-new.js') - fileList.setFiles([fileDiffA, fileDiffB]) - expect(fileList.getFileFromPathName('src/a-new.js')).toBe(fileDiffA) - expect(fileList.getFileFromPathName('src/a.js')).toBeUndefined() - expect(fileList.getFileFromPathName(fileDiffB.getNewPathName())).toBe(fileDiffB) - }) - }) -}) diff --git a/spec/file-list-store-spec.js b/spec/file-list-store-spec.js new file mode 100644 index 0000000000..c9ecffe2ae --- /dev/null +++ b/spec/file-list-store-spec.js @@ -0,0 +1,30 @@ +/** @babel */ + +import {createFileListStore} from './helpers' +import {it, beforeEach} from './async-spec-helpers' + +describe('FileListStore', function () { + let fileListStore + + beforeEach(async () => { + fileListStore = await createFileListStore() + }) + + it('opens a new diff item as pending when openFileDiff is called', () => { + spyOn(atom.workspace, 'open') + fileListStore.openFileDiff(fileListStore.getFiles()[0]) + + let args = atom.workspace.open.mostRecentCall.args + expect(args[0]).toContain('README.md') + expect(args[1].pending).toBe(true) + }) + + it('opens a file for editing when openFile is called', () => { + spyOn(atom.workspace, 'open') + fileListStore.openFile(fileListStore.getFiles()[0]) + + let args = atom.workspace.open.mostRecentCall.args + expect(args[0]).toEqual('README.md') + expect(args[1].pending).toBe(true) + }) +}) diff --git a/spec/file-list-view-model-spec.js b/spec/file-list-view-model-spec.js index e9f83cc042..5ebf9cf2a9 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -1,25 +1,15 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import FileList from '../lib/file-list' -import FileListViewModel from '../lib/file-list-view-model' -import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' - -function createFileList (filePath, gitService) { - let fileDiffs = createFileDiffsFromPath(filePath) - let fileList = new FileList(fileDiffs, gitService, {stageOnChange: false}) - return new FileListViewModel(fileList) -} +import path from 'path' +import fs from 'fs-plus' +import {beforeEach, it} from './async-spec-helpers' +import {createFileListViewModel} from './helpers' describe('FileListViewModel', function () { let viewModel - let gitService - beforeEach(function () { - const repoPath = copyRepository() - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - viewModel = createFileList('fixtures/two-file-diff.txt', gitService) + beforeEach(async () => { + viewModel = await createFileListViewModel() }) describe('moving the selection', function () { @@ -28,6 +18,7 @@ describe('FileListViewModel', function () { }) it('moves the selection down on ::moveSelectionDown()', function () { + expect(viewModel.getSelectedIndex()).toBe(0) viewModel.moveSelectionDown() expect(viewModel.getSelectedIndex()).toBe(1) viewModel.moveSelectionDown() @@ -36,6 +27,7 @@ describe('FileListViewModel', function () { }) it('moves the selection up on ::moveSelectionUp()', function () { + expect(viewModel.getSelectedIndex()).toBe(0) viewModel.moveSelectionDown() viewModel.moveSelectionDown() expect(viewModel.getSelectedIndex()).toBe(1) @@ -46,4 +38,118 @@ describe('FileListViewModel', function () { expect(viewModel.getSelectedIndex()).toBe(0) }) }) + + describe('staging', () => { + let filePath + let repoPath + let getDiff + + beforeEach(() => { + repoPath = viewModel.gitService.repoPath + filePath = path.join(repoPath, 'README.md') + + getDiff = async (name) => { + await viewModel.getFileListStore().loadFromGit() + return viewModel.getDiffForPathName(name) + } + }) + + describe('.stage()/.unstage()', () => { + it('stages/unstages the entirety of a modified file', async () => { + fs.writeFileSync(filePath, "oh the files, they are a'changin'") + + let fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + + it('stages/unstages the entirety of a modified file that ends in a newline', async () => { + fs.writeFileSync(filePath, "oh the files, they are a'changin'\n") + + let fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + + it('stages/unstages the entirety of a renamed file', async () => { + const newFileName = 'REAMDE.md' + const newFilePath = path.join(repoPath, newFileName) + fs.moveSync(filePath, newFilePath) + + let fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + + it('stages/unstages the entirety of a deleted file', async () => { + fs.removeSync(filePath) + + let fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff('README.md') + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + + it('stages/unstages the entirety of a new file', async () => { + const newFileName = 'REAMDE.md' + const newFilePath = path.join(repoPath, newFileName) + fs.writeFileSync(newFilePath, 'a whole new world') + + let fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + + it('stages/unstages the entirety of a new file that ends in a newline', async () => { + const newFileName = 'REAMDE.md' + const newFilePath = path.join(repoPath, newFileName) + fs.writeFileSync(newFilePath, 'a whole new world\na new fantastic POV\n') + + let fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('staged') + + await viewModel.toggleFileStageStatus(fileDiff) + fileDiff = await getDiff(newFileName) + expect(fileDiff.getStageStatus()).toBe('unstaged') + }) + }) + }) }) diff --git a/spec/fixtures/dummy-atom/git.git/COMMIT_EDITMSG b/spec/fixtures/dummy-atom/git.git/COMMIT_EDITMSG new file mode 100644 index 0000000000..6d2648541d --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/COMMIT_EDITMSG @@ -0,0 +1,11 @@ +first +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# On branch master +# +# Initial commit +# +# Changes to be committed: +# new file: src/config.coffee +# new file: src/workspace.coffee +# diff --git a/spec/fixtures/dummy-atom/git.git/HEAD b/spec/fixtures/dummy-atom/git.git/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/spec/fixtures/dummy-atom/git.git/config b/spec/fixtures/dummy-atom/git.git/config new file mode 100644 index 0000000000..6c9406b7d9 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true diff --git a/spec/fixtures/dummy-atom/git.git/description b/spec/fixtures/dummy-atom/git.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/spec/fixtures/dummy-atom/git.git/hooks/applypatch-msg.sample b/spec/fixtures/dummy-atom/git.git/hooks/applypatch-msg.sample new file mode 100755 index 0000000000..a5d7b84a67 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/spec/fixtures/dummy-atom/git.git/hooks/commit-msg.sample b/spec/fixtures/dummy-atom/git.git/hooks/commit-msg.sample new file mode 100755 index 0000000000..b58d1184a9 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/spec/fixtures/dummy-atom/git.git/hooks/post-update.sample b/spec/fixtures/dummy-atom/git.git/hooks/post-update.sample new file mode 100755 index 0000000000..ec17ec1939 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/spec/fixtures/dummy-atom/git.git/hooks/pre-applypatch.sample b/spec/fixtures/dummy-atom/git.git/hooks/pre-applypatch.sample new file mode 100755 index 0000000000..4142082bcb --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/spec/fixtures/dummy-atom/git.git/hooks/pre-commit.sample b/spec/fixtures/dummy-atom/git.git/hooks/pre-commit.sample new file mode 100755 index 0000000000..68d62d5446 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/spec/fixtures/dummy-atom/git.git/hooks/pre-push.sample b/spec/fixtures/dummy-atom/git.git/hooks/pre-push.sample new file mode 100755 index 0000000000..6187dbf439 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/spec/fixtures/dummy-atom/git.git/hooks/pre-rebase.sample b/spec/fixtures/dummy-atom/git.git/hooks/pre-rebase.sample new file mode 100755 index 0000000000..9773ed4cb2 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up-to-date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +exit 0 + +################################################################ + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". diff --git a/spec/fixtures/dummy-atom/git.git/hooks/prepare-commit-msg.sample b/spec/fixtures/dummy-atom/git.git/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000000..f093a02ec4 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/prepare-commit-msg.sample @@ -0,0 +1,36 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first comments out the +# "Conflicts:" part of a merge commit. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +case "$2,$3" in + merge,) + /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; + +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$1" ;; + + *) ;; +esac + +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" diff --git a/spec/fixtures/dummy-atom/git.git/hooks/update.sample b/spec/fixtures/dummy-atom/git.git/hooks/update.sample new file mode 100755 index 0000000000..80ba94135c --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/spec/fixtures/dummy-atom/git.git/index b/spec/fixtures/dummy-atom/git.git/index new file mode 100644 index 0000000000..9fc0c7c8e2 Binary files /dev/null and b/spec/fixtures/dummy-atom/git.git/index differ diff --git a/spec/fixtures/dummy-atom/git.git/info/exclude b/spec/fixtures/dummy-atom/git.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/spec/fixtures/dummy-atom/git.git/logs/HEAD b/spec/fixtures/dummy-atom/git.git/logs/HEAD new file mode 100644 index 0000000000..f441cf9b4f --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/logs/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 58e0826234eb7b82ffce8b717302bd991b1b0269 joshaber 1458680438 -0400 commit (initial): first diff --git a/spec/fixtures/dummy-atom/git.git/logs/refs/heads/master b/spec/fixtures/dummy-atom/git.git/logs/refs/heads/master new file mode 100644 index 0000000000..f441cf9b4f --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/logs/refs/heads/master @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 58e0826234eb7b82ffce8b717302bd991b1b0269 joshaber 1458680438 -0400 commit (initial): first diff --git a/spec/fixtures/dummy-atom/git.git/objects/25/9905570d5a5253c139ead02042aa88ade66da8 b/spec/fixtures/dummy-atom/git.git/objects/25/9905570d5a5253c139ead02042aa88ade66da8 new file mode 100644 index 0000000000..8de27458d9 Binary files /dev/null and b/spec/fixtures/dummy-atom/git.git/objects/25/9905570d5a5253c139ead02042aa88ade66da8 differ diff --git a/spec/fixtures/dummy-atom/git.git/objects/58/e0826234eb7b82ffce8b717302bd991b1b0269 b/spec/fixtures/dummy-atom/git.git/objects/58/e0826234eb7b82ffce8b717302bd991b1b0269 new file mode 100644 index 0000000000..b0fa39d83f --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/objects/58/e0826234eb7b82ffce8b717302bd991b1b0269 @@ -0,0 +1,3 @@ +x��K +!³ö}�„öÓ¦…!ÌU:j2†ˆà˜ûÇÍìgW<êQ±ÕZhï.£ç †B@¢;&2d£¶!KBƒÎˆ0KÊÞ'a%¿±µŸ¶oòÌ–ƒÖw•ò½ÅV ±gt–áŠQÍuæÆôýäQ½J߇úB +4ú \ No newline at end of file diff --git a/spec/fixtures/dummy-atom/git.git/objects/66/f07516e37d3f32eb8c7cb2405b40796be98f3f b/spec/fixtures/dummy-atom/git.git/objects/66/f07516e37d3f32eb8c7cb2405b40796be98f3f new file mode 100644 index 0000000000..b21827961a Binary files /dev/null and b/spec/fixtures/dummy-atom/git.git/objects/66/f07516e37d3f32eb8c7cb2405b40796be98f3f differ diff --git a/spec/fixtures/dummy-atom/git.git/objects/b8/ed79fd6dfe9d2fafd97058f0c7d624688a547f b/spec/fixtures/dummy-atom/git.git/objects/b8/ed79fd6dfe9d2fafd97058f0c7d624688a547f new file mode 100644 index 0000000000..2011ce79e6 Binary files /dev/null and b/spec/fixtures/dummy-atom/git.git/objects/b8/ed79fd6dfe9d2fafd97058f0c7d624688a547f differ diff --git a/spec/fixtures/dummy-atom/git.git/objects/dc/56415965e522162912c3dd812843e44a629c2f b/spec/fixtures/dummy-atom/git.git/objects/dc/56415965e522162912c3dd812843e44a629c2f new file mode 100644 index 0000000000..58bca05488 Binary files /dev/null and b/spec/fixtures/dummy-atom/git.git/objects/dc/56415965e522162912c3dd812843e44a629c2f differ diff --git a/spec/fixtures/dummy-atom/git.git/refs/heads/master b/spec/fixtures/dummy-atom/git.git/refs/heads/master new file mode 100644 index 0000000000..9e6541fc73 --- /dev/null +++ b/spec/fixtures/dummy-atom/git.git/refs/heads/master @@ -0,0 +1 @@ +58e0826234eb7b82ffce8b717302bd991b1b0269 diff --git a/spec/fixtures/dummy-atom/src/config.coffee b/spec/fixtures/dummy-atom/src/config.coffee new file mode 100644 index 0000000000..f37efc8568 --- /dev/null +++ b/spec/fixtures/dummy-atom/src/config.coffee @@ -0,0 +1,1260 @@ +_ = require 'underscore-plus' +fs = require 'fs-plus' +{CompositeDisposable, Disposable, Emitter} = require 'event-kit' +CSON = require 'season' +path = require 'path' +async = require 'async' +pathWatcher = require 'pathwatcher' +{ + getValueAtKeyPath, setValueAtKeyPath, deleteValueAtKeyPath, + pushKeyPath, splitKeyPath, +} = require 'key-path-helpers' + +Color = require './color' +ScopedPropertyStore = require 'scoped-property-store' +ScopeDescriptor = require './scope-descriptor' + +# Essential: Used to access all of Atom's configuration details. +# +# An instance of this class is always available as the `atom.config` global. +# +# ## Getting and setting config settings. +# +# ```coffee +# # Note that with no value set, ::get returns the setting's default value. +# atom.config.get('my-package.myKey') # -> 'defaultValue' +# +# atom.config.set('my-package.myKey', 'value') +# atom.config.get('my-package.myKey') # -> 'value' +# ``` +# +# You may want to watch for changes. Use {::observe} to catch changes to the setting. +# +# ```coffee +# atom.config.set('my-package.myKey', 'value') +# atom.config.observe 'my-package.myKey', (newValue) -> +# # `observe` calls immediately and every time the value is changed +# console.log 'My configuration changed:', newValue +# ``` +# +# If you want a notification only when the value changes, use {::onDidChange}. +# +# ```coffee +# atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) -> +# console.log 'My configuration changed:', newValue, oldValue +# ``` +# +# ### Value Coercion +# +# Config settings each have a type specified by way of a +# [schema](json-schema.org). For example we might an integer setting that only +# allows integers greater than `0`: +# +# ```coffee +# # When no value has been set, `::get` returns the setting's default value +# atom.config.get('my-package.anInt') # -> 12 +# +# # The string will be coerced to the integer 123 +# atom.config.set('my-package.anInt', '123') +# atom.config.get('my-package.anInt') # -> 123 +# +# # The string will be coerced to an integer, but it must be greater than 0, so is set to 1 +# atom.config.set('my-package.anInt', '-20') +# atom.config.get('my-package.anInt') # -> 1 +# ``` +# +# ## Defining settings for your package +# +# Define a schema under a `config` key in your package main. +# +# ```coffee +# module.exports = +# # Your config schema +# config: +# someInt: +# type: 'integer' +# default: 23 +# minimum: 1 +# +# activate: (state) -> # ... +# # ... +# ``` +# +# See [package docs](https://atom.io/docs/latest/hacking-atom-package-word-count) for +# more info. +# +# ## Config Schemas +# +# ```coffee +# # We want to provide an `enableThing`, and a `thingVolume` +# config: +# enableThing: +# type: 'boolean' +# default: false +# thingVolume: +# type: 'integer' +# default: 5 +# minimum: 1 +# maximum: 11 +# ``` +# +# The type keyword allows for type coercion and validation. If a `thingVolume` is +# set to a string `'10'`, it will be coerced into an integer. +# +# ```coffee +# atom.config.set('my-package.thingVolume', '10') +# atom.config.get('my-package.thingVolume') # -> 10 +# +# # It respects the min / max +# atom.config.set('my-package.thingVolume', '400') +# atom.config.get('my-package.thingVolume') # -> 11 +# +# # If it cannot be coerced, the value will not be set +# atom.config.set('my-package.thingVolume', 'cats') +# atom.config.get('my-package.thingVolume') # -> 11 +# ``` +# +# ### Supported Types +# +# The `type` keyword can be a string with any one of the following. You can also +# chain them by specifying multiple in an an array. For example +# +# ```coffee +# config: +# someSetting: +# type: ['boolean', 'integer'] +# default: 5 +# +# # Then +# atom.config.set('my-package.someSetting', 'true') +# atom.config.get('my-package.someSetting') # -> true +# +# atom.config.set('my-package.someSetting', '12') +# atom.config.get('my-package.someSetting') # -> 12 +# ``` +# +# #### string +# +# Values must be a string. +# +# ```coffee +# config: +# someSetting: +# type: 'string' +# default: 'hello' +# ``` +# +# #### integer +# +# Values will be coerced into integer. Supports the (optional) `minimum` and +# `maximum` keys. +# +# ```coffee +# config: +# someSetting: +# type: 'integer' +# default: 5 +# minimum: 1 +# maximum: 11 +# ``` +# +# #### number +# +# Values will be coerced into a number, including real numbers. Supports the +# (optional) `minimum` and `maximum` keys. +# +# ```coffee +# config: +# someSetting: +# type: 'number' +# default: 5.3 +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# #### boolean +# +# Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into +# a boolean. Numbers, arrays, objects, and anything else will not be coerced. +# +# ```coffee +# config: +# someSetting: +# type: 'boolean' +# default: false +# ``` +# +# #### array +# +# Value must be an Array. The types of the values can be specified by a +# subschema in the `items` key. +# +# ```coffee +# config: +# someSetting: +# type: 'array' +# default: [1, 2, 3] +# items: +# type: 'integer' +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# #### color +# +# Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha` +# properties that all have numeric values. `red`, `green`, `blue` will be in +# the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any +# valid CSS color format such as `#abc`, `#abcdef`, `white`, +# `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`. +# +# ```coffee +# config: +# someSetting: +# type: 'color' +# default: 'white' +# ``` +# +# #### object / Grouping other types +# +# A config setting with the type `object` allows grouping a set of config +# settings. The group will be visualy separated and has its own group headline. +# The sub options must be listed under a `properties` key. +# +# ```coffee +# config: +# someSetting: +# type: 'object' +# properties: +# myChildIntOption: +# type: 'integer' +# minimum: 1.5 +# maximum: 11.5 +# ``` +# +# ### Other Supported Keys +# +# #### enum +# +# All types support an `enum` key, which lets you specify all the values the +# setting can take. `enum` may be an array of allowed values (of the specified +# type), or an array of objects with `value` and `description` properties, where +# the `value` is an allowed value, and the `description` is a descriptive string +# used in the settings view. +# +# In this example, the setting must be one of the 4 integers: +# +# ```coffee +# config: +# someSetting: +# type: 'integer' +# default: 4 +# enum: [2, 4, 6, 8] +# ``` +# +# In this example, the setting must be either 'foo' or 'bar', which are +# presented using the provided descriptions in the settings pane: +# +# ```coffee +# config: +# someSetting: +# type: 'string' +# default: 'foo' +# enum: [ +# {value: 'foo', description: 'Foo mode. You want this.'} +# {value: 'bar', description: 'Bar mode. Nobody wants that!'} +# ] +# ``` +# +# Usage: +# +# ```coffee +# atom.config.set('my-package.someSetting', '2') +# atom.config.get('my-package.someSetting') # -> 2 +# +# # will not set values outside of the enum values +# atom.config.set('my-package.someSetting', '3') +# atom.config.get('my-package.someSetting') # -> 2 +# +# # If it cannot be coerced, the value will not be set +# atom.config.set('my-package.someSetting', '4') +# atom.config.get('my-package.someSetting') # -> 4 +# ``` +# +# #### title and description +# +# The settings view will use the `title` and `description` keys to display your +# config setting in a readable way. By default the settings view humanizes your +# config key, so `someSetting` becomes `Some Setting`. In some cases, this is +# confusing for users, and a more descriptive title is useful. +# +# Descriptions will be displayed below the title in the settings view. +# +# For a group of config settings the humanized key or the title and the +# description are used for the group headline. +# +# ```coffee +# config: +# someSetting: +# title: 'Setting Magnitude' +# description: 'This will affect the blah and the other blah' +# type: 'integer' +# default: 4 +# ``` +# +# __Note__: You should strive to be so clear in your naming of the setting that +# you do not need to specify a title or description! +# +# Descriptions allow a subset of +# [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/). +# Specifically, you may use the following in configuration setting descriptions: +# +# * **bold** - `**bold**` +# * *italics* - `*italics*` +# * [links](https://atom.io) - `[links](https://atom.io)` +# * `code spans` - `\`code spans\`` +# * line breaks - `line breaks
` +# * ~~strikethrough~~ - `~~strikethrough~~` +# +# #### order +# +# The settings view orders your settings alphabetically. You can override this +# ordering with the order key. +# +# ```coffee +# config: +# zSetting: +# type: 'integer' +# default: 4 +# order: 1 +# aSetting: +# type: 'integer' +# default: 4 +# order: 2 +# ``` +# +# ## Best practices +# +# * Don't depend on (or write to) configuration keys outside of your keypath. +# +module.exports = +class Config + @schemaEnforcers = {} + + @addSchemaEnforcer: (typeName, enforcerFunction) -> + @schemaEnforcers[typeName] ?= [] + @schemaEnforcers[typeName].push(enforcerFunction) + + @addSchemaEnforcers: (filters) -> + for typeName, functions of filters + for name, enforcerFunction of functions + @addSchemaEnforcer(typeName, enforcerFunction) + return + + @executeSchemaEnforcers: (keyPath, value, schema) -> + error = null + types = schema.type + types = [types] unless Array.isArray(types) + for type in types + try + enforcerFunctions = @schemaEnforcers[type].concat(@schemaEnforcers['*']) + for enforcer in enforcerFunctions + # At some point in one's life, one must call upon an enforcer. + value = enforcer.call(this, keyPath, value, schema) + error = null + break + catch e + error = e + + throw error if error? + value + + # Created during initialization, available as `atom.config` + constructor: ({@configDirPath, @resourcePath, @notificationManager, @enablePersistence}={}) -> + if @enablePersistence? + @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson']) + @configFilePath ?= path.join(@configDirPath, 'config.cson') + @clear() + + clear: -> + @emitter = new Emitter + @schema = + type: 'object' + properties: {} + @defaultSettings = {} + @settings = {} + @scopedSettingsStore = new ScopedPropertyStore + @configFileHasErrors = false + @transactDepth = 0 + @savePending = false + @requestLoad = _.debounce(@loadUserConfig, 100) + @requestSave = => + @savePending = true + debouncedSave.call(this) + save = => + @savePending = false + @save() + debouncedSave = _.debounce(save, 100) + + shouldNotAccessFileSystem: -> not @enablePersistence + + ### + Section: Config Subscription + ### + + # Essential: Add a listener for changes to a given key path. This is different + # than {::onDidChange} in that it will immediately call your callback with the + # current value of the config entry. + # + # ### Examples + # + # You might want to be notified when the themes change. We'll watch + # `core.themes` for changes + # + # ```coffee + # atom.config.observe 'core.themes', (value) -> + # # do stuff with value + # ``` + # + # * `keyPath` {String} name of the key to observe + # * `options` (optional) {Object} + # * `scope` (optional) {ScopeDescriptor} describing a path from + # the root of the syntax tree to a token. Get one by calling + # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) + # for more information. + # * `callback` {Function} to call when the value of the key changes. + # * `value` the new value of the key + # + # Returns a {Disposable} with the following keys on which you can call + # `.dispose()` to unsubscribe. + observe: -> + if arguments.length is 2 + [keyPath, callback] = arguments + else if arguments.length is 3 and (_.isString(arguments[0]) and _.isObject(arguments[1])) + [keyPath, options, callback] = arguments + scopeDescriptor = options.scope + else + console.error 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details' + return + + # Some new linesssss + # + # + if scopeDescriptor? + @observeScopedKeyPath(scopeDescriptor, keyPath, callback) + else + @observeKeyPath(keyPath, options ? {}, callback) + + # Essential: Add a listener for changes to a given key path. If `keyPath` is + # not specified, your callback will be called on changes to any key. + # + # * `keyPath` (optional) {String} name of the key to observe. Must be + # specified if `scopeDescriptor` is specified. + # * `options` (optional) {Object} + # * `scope` (optional) {ScopeDescriptor} describing a path from + # the root of the syntax tree to a token. Get one by calling + # {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples. + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) + # for more information. + # * `callback` {Function} to call when the value of the key changes. + # * `event` {Object} + # * `newValue` the new value of the key + # * `oldValue` the prior value of the key. + # + # Returns a {Disposable} with the following keys on which you can call + # `.dispose()` to unsubscribe. + onDidChange: -> + if arguments.length is 1 + [callback] = arguments + else if arguments.length is 2 + [keyPath, callback] = arguments + else + [keyPath, options, callback] = arguments + scopeDescriptor = options.scope + + if scopeDescriptor? + @onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback) + else + @onDidChangeKeyPath(keyPath, callback) + + ### + Section: Managing Settings + ### + + # Essential: Retrieves the setting for the given key. + # + # ### Examples + # + # You might want to know what themes are enabled, so check `core.themes` + # + # ```coffee + # atom.config.get('core.themes') + # ``` + # + # With scope descriptors you can get settings within a specific editor + # scope. For example, you might want to know `editor.tabLength` for ruby + # files. + # + # ```coffee + # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + # ``` + # + # This setting in ruby files might be different than the global tabLength setting + # + # ```coffee + # atom.config.get('editor.tabLength') # => 4 + # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + # ``` + # + # You can get the language scope descriptor via + # {TextEditor::getRootScopeDescriptor}. This will get the setting specifically + # for the editor's language. + # + # ```coffee + # atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2 + # ``` + # + # Additionally, you can get the setting at the specific cursor position. + # + # ```coffee + # scopeDescriptor = @editor.getLastCursor().getScopeDescriptor() + # atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2 + # ``` + # + # * `keyPath` The {String} name of the key to retrieve. + # * `options` (optional) {Object} + # * `sources` (optional) {Array} of {String} source names. If provided, only + # values that were associated with these sources during {::set} will be used. + # * `excludeSources` (optional) {Array} of {String} source names. If provided, + # values that were associated with these sources during {::set} will not + # be used. + # * `scope` (optional) {ScopeDescriptor} describing a path from + # the root of the syntax tree to a token. Get one by calling + # {editor.getLastCursor().getScopeDescriptor()} + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) + # for more information. + # + # Returns the value from Atom's default settings, the user's configuration + # file in the type specified by the configuration schema. + get: -> + if arguments.length > 1 + if typeof arguments[0] is 'string' or not arguments[0]? + [keyPath, options] = arguments + {scope} = options + else + [keyPath] = arguments + + if scope? + value = @getRawScopedValue(scope, keyPath, options) + value ? @getRawValue(keyPath, options) + else + @getRawValue(keyPath, options) + + # Extended: Get some of the values for the given key-path, along with their + # associated scoped selector. + # + # * `keyPath` The {String} name of the key to retrieve + # * `options` (optional) {Object} see the `options` argument to {::get} + # + # Returns annnnn {Array} of {Object}s with the following keys: + # * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated + # * `value` The value for the key-path + getAll: (keyPath, options) -> + {scope, sources} = options if options? + result = [] + + if scope? + scopeDescriptor = ScopeDescriptor.fromObject(scope) + result = result.concat @scopedSettingsStore.getAll(scopeDescriptor.getScopeChain(), keyPath, options) + + if globalValue = @getRawValue(keyPath, options) + result.push(scopeSelector: '*', value: globalValue) + + result + + # Essential: Sets the value for a configuration setting. + # + # This value is stored in Atom's internal configuration file. + # + # ### Examples + # + # You might want to change the themes programmatically: + # + # ```coffee + # atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax']) + # ``` + # + # You can also set scoped settings. For example, you might want change the + # `editor.tabLength` only for ruby files. + # + # ```coffee + # atom.config.get('editor.tabLength') # => 4 + # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4 + # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 + # + # # Set ruby to 2 + # atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true + # + # # Notice it's only set to 2 in the case of ruby + # atom.config.get('editor.tabLength') # => 4 + # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2 + # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4 + # ``` + # + # * `keyPath` The {String} name of the key. + # * `value` The value of the setting. Passing `undefined` will revert the + # setting to the default value. + # * `options` (optional) {Object} + # * `scopeSelector` (optional) {String}. eg. '.source.ruby' + # See [the scopes docs](https://atom.io/docs/latest/behind-atom-scoped-settings-scopes-and-scope-descriptors) + # for more information. + # * `source` (optional) {String} The name of a file with which the setting + # is associated. Defaults to the user's config file. + # + # Returns a {Boolean} + # * `true` if the value was set. + # * `false` if the value was not able to be coerced to the type specified in the setting's schema. + set: -> + [keyPath, value, options] = arguments + scopeSelector = options?.scopeSelector + source = options?.source + shouldSave = options?.save ? true + + if source and not scopeSelector + throw new Error("::set with a 'source' and no 'sourceSelector' is not yet implemented!") + + source ?= @getUserConfigPath() + + unless value is undefined + try + value = @makeValueConformToSchema(keyPath, value) + catch e + return false + + if scopeSelector? + @setRawScopedValue(keyPath, value, source, scopeSelector) + else + @setRawValue(keyPath, value) + + @requestSave() if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors + true + + # Essential: Restore the setting at `keyPath` to its default value. + # + # * `keyPath` The {String} name of the key. + # * `options` (optional) {Object} + # * `scopeSelector` (optional) {String}. See {::set} + # * `source` (optional) {String}. See {::set} + unset: (keyPath, options) -> + {scopeSelector, source} = options ? {} + source ?= @getUserConfigPath() + + if scopeSelector? + if keyPath? + settings = @scopedSettingsStore.propertiesForSourceAndSelector(source, scopeSelector) + if getValueAtKeyPath(settings, keyPath)? + @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector) + setValueAtKeyPath(settings, keyPath, undefined) + settings = withoutEmptyObjects(settings) + @set(null, settings, {scopeSelector, source, priority: @priorityForSource(source)}) if settings? + @requestSave() + else + @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector) + @emitChangeEvent() + else + for scopeSelector of @scopedSettingsStore.propertiesForSource(source) + @unset(keyPath, {scopeSelector, source}) + if keyPath? and source is @getUserConfigPath() + @set(keyPath, getValueAtKeyPath(@defaultSettings, keyPath)) + + # Extended: Get an {Array} of all of the `source` {String}s with which + # settings have been added via {::set}. + getSources: -> + _.uniq(_.pluck(@scopedSettingsStore.propertySets, 'source')).sort() + + # Extended: Retrieve the schema for a specific key path. The schema will tell + # you what type the keyPath expects, and other metadata about the config + # option. + # + # * `keyPath` The {String} name of the key. + # + # Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`. + # Returns `null` when the keyPath has no schema specified, but is accessible + # from the root schema. + getSchema: (keyPath) -> + keys = splitKeyPath(keyPath) + schema = @schema + for key in keys + if schema.type is 'object' + childSchema = schema.properties?[key] + unless childSchema? + if isPlainObject(schema.additionalProperties) + childSchema = schema.additionalProperties + else if schema.additionalProperties is false + return null + else + return {type: 'any'} + else + return null + schema = childSchema + schema + + # Extended: Get the {String} path to the config file being used. + getUserConfigPath: -> + @configFilePath + + # Extended: Suppress calls to handler functions registered with {::onDidChange} + # and {::observe} for the duration of `callback`. After `callback` executes, + # handlers will be called once if the value for their key-path has changed. + # + # * `callback` {Function} to execute while suppressing calls to handlers. + transact: (callback) -> + @beginTransaction() + try + callback() + finally + @endTransaction() + + ### + Section: Internal methods used by core + ### + + # Private: Suppress calls to handler functions registered with {::onDidChange} + # and {::observe} for the duration of the {Promise} returned by `callback`. + # After the {Promise} is either resolved or rejected, handlers will be called + # once if the value for their key-path has changed. + # + # * `callback` {Function} that returns a {Promise}, which will be executed + # while suppressing calls to handlers. + # + # Returns a {Promise} that is either resolved or rejected according to the + # `{Promise}` returned by `callback`. If `callback` throws an error, a + # rejected {Promise} will be returned instead. + transactAsync: (callback) -> + @beginTransaction() + try + endTransaction = (fn) => (args...) => + @endTransaction() + fn(args...) + result = callback() + new Promise (resolve, reject) -> + result.then(endTransaction(resolve)).catch(endTransaction(reject)) + catch error + @endTransaction() + Promise.reject(error) + + beginTransaction: -> + @transactDepth++ + + endTransaction: -> + @transactDepth-- + @emitChangeEvent() + + pushAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = arrayValue.push(value) + @set(keyPath, arrayValue) + result + + unshiftAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = arrayValue.unshift(value) + @set(keyPath, arrayValue) + result + + removeAtKeyPath: (keyPath, value) -> + arrayValue = @get(keyPath) ? [] + result = _.remove(arrayValue, value) + @set(keyPath, arrayValue) + result + + setSchema: (keyPath, schema) -> + unless isPlainObject(schema) + throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!") + + unless typeof schema.type? + throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute") + + rootSchema = @schema + if keyPath + for key in splitKeyPath(keyPath) + rootSchema.type = 'object' + rootSchema.properties ?= {} + properties = rootSchema.properties + properties[key] ?= {} + rootSchema = properties[key] + + _.extend rootSchema, schema + @setDefaults(keyPath, @extractDefaultsFromSchema(schema)) + @setScopedDefaultsFromSchema(keyPath, schema) + @resetSettingsForSchemaChange() + + load: -> + @initializeConfigDirectory() + @loadUserConfig() + @observeUserConfig() + + ### + Section: Private methods managing the user's config file + ### + + initializeConfigDirectory: (done) -> + return if fs.existsSync(@configDirPath) or @shouldNotAccessFileSystem() + + fs.makeTreeSync(@configDirPath) + + queue = async.queue ({sourcePath, destinationPath}, callback) -> + fs.copy(sourcePath, destinationPath, callback) + queue.drain = done + + templateConfigDirPath = fs.resolve(@resourcePath, 'dot-atom') + onConfigDirFile = (sourcePath) => + relativePath = sourcePath.substring(templateConfigDirPath.length + 1) + destinationPath = path.join(@configDirPath, relativePath) + queue.push({sourcePath, destinationPath}) + fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true) + + loadUserConfig: -> + return if @shouldNotAccessFileSystem() + + try + unless fs.existsSync(@configFilePath) + fs.makeTreeSync(path.dirname(@configFilePath)) + CSON.writeFileSync(@configFilePath, {}) + catch error + @configFileHasErrors = true + @notifyFailure("Failed to initialize `#{path.basename(@configFilePath)}`", error.stack) + return + + try + unless @savePending + userConfig = CSON.readFileSync(@configFilePath) + @resetUserSettings(userConfig) + @configFileHasErrors = false + catch error + @configFileHasErrors = true + message = "Failed to load `#{path.basename(@configFilePath)}`" + + detail = if error.location? + # stack is the output from CSON in this case + error.stack + else + # message will be EACCES permission denied, et al + error.message + + @notifyFailure(message, detail) + + observeUserConfig: -> + return if @shouldNotAccessFileSystem() + + try + @watchSubscription ?= pathWatcher.watch @configFilePath, (eventType) => + @requestLoad() if eventType is 'change' and @watchSubscription? + catch error + @notifyFailure """ + Unable to watch path: `#{path.basename(@configFilePath)}`. Make sure you have permissions to + `#{@configFilePath}`. On linux there are currently problems with watch + sizes. See [this document][watches] for more info. + [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path + """ + + unobserveUserConfig: -> + @watchSubscription?.close() + @watchSubscription = null + + notifyFailure: (errorMessage, detail) -> + @notificationManager?.addError(errorMessage, {detail, dismissable: true}) + + save: -> + return if @shouldNotAccessFileSystem() + + allSettings = {'*': @settings} + allSettings = _.extend allSettings, @scopedSettingsStore.propertiesForSource(@getUserConfigPath()) + allSettings = sortObject(allSettings) + try + CSON.writeFileSync(@configFilePath, allSettings) + catch error + message = "Failed to save `#{path.basename(@configFilePath)}`" + detail = error.message + @notifyFailure(message, detail) + + ### + Section: Private methods managing global settings + ### + + resetUserSettings: (newSettings) -> + unless isPlainObject(newSettings) + @settings = {} + @emitChangeEvent() + return + + if newSettings.global? + newSettings['*'] = newSettings.global + delete newSettings.global + + if newSettings['*']? + scopedSettings = newSettings + newSettings = newSettings['*'] + delete scopedSettings['*'] + @resetUserScopedSettings(scopedSettings) + + @transact => + @settings = {} + @set(key, value, save: false) for key, value of newSettings + return + + getRawValue: (keyPath, options) -> + unless options?.excludeSources?.indexOf(@getUserConfigPath()) >= 0 + value = getValueAtKeyPath(@settings, keyPath) + unless options?.sources?.length > 0 + defaultValue = getValueAtKeyPath(@defaultSettings, keyPath) + + if value? + value = @deepClone(value) + @deepDefaults(value, defaultValue) if isPlainObject(value) and isPlainObject(defaultValue) + else + value = @deepClone(defaultValue) + + value + + setRawValue: (keyPath, value) -> + defaultValue = getValueAtKeyPath(@defaultSettings, keyPath) + if _.isEqual(defaultValue, value) + if keyPath? + deleteValueAtKeyPath(@settings, keyPath) + else + @settings = null + else + if keyPath? + setValueAtKeyPath(@settings, keyPath, value) + else + @settings = value + @emitChangeEvent() + + observeKeyPath: (keyPath, options, callback) -> + callback(@get(keyPath)) + @onDidChangeKeyPath keyPath, (event) -> callback(event.newValue) + + onDidChangeKeyPath: (keyPath, callback) -> + oldValue = @get(keyPath) + @emitter.on 'did-change', => + newValue = @get(keyPath) + unless _.isEqual(oldValue, newValue) + event = {oldValue, newValue} + oldValue = newValue + callback(event) + + isSubKeyPath: (keyPath, subKeyPath) -> + return false unless keyPath? and subKeyPath? + pathSubTokens = splitKeyPath(subKeyPath) + pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length) + _.isEqual(pathTokens, pathSubTokens) + + setRawDefault: (keyPath, value) -> + setValueAtKeyPath(@defaultSettings, keyPath, value) + @emitChangeEvent() + + setDefaults: (keyPath, defaults) -> + if defaults? and isPlainObject(defaults) + keys = splitKeyPath(keyPath) + for key, childValue of defaults + continue unless defaults.hasOwnProperty(key) + @setDefaults(keys.concat([key]).join('.'), childValue) + else + try + defaults = @makeValueConformToSchema(keyPath, defaults) + @setRawDefault(keyPath, defaults) + catch e + console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + return + + deepClone: (object) -> + if object instanceof Color + object.clone() + else if _.isArray(object) + object.map (value) => @deepClone(value) + else if isPlainObject(object) + _.mapObject object, (key, value) => [key, @deepClone(value)] + else + object + + deepDefaults: (target) -> + result = target + i = 0 + while ++i < arguments.length + object = arguments[i] + if isPlainObject(result) and isPlainObject(object) + for key in Object.keys(object) + result[key] = @deepDefaults(result[key], object[key]) + else + if not result? + result = @deepClone(object) + result + + # `schema` will look something like this + # + # ```coffee + # type: 'string' + # default: 'ok' + # scopes: + # '.source.js': + # default: 'omg' + # ``` + setScopedDefaultsFromSchema: (keyPath, schema) -> + if schema.scopes? and isPlainObject(schema.scopes) + scopedDefaults = {} + for scope, scopeSchema of schema.scopes + continue unless scopeSchema.hasOwnProperty('default') + scopedDefaults[scope] = {} + setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default) + @scopedSettingsStore.addProperties('schema-default', scopedDefaults) + + if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties) + keys = splitKeyPath(keyPath) + for key, childValue of schema.properties + continue unless schema.properties.hasOwnProperty(key) + @setScopedDefaultsFromSchema(keys.concat([key]).join('.'), childValue) + + return + + extractDefaultsFromSchema: (schema) -> + if schema.default? + schema.default + else if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties) + defaults = {} + properties = schema.properties or {} + defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties + defaults + + makeValueConformToSchema: (keyPath, value, options) -> + if options?.suppressException + try + @makeValueConformToSchema(keyPath, value) + catch e + undefined + else + unless (schema = @getSchema(keyPath))? + throw new Error("Illegal key path #{keyPath}") if schema is false + @constructor.executeSchemaEnforcers(keyPath, value, schema) + + # When the schema is changed / added, there may be values set in the config + # that do not conform to the schema. This will reset make them conform. + resetSettingsForSchemaChange: (source=@getUserConfigPath()) -> + @transact => + @settings = @makeValueConformToSchema(null, @settings, suppressException: true) + priority = @priorityForSource(source) + selectorsAndSettings = @scopedSettingsStore.propertiesForSource(source) + @scopedSettingsStore.removePropertiesForSource(source) + for scopeSelector, settings of selectorsAndSettings + settings = @makeValueConformToSchema(null, settings, suppressException: true) + @setRawScopedValue(null, settings, source, scopeSelector) + return + + ### + Section: Private Scoped Settings + ### + + priorityForSource: (source) -> + if source is @getUserConfigPath() + 1000 + else + 0 + + emitChangeEvent: -> + @emitter.emit 'did-change' unless @transactDepth > 0 + + resetUserScopedSettings: (newScopedSettings) -> + source = @getUserConfigPath() + priority = @priorityForSource(source) + @scopedSettingsStore.removePropertiesForSource(source) + + for scopeSelector, settings of newScopedSettings + settings = @makeValueConformToSchema(null, settings, suppressException: true) + validatedSettings = {} + validatedSettings[scopeSelector] = withoutEmptyObjects(settings) + @scopedSettingsStore.addProperties(source, validatedSettings, {priority}) if validatedSettings[scopeSelector]? + + @emitChangeEvent() + + setRawScopedValue: (keyPath, value, source, selector, options) -> + if keyPath? + newValue = {} + setValueAtKeyPath(newValue, keyPath, value) + value = newValue + + settingsBySelector = {} + settingsBySelector[selector] = value + @scopedSettingsStore.addProperties(source, settingsBySelector, priority: @priorityForSource(source)) + @emitChangeEvent() + + getRawScopedValue: (scopeDescriptor, keyPath, options) -> + scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor) + @scopedSettingsStore.getPropertyValue(scopeDescriptor.getScopeChain(), keyPath, options) + + observeScopedKeyPath: (scope, keyPath, callback) -> + callback(@get(keyPath, {scope})) + @onDidChangeScopedKeyPath scope, keyPath, (event) -> callback(event.newValue) + + onDidChangeScopedKeyPath: (scope, keyPath, callback) -> + oldValue = @get(keyPath, {scope}) + @emitter.on 'did-change', => + newValue = @get(keyPath, {scope}) + unless _.isEqual(oldValue, newValue) + event = {oldValue, newValue} + oldValue = newValue + callback(event) + +# Base schema enforcers. These will coerce raw input into the specified type, +# and will throw an error when the value cannot be coerced. Throwing the error +# will indicate that the value should not be set. +# +# Enforcers are run from most specific to least. For a schema with type +# `integer`, all the enforcers for the `integer` type will be run first, in +# order of specification. Then the `*` enforcers will be run, in order of +# specification. +Config.addSchemaEnforcers + 'any': + coerce: (keyPath, value, schema) -> + value + + 'integer': + coerce: (keyPath, value, schema) -> + value = parseInt(value) + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value) + value + + 'number': + coerce: (keyPath, value, schema) -> + value = parseFloat(value) + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value) + value + + 'boolean': + coerce: (keyPath, value, schema) -> + switch typeof value + when 'string' + if value.toLowerCase() is 'true' + true + else if value.toLowerCase() is 'false' + false + else + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") + when 'boolean' + value + else + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'") + + 'string': + validate: (keyPath, value, schema) -> + unless typeof value is 'string' + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string") + value + + validateMaximumLength: (keyPath, value, schema) -> + if typeof schema.maximumLength is 'number' and value.length > schema.maximumLength + value.slice(0, schema.maximumLength) + else + value + + 'null': + # null sort of isnt supported. It will just unset in this case + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value in [undefined, null] + value + + 'object': + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value) + return value unless schema.properties? + + defaultChildSchema = null + allowsAdditionalProperties = true + if isPlainObject(schema.additionalProperties) + defaultChildSchema = schema.additionalProperties + if schema.additionalProperties is false + allowsAdditionalProperties = false + + newValue = {} + for prop, propValue of value + childSchema = schema.properties[prop] ? defaultChildSchema + if childSchema? + try + newValue[prop] = @executeSchemaEnforcers(pushKeyPath(keyPath, prop), propValue, childSchema) + catch error + console.warn "Error setting item in object: #{error.message}" + else if allowsAdditionalProperties + # Just pass through un-schema'd values + newValue[prop] = propValue + else + console.warn "Illegal object key: #{keyPath}.#{prop}" + + newValue + + 'array': + coerce: (keyPath, value, schema) -> + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value) + itemSchema = schema.items + if itemSchema? + newValue = [] + for item in value + try + newValue.push @executeSchemaEnforcers(keyPath, item, itemSchema) + catch error + console.warn "Error setting item in array: #{error.message}" + newValue + else + value + + 'color': + coerce: (keyPath, value, schema) -> + color = Color.parse(value) + unless color? + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a color") + color + + '*': + coerceMinimumAndMaximum: (keyPath, value, schema) -> + return value unless typeof value is 'number' + if schema.minimum? and typeof schema.minimum is 'number' + value = Math.max(value, schema.minimum) + if schema.maximum? and typeof schema.maximum is 'number' + value = Math.min(value, schema.maximum) + value + + validateEnum: (keyPath, value, schema) -> + possibleValues = schema.enum + + if Array.isArray(possibleValues) + possibleValues = possibleValues.map (value) -> + if value.hasOwnProperty('value') then value.value else value + + return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length + + for possibleValue in possibleValues + # Using `isEqual` for possibility of placing enums on array and object schemas + return value if _.isEqual(possibleValue, value) + + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}") + +isPlainObject = (value) -> + _.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value) and not (value instanceof Color) + +sortObject = (value) -> + return value unless isPlainObject(value) + result = {} + for key in Object.keys(value).sort() + result[key] = sortObject(value[key]) + result + +withoutEmptyObjects = (object) -> + resultObject = undefined + if isPlainObject(object) + for key, value of object + newValue = withoutEmptyObjects(value) + if newValue? + resultObject ?= {} + resultObject[key] = newValue + else + resultObject = object + resultObject diff --git a/spec/fixtures/dummy-atom/src/workspace.coffee b/spec/fixtures/dummy-atom/src/workspace.coffee new file mode 100644 index 0000000000..80d16b5d5a --- /dev/null +++ b/spec/fixtures/dummy-atom/src/workspace.coffee @@ -0,0 +1,1083 @@ +_ = require 'underscore-plus' +url = require 'url' +path = require 'path' +{join} = path +{Emitter, Disposable, CompositeDisposable} = require 'event-kit' +fs = require 'fs-plus' +DefaultDirectorySearcher = require './default-directory-searcher' +Model = require './model' +TextEditor = require './text-editor' +PaneContainer = require './pane-container' +Pane = require './pane' +Panel = require './panel' +PanelContainer = require './panel-container' +Task = require './task' + +# Essential: Represents the state of the user interface for the entire window. +# An instance of this class is available via the `atom.workspace` global. +# +# Interact with this object to open files, be notified of current and future +# editors, and manipulate panes. To add panels, use {Workspace::addTopPanel} +# and friends. +# +# * `editor` {TextEditor} the new editor +# +module.exports = +class Workspace extends Model + constructor: (params) -> + super + + { + @packageManager, @config, @project, @grammarRegistry, @notificationManager, + @clipboard, @viewRegistry, @grammarRegistry, @applicationDelegate, @assert, + @deserializerManager + } = params + + @emitter = new Emitter + @openers = [] + @destroyedItemURIs = [] + + @paneContainer = new PaneContainer({@config, @applicationDelegate, @notificationManager, @deserializerManager}) + @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) + + @defaultDirectorySearcher = new DefaultDirectorySearcher() + @consumeServices(@packageManager) + + # One cannot simply .bind here since it could be used as a component with + # Etch, in which case it'd be `new`d. And when it's `new`d, `this` is always + # the newly created object. + realThis = this + @buildTextEditor = -> Workspace.prototype.buildTextEditor.apply(realThis, arguments) + + @panelContainers = + top: new PanelContainer({location: 'top'}) + left: new PanelContainer({location: 'left'}) + right: new PanelContainer({location: 'right'}) + bottom: new PanelContainer({location: 'bottom'}) + header: new PanelContainer({location: 'header'}) + footer: new PanelContainer({location: 'footer'}) + modal: new PanelContainer({location: 'modal'}) + + @subscribeToEvents() + + reset: (@packageManager) -> + @emitter.dispose() + @emitter = new Emitter + + @paneContainer.destroy() + panelContainer.destroy() for panelContainer in @panelContainers + + @paneContainer = new PaneContainer({@config, @applicationDelegate, @notificationManager, @deserializerManager}) + @paneContainer.onDidDestroyPaneItem(@didDestroyPaneItem) + + @panelContainers = + top: new PanelContainer({location: 'top'}) + left: new PanelContainer({location: 'left'}) + right: new PanelContainer({location: 'right'}) + bottom: new PanelContainer({location: 'bottom'}) + header: new PanelContainer({location: 'header'}) + footer: new PanelContainer({location: 'footer'}) + modal: new PanelContainer({location: 'modal'}) + + @originalFontSize = null + @openers = [] + @destroyedItemURIs = [] + @consumeServices(@packageManager) + + subscribeToEvents: -> + @subscribeToActiveItem() + @subscribeToFontSize() + + consumeServices: ({serviceHub}) -> + @directorySearchers = [] + serviceHub.consume( + 'atom.directory-searcher', + '^0.1.0', + (provider) => @directorySearchers.unshift(provider)) + + # Called by the Serializable mixin during serialization. + serialize: -> + deserializer: 'Workspace' + paneContainer: @paneContainer.serialize() + packagesWithActiveGrammars: @getPackageNamesWithActiveGrammars() + destroyedItemURIs: @destroyedItemURIs.slice() + + deserialize: (state, deserializerManager) -> + for packageName in state.packagesWithActiveGrammars ? [] + @packageManager.getLoadedPackage(packageName)?.loadGrammarsSync() + if state.destroyedItemURIs? + @destroyedItemURIs = state.destroyedItemURIs + @paneContainer.deserialize(state.paneContainer, deserializerManager) + + getPackageNamesWithActiveGrammars: -> + packageNames = [] + addGrammar = ({includedGrammarScopes, packageName}={}) => + return unless packageName + # Prevent cycles + return if packageNames.indexOf(packageName) isnt -1 + + packageNames.push(packageName) + for scopeName in includedGrammarScopes ? [] + addGrammar(@grammarRegistry.grammarForScopeName(scopeName)) + return + + editors = @getTextEditors() + addGrammar(editor.getGrammar()) for editor in editors + + if editors.length > 0 + for grammar in @grammarRegistry.getGrammars() when grammar.injectionSelector + addGrammar(grammar) + + _.uniq(packageNames) + + subscribeToActiveItem: -> + @updateWindowTitle() + @updateDocumentEdited() + @project.onDidChangePaths @updateWindowTitle + + @observeActivePaneItem (item) => + @updateWindowTitle() + @updateDocumentEdited() + + @activeItemSubscriptions?.dispose() + @activeItemSubscriptions = new CompositeDisposable + + if typeof item?.onDidChangeTitle is 'function' + titleSubscription = item.onDidChangeTitle(@updateWindowTitle) + else if typeof item?.on is 'function' + titleSubscription = item.on('title-changed', @updateWindowTitle) + unless typeof titleSubscription?.dispose is 'function' + titleSubscription = new Disposable => item.off('title-changed', @updateWindowTitle) + + if typeof item?.onDidChangeModified is 'function' + modifiedSubscription = item.onDidChangeModified(@updateDocumentEdited) + else if typeof item?.on? is 'function' + modifiedSubscription = item.on('modified-status-changed', @updateDocumentEdited) + unless typeof modifiedSubscription?.dispose is 'function' + modifiedSubscription = new Disposable => item.off('modified-status-changed', @updateDocumentEdited) + + @activeItemSubscriptions.add(titleSubscription) if titleSubscription? + @activeItemSubscriptions.add(modifiedSubscription) if modifiedSubscription? + + # Updates the application's title and proxy icon based on whichever file is + # open. + updateWindowTitle: => + appName = 'Atom' + projectPaths = @project.getPaths() ? [] + if item = @getActivePaneItem() + itemPath = item.getPath?() + itemTitle = item.getLongTitle?() ? item.getTitle?() + projectPath = _.find projectPaths, (projectPath) -> + itemPath is projectPath or itemPath?.startsWith(projectPath + path.sep) + itemTitle ?= "untitled" + projectPath ?= projectPaths[0] + + titleParts = [] + if item? and projectPath? + titleParts.push itemTitle, projectPath + representedPath = itemPath ? projectPath + else if projectPath? + titleParts.push projectPath + representedPath = projectPath + else + titleParts.push itemTitle + representedPath = "" + + unless process.platform is 'darwin' + titleParts.push appName + + document.title = titleParts.join(" \u2014 ") + @applicationDelegate.setRepresentedFilename(representedPath) + + # On OS X, fades the application window's proxy icon when the current file + # has been modified. + updateDocumentEdited: => + modified = @getActivePaneItem()?.isModified?() ? false + @applicationDelegate.setWindowDocumentEdited(modified) + + ### + Section: Event Subscription + ### + + # Essential: Invoke the given callback with all current and future text + # editors in the workspace. + # + # * `callback` {Function} to be called with current and future text editors. + # * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + # of subscription or that is added at some later time. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors: (callback) -> + callback(textEditor) for textEditor in @getTextEditors() + @onDidAddTextEditor ({textEditor}) -> callback(textEditor) + + # Essential: Invoke the given callback with all current and future panes items + # in the workspace. + # + # * `callback` {Function} to be called with current and future pane items. + # * `item` An item that is present in {::getPaneItems} at the time of + # subscription or that is added at some later time. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePaneItems: (callback) -> @paneContainer.observePaneItems(callback) + + # Essential: Invoke the given callback when the active pane item changes. + # + # Because observers are invoked synchronously, it's important not to perform + # any expensive operations via this method. Consider + # {::onDidStopChangingActivePaneItem} to delay operations until after changes + # stop occurring. + # + # * `callback` {Function} to be called when the active pane item changes. + # * `item` The active pane item. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePaneItem: (callback) -> + @paneContainer.onDidChangeActivePaneItem(callback) + + # Essential: Invoke the given callback when the active pane item stops + # changing. + # + # Observers are called asynchronously 100ms after the last active pane item + # change. Handling changes here rather than in the synchronous + # {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly + # changing or closing tabs and ensures critical UI feedback, like changing the + # highlighted tab, gets priority over work that can be done asynchronously. + # + # * `callback` {Function} to be called when the active pane item stopts + # changing. + # * `item` The active pane item. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidStopChangingActivePaneItem: (callback) -> + @paneContainer.onDidStopChangingActivePaneItem(callback) + + # Essential: Invoke the given callback with the current active pane item and + # with all future active pane items in the workspace. + # + # * `callback` {Function} to be called when the active pane item changes. + # * `item` The current active pane item. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePaneItem: (callback) -> @paneContainer.observeActivePaneItem(callback) + + # Essential: Invoke the given callback whenever an item is opened. Unlike + # {::onDidAddPaneItem}, observers will be notified for items that are already + # present in the workspace when they are reopened. + # + # * `callback` {Function} to be called whenever an item is opened. + # * `event` {Object} with the following keys: + # * `uri` {String} representing the opened URI. Could be `undefined`. + # * `item` The opened item. + # * `pane` The pane in which the item was opened. + # * `index` The index of the opened item on its pane. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidOpen: (callback) -> + @emitter.on 'did-open', callback + + # Extended: Invoke the given callback when a pane is added to the workspace. + # + # * `callback` {Function} to be called panes are added. + # * `event` {Object} with the following keys: + # * `pane` The added pane. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPane: (callback) -> @paneContainer.onDidAddPane(callback) + + # Extended: Invoke the given callback before a pane is destroyed in the + # workspace. + # + # * `callback` {Function} to be called before panes are destroyed. + # * `event` {Object} with the following keys: + # * `pane` The pane to be destroyed. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onWillDestroyPane: (callback) -> @paneContainer.onWillDestroyPane(callback) + + # Extended: Invoke the given callback when a pane is destroyed in the + # workspace. + # + # * `callback` {Function} to be called panes are destroyed. + # * `event` {Object} with the following keys: + # * `pane` The destroyed pane. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidDestroyPane: (callback) -> @paneContainer.onDidDestroyPane(callback) + + # Extended: Invoke the given callback with all current and future panes in the + # workspace. + # + # * `callback` {Function} to be called with current and future panes. + # * `pane` A {Pane} that is present in {::getPanes} at the time of + # subscription or that is added at some later time. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observePanes: (callback) -> @paneContainer.observePanes(callback) + + # Extended: Invoke the given callback when the active pane changes. + # + # * `callback` {Function} to be called when the active pane changes. + # * `pane` A {Pane} that is the current return value of {::getActivePane}. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidChangeActivePane: (callback) -> @paneContainer.onDidChangeActivePane(callback) + + # Extended: Invoke the given callback with the current active pane and when + # the active pane changes. + # + # * `callback` {Function} to be called with the current and future active# + # panes. + # * `pane` A {Pane} that is the current return value of {::getActivePane}. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeActivePane: (callback) -> @paneContainer.observeActivePane(callback) + + # Extended: Invoke the given callback when a pane item is added to the + # workspace. + # + # * `callback` {Function} to be called when pane items are added. + # * `event` {Object} with the following keys: + # * `item` The added pane item. + # * `pane` {Pane} containing the added item. + # * `index` {Number} indicating the index of the added item in its pane. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddPaneItem: (callback) -> @paneContainer.onDidAddPaneItem(callback) + + # Extended: Invoke the given callback when a pane item is about to be + # destroyed, before the user is prompted to save it. + # + # * `callback` {Function} to be called before pane items are destroyed. + # * `event` {Object} with the following keys: + # * `item` The item to be destroyed. + # * `pane` {Pane} containing the item to be destroyed. + # * `index` {Number} indicating the index of the item to be destroyed in + # its pane. + # + # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onWillDestroyPaneItem: (callback) -> @paneContainer.onWillDestroyPaneItem(callback) + + # Extended: Invoke the given callback when a pane item is destroyed. + # + # * `callback` {Function} to be called when pane items are destroyed. + # * `event` {Object} with the following keys: + # * `item` The destroyed item. + # * `pane` {Pane} containing the destroyed item. + # * `index` {Number} indicating the index of the destroyed item in its + # pane. + # + # Returns a {Disposable} on which `.dispose` can be called to unsubscribe. + onDidDestroyPaneItem: (callback) -> @paneContainer.onDidDestroyPaneItem(callback) + + # Extended: Invoke the given callback when a text editor is added to the + # workspace. + # + # * `callback` {Function} to be called panes are added. + # * `event` {Object} with the following keys: + # * `textEditor` {TextEditor} that was added. + # * `pane` {Pane} containing the added text editor. + # * `index` {Number} indicating the index of the added text editor in its + # pane. + # + # Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + onDidAddTextEditor: (callback) -> + @onDidAddPaneItem ({item, pane, index}) -> + callback({textEditor: item, pane, index}) if item instanceof TextEditor + + ### + Section: Opening + ### + + # Essential: Opens the given URI in Atom asynchronously. + # If the URI is already open, the existing item for that URI will be + # activated. If no URI is given, or no registered opener can open + # the URI, a new empty {TextEditor} will be created. + # + # * `uri` (optional) A {String} containing a URI. + # * `options` (optional) {Object} + # * `initialLine` A {Number} indicating which row to move the cursor to + # initially. Defaults to `0`. + # * `initialColumn` A {Number} indicating which column to move the cursor to + # initially. Defaults to `0`. + # * `split` Either 'left', 'right', 'up' or 'down'. + # If 'left', the item will be opened in leftmost pane of the current active pane's row. + # If 'right', the item will be opened in the rightmost pane of the current active pane's row. If only one pane exists in the row, a new pane will be created. + # If 'up', the item will be opened in topmost pane of the current active pane's column. + # If 'down', the item will be opened in the bottommost pane of the current active pane's column. If only one pane exists in the column, a new pane will be created. + # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + # containing pane. Defaults to `true`. + # * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + # on containing pane. Defaults to `true`. + # * `pending` A {Boolean} indicating whether or not the item should be opened + # in a pending state. Existing pending items in a pane are replaced with + # new pending items when they are opened. + # * `searchAllPanes` A {Boolean}. If `true`, the workspace will attempt to + # activate an existing item for the given URI on any pane. + # If `false`, only the active pane will be searched for + # an existing item for the same URI. Defaults to `false`. + # + # + # This is a new line. + # Returns a {Promise} that resolves to the {TextEditor} for the file URI. + open: (uri, options={}) -> + searchAllPanes = options.searchAllPanes + split = options.split + uri = @project.resolvePath(uri) + + if not atom.config.get('core.allowPendingPaneItems') + options.pending = false + + # Avoid adding URLs as recent documents to work-around this Spotlight crash: + # https://github.com/atom/atom/issues/10071 + if uri? and not url.parse(uri).protocol? + @applicationDelegate.addRecentDocument(uri) + + pane = @paneContainer.paneForURI(uri) if searchAllPanes + pane ?= switch split + when 'left' + @getActivePane().findLeftmostSibling() + when 'right' + @getActivePane().findOrCreateRightmostSibling() + when 'up' + @getActivePane().findTopmostSibling() + when 'down' + @getActivePane().findOrCreateBottommostSibling() + else + @getActivePane() + + @openURIInPane(uri, pane, options) + + # Open Atom's license in the active pane. + openLicense: -> + @open(path.join(process.resourcesPath, 'LICENSE.md')) + + # Synchronously open the given URI in the active pane. **Only use this method + # in specs. Calling this in production code will block the UI thread and + # everyone will be mad at you.** + # + # * `uri` A {String} containing a URI. + # * `options` An optional options {Object} + # * `initialLine` A {Number} indicating which row to move the cursor to + # initially. Defaults to `0`. + # * `initialColumn` A {Number} indicating which column to move the cursor to + # initially. Defaults to `0`. + # * `activatePane` A {Boolean} indicating whether to call {Pane::activate} on + # the containing pane. Defaults to `true`. + # * `activateItem` A {Boolean} indicating whether to call {Pane::activateItem} + # on containing pane. Defaults to `true`. + openSync: (uri='', options={}) -> + {initialLine, initialColumn} = options + activatePane = options.activatePane ? true + activateItem = options.activateItem ? true + + uri = @project.resolvePath(uri) + item = @getActivePane().itemForURI(uri) + if uri + item ?= opener(uri, options) for opener in @getOpeners() when not item + item ?= @project.openSync(uri, {initialLine, initialColumn}) + + @getActivePane().activateItem(item) if activateItem + @itemOpened(item) + @getActivePane().activate() if activatePane + item + + openURIInPane: (uri, pane, options={}) -> + activatePane = options.activatePane ? true + activateItem = options.activateItem ? true + + if uri? + if item = pane.itemForURI(uri) + pane.clearPendingItem() if not options.pending and pane.getPendingItem() is item + item ?= opener(uri, options) for opener in @getOpeners() when not item + + try + item ?= @openTextFile(uri, options) + catch error + switch error.code + when 'CANCELLED' + return Promise.resolve() + when 'EACCES' + @notificationManager.addWarning("Permission denied '#{error.path}'") + return Promise.resolve() + when 'EPERM', 'EBUSY', 'ENXIO', 'EIO', 'ENOTCONN', 'UNKNOWN', 'ECONNRESET', 'EINVAL', 'EMFILE', 'ENOTDIR', 'EAGAIN' + @notificationManager.addWarning("Unable to open '#{error.path ? uri}'", detail: error.message) + return Promise.resolve() + else + throw error + + Promise.resolve(item) + .then (item) => + return item if pane.isDestroyed() + + @itemOpened(item) + pane.activateItem(item, {pending: options.pending}) if activateItem + pane.activate() if activatePane + + initialLine = initialColumn = 0 + unless Number.isNaN(options.initialLine) + initialLine = options.initialLine + unless Number.isNaN(options.initialColumn) + initialColumn = options.initialColumn + if initialLine >= 0 or initialColumn >= 0 + item.setCursorBufferPosition?([initialLine, initialColumn]) + + index = pane.getActiveItemIndex() + @emitter.emit 'did-open', {uri, pane, item, index} + item + + openTextFile: (uri, options) -> + filePath = @project.resolvePath(uri) + + if filePath? + try + fs.closeSync(fs.openSync(filePath, 'r')) + catch error + # allow ENOENT errors to create an editor for paths that dont exist + throw error unless error.code is 'ENOENT' + + fileSize = fs.getSizeSync(filePath) + + largeFileMode = fileSize >= 2 * 1048576 # 2MB + if fileSize >= 20 * 1048576 # 20MB + choice = @applicationDelegate.confirm + message: 'Atom will be unresponsive during the loading of very large files.' + detailedMessage: "Do you still want to load this file?" + buttons: ["Proceed", "Cancel"] + if choice is 1 + error = new Error + error.code = 'CANCELLED' + throw error + + @project.bufferForPath(filePath, options).then (buffer) => + editor = @buildTextEditor(_.extend({buffer, largeFileMode}, options)) + disposable = atom.textEditors.add(editor) + editor.onDidDestroy -> disposable.dispose() + editor + + # Public: Returns a {Boolean} that is `true` if `object` is a `TextEditor`. + # + # * `object` An {Object} you want to perform the check against. + isTextEditor: (object) -> + object instanceof TextEditor + + # Extended: Create a new text editor. + # + # Returns a {TextEditor}. + buildTextEditor: (params) -> + params = _.extend({ + @config, @notificationManager, @packageManager, @clipboard, @viewRegistry, + @grammarRegistry, @project, @assert, @applicationDelegate + }, params) + new TextEditor(params) + + # Public: Asynchronously reopens the last-closed item's URI if it hasn't already been + # reopened. + # + # Returns a {Promise} that is resolved when the item is opened + reopenItem: -> + if uri = @destroyedItemURIs.pop() + @open(uri) + else + Promise.resolve() + + # Public: Register an opener for a uri. + # + # An {TextEditor} will be used if no openers return a value. + # + # ## Examples + # + # ```coffee + # atom.workspace.addOpener (uri) -> + # if path.extname(uri) is '.toml' + # return new TomlEditor(uri) + # ``` + # + # * `opener` A {Function} to be called when a path is being opened. + # + # Returns a {Disposable} on which `.dispose()` can be called to remove the + # opener. + # + # Note that the opener will be called if and only if the URI is not already open + # in the current pane. The searchAllPanes flag expands the search from the + # current pane to all panes. If you wish to open a view of a different type for + # a file that is already open, consider changing the protocol of the URI. For + # example, perhaps you wish to preview a rendered version of the file `/foo/bar/baz.quux` + # that is already open in a text editor view. You could signal this by calling + # {Workspace::open} on the URI `quux-preview://foo/bar/baz.quux`. Then your opener + # can check the protocol for quux-preview and only handle those URIs that match. + addOpener: (opener) -> + @openers.push(opener) + new Disposable => _.remove(@openers, opener) + + getOpeners: -> + @openers + + ### + Section: Pane Items + ### + + # Essential: Get all pane items in the workspace. + # + # Returns an {Array} of items. + getPaneItems: -> + @paneContainer.getPaneItems() + + # Essential: Get the active {Pane}'s active item. + # + # Returns an pane item {Object}. + getActivePaneItem: -> + @paneContainer.getActivePaneItem() + + # Essential: Get all text editors in the workspace. + # + # Returns an {Array} of {TextEditor}s. + getTextEditors: -> + @getPaneItems().filter (item) -> item instanceof TextEditor + + # Essential: Get the active item if it is an {TextEditor}. + # + # Returns an {TextEditor} or `undefined` if the current active item is not an + # {TextEditor}. + getActiveTextEditor: -> + activeItem = @getActivePaneItem() + activeItem if activeItem instanceof TextEditor + + # Save all pane items. + saveAll: -> + @paneContainer.saveAll() + + confirmClose: (options) -> + @paneContainer.confirmClose(options) + + # Save the active pane item. + # + # If the active pane item currently has a URI according to the item's + # `.getURI` method, calls `.save` on the item. Otherwise + # {::saveActivePaneItemAs} # will be called instead. This method does nothing + # if the active item does not implement a `.save` method. + saveActivePaneItem: -> + @getActivePane().saveActiveItem() + + # Prompt the user for a path and save the active pane item to it. + # + # Opens a native dialog where the user selects a path on disk, then calls + # `.saveAs` on the item with the selected path. This method does nothing if + # the active item does not implement a `.saveAs` method. + saveActivePaneItemAs: -> + @getActivePane().saveActiveItemAs() + + # Destroy (close) the active pane item. + # + # Removes the active pane item and calls the `.destroy` method on it if one is + # defined. + destroyActivePaneItem: -> + @getActivePane().destroyActiveItem() + + ### + Section: Panes + ### + + # Extended: Get all panes in the workspace. + # + # Returns an {Array} of {Pane}s. + getPanes: -> + @paneContainer.getPanes() + + # Extended: Get the active {Pane}. + # + # Returns a {Pane}. + getActivePane: -> + @paneContainer.getActivePane() + + # Extended: Make the next pane active. + activateNextPane: -> + @paneContainer.activateNextPane() + + # Extended: Make the previous pane active. + activatePreviousPane: -> + @paneContainer.activatePreviousPane() + + # Extended: Get the first {Pane} with an item for the given URI. + # + # * `uri` {String} uri + # + # Returns a {Pane} or `undefined` if no pane exists for the given URI. + paneForURI: (uri) -> + @paneContainer.paneForURI(uri) + + # Extended: Get the {Pane} containing the given item. + # + # * `item` Item the returned pane contains. + # + # Returns a {Pane} or `undefined` if no pane exists for the given item. + paneForItem: (item) -> + @paneContainer.paneForItem(item) + + # Destroy (close) the active pane. + destroyActivePane: -> + @getActivePane()?.destroy() + + # Close the active pane item, or the active pane if it is empty, + # or the current window if there is only the empty root pane. + closeActivePaneItemOrEmptyPaneOrWindow: -> + if @getActivePaneItem()? + @destroyActivePaneItem() + else if @getPanes().length > 1 + @destroyActivePane() + else if @config.get('core.closeEmptyWindows') + atom.close() + + # Increase the editor font size by 1px. + increaseFontSize: -> + @config.set("editor.fontSize", @config.get("editor.fontSize") + 1) + + # Decrease the editor font size by 1px. + decreaseFontSize: -> + fontSize = @config.get("editor.fontSize") + @config.set("editor.fontSize", fontSize - 1) if fontSize > 1 + + # Restore to the window's original editor font size. + resetFontSize: -> + if @originalFontSize + @config.set("editor.fontSize", @originalFontSize) + + subscribeToFontSize: -> + @config.onDidChange 'editor.fontSize', ({oldValue}) => + @originalFontSize ?= oldValue + + # Removes the item's uri from the list of potential items to reopen. + itemOpened: (item) -> + if typeof item.getURI is 'function' + uri = item.getURI() + else if typeof item.getUri is 'function' + uri = item.getUri() + + if uri? + _.remove(@destroyedItemURIs, uri) + + # Adds the destroyed item's uri to the list of items to reopen. + didDestroyPaneItem: ({item}) => + if typeof item.getURI is 'function' + uri = item.getURI() + else if typeof item.getUri is 'function' + uri = item.getUri() + + if uri? + @destroyedItemURIs.push(uri) + + # Called by Model superclass when destroyed + destroyed: -> + @paneContainer.destroy() + @activeItemSubscriptions?.dispose() + + + ### + Section: Panels + + Panels are used to display UI related to an editor window. They are placed at one of the four + edges of the window: left, right, top or bottom. If there are multiple panels on the same window + edge they are stacked in order of priority: higher priority is closer to the center, lower + priority towards the edge. + + *Note:* If your panel changes its size throughout its lifetime, consider giving it a higher + priority, allowing fixed size panels to be closer to the edge. This allows control targets to + remain more static for easier targeting by users that employ mice or trackpads. (See + [atom/atom#4834](https://github.com/atom/atom/issues/4834) for discussion.) + ### + + # Essential: Get an {Array} of all the panel items at the bottom of the editor window. + getBottomPanels: -> + @getPanels('bottom') + + # Essential: Adds a panel item to the bottom of the editor window. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addBottomPanel: (options) -> + @addPanel('bottom', options) + + # Essential: Get an {Array} of all the panel items to the left of the editor window. + getLeftPanels: -> + @getPanels('left') + + # Essential: Adds a panel item to the left of the editor window. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addLeftPanel: (options) -> + @addPanel('left', options) + + # Essential: Get an {Array} of all the panel items to the right of the editor window. + getRightPanels: -> + @getPanels('right') + + # Essential: Adds a panel item to the right of the editor window. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addRightPanel: (options) -> + @addPanel('right', options) + + # Essential: Get an {Array} of all the panel items at the top of the editor window. + getTopPanels: -> + @getPanels('top') + + # Essential: Adds a panel item to the top of the editor window above the tabs. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addTopPanel: (options) -> + @addPanel('top', options) + + # Essential: Get an {Array} of all the panel items in the header. + getHeaderPanels: -> + @getPanels('header') + + # Essential: Adds a panel item to the header. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addHeaderPanel: (options) -> + @addPanel('header', options) + + # Essential: Get an {Array} of all the panel items in the footer. + getFooterPanels: -> + @getPanels('footer') + + # Essential: Adds a panel item to the footer. + # + # * `options` {Object} + # * `item` Your panel content. It can be DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # latter. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addFooterPanel: (options) -> + @addPanel('footer', options) + + # Essential: Get an {Array} of all the modal panel items + getModalPanels: -> + @getPanels('modal') + + # Essential: Adds a panel item as a modal dialog. + # + # * `options` {Object} + # * `item` Your panel content. It can be a DOM element, a jQuery element, or + # a model with a view registered via {ViewRegistry::addViewProvider}. We recommend the + # model option. See {ViewRegistry::addViewProvider} for more information. + # * `visible` (optional) {Boolean} false if you want the panel to initially be hidden + # (default: true) + # * `priority` (optional) {Number} Determines stacking order. Lower priority items are + # forced closer to the edges of the window. (default: 100) + # + # Returns a {Panel} + addModalPanel: (options={}) -> + @addPanel('modal', options) + + # Essential: Returns the {Panel} associated with the given item. Returns + # `null` when the item has no panel. + # + # * `item` Item the panel contains + panelForItem: (item) -> + for location, container of @panelContainers + panel = container.panelForItem(item) + return panel if panel? + null + + getPanels: (location) -> + @panelContainers[location].getPanels() + + addPanel: (location, options) -> + options ?= {} + @panelContainers[location].addPanel(new Panel(options)) + + ### + Section: Searching and Replacing + ### + + # Public: Performs a search across all files in the workspace. + # + # * `regex` {RegExp} to search with. + # * `options` (optional) {Object} + # * `paths` An {Array} of glob patterns to search within. + # * `onPathsSearched` (optional) {Function} to be periodically called + # with number of paths searched. + # * `iterator` {Function} callback on each file found. + # + # Returns a {Promise} with a `cancel()` method that will cancel all + # of the underlying searches that were started as part of this scan. + scan: (regex, options={}, iterator) -> + if _.isFunction(options) + iterator = options + options = {} + + # Find a searcher for every Directory in the project. Each searcher that is matched + # will be associated with an Array of Directory objects in the Map. + directoriesForSearcher = new Map() + for directory in @project.getDirectories() + searcher = @defaultDirectorySearcher + for directorySearcher in @directorySearchers + if directorySearcher.canSearchDirectory(directory) + searcher = directorySearcher + break + directories = directoriesForSearcher.get(searcher) + unless directories + directories = [] + directoriesForSearcher.set(searcher, directories) + directories.push(directory) + + # Define the onPathsSearched callback. + if _.isFunction(options.onPathsSearched) + # Maintain a map of directories to the number of search results. When notified of a new count, + # replace the entry in the map and update the total. + onPathsSearchedOption = options.onPathsSearched + totalNumberOfPathsSearched = 0 + numberOfPathsSearchedForSearcher = new Map() + onPathsSearched = (searcher, numberOfPathsSearched) -> + oldValue = numberOfPathsSearchedForSearcher.get(searcher) + if oldValue + totalNumberOfPathsSearched -= oldValue + numberOfPathsSearchedForSearcher.set(searcher, numberOfPathsSearched) + totalNumberOfPathsSearched += numberOfPathsSearched + onPathsSearchedOption(totalNumberOfPathsSearched) + else + onPathsSearched = -> + + # Kick off all of the searches and unify them into one Promise. + allSearches = [] + directoriesForSearcher.forEach (directories, searcher) => + searchOptions = + inclusions: options.paths or [] + includeHidden: true + excludeVcsIgnores: @config.get('core.excludeVcsIgnoredPaths') + exclusions: @config.get('core.ignoredNames') + follow: @config.get('core.followSymlinks') + didMatch: (result) => + iterator(result) unless @project.isPathModified(result.filePath) + didError: (error) -> + iterator(null, error) + didSearchPaths: (count) -> onPathsSearched(searcher, count) + directorySearcher = searcher.search(directories, regex, searchOptions) + allSearches.push(directorySearcher) + searchPromise = Promise.all(allSearches) + + for buffer in @project.getBuffers() when buffer.isModified() + filePath = buffer.getPath() + continue unless @project.contains(filePath) + matches = [] + buffer.scan regex, (match) -> matches.push match + iterator {filePath, matches} if matches.length > 0 + + # Make sure the Promise that is returned to the client is cancelable. To be consistent + # with the existing behavior, instead of cancel() rejecting the promise, it should + # resolve it with the special value 'cancelled'. At least the built-in find-and-replace + # package relies on this behavior. + isCancelled = false + cancellablePromise = new Promise (resolve, reject) -> + onSuccess = -> + if isCancelled + resolve('cancelled') + else + resolve(null) + + onFailure = -> + promise.cancel() for promise in allSearches + reject() + + searchPromise.then(onSuccess, onFailure) + cancellablePromise.cancel = -> + isCancelled = true + # Note that cancelling all of the members of allSearches will cause all of the searches + # to resolve, which causes searchPromise to resolve, which is ultimately what causes + # cancellablePromise to resolve. + promise.cancel() for promise in allSearches + + # Although this method claims to return a `Promise`, the `ResultsPaneView.onSearch()` + # method in the find-and-replace package expects the object returned by this method to have a + # `done()` method. Include a done() method until find-and-replace can be updated. + cancellablePromise.done = (onSuccessOrFailure) -> + cancellablePromise.then(onSuccessOrFailure, onSuccessOrFailure) + cancellablePromise + + # Public: Performs a replace across all the specified files in the project. + # + # * `regex` A {RegExp} to search with. + # * `replacementText` {String} to replace all matches of regex with. + # * `filePaths` An {Array} of file path strings to run the replace on. + # * `iterator` A {Function} callback on each file with replacements: + # * `options` {Object} with keys `filePath` and `replacements`. + # + # Returns a {Promise}. + replace: (regex, replacementText, filePaths, iterator) -> + new Promise (resolve, reject) => + openPaths = (buffer.getPath() for buffer in @project.getBuffers()) + outOfProcessPaths = _.difference(filePaths, openPaths) + + inProcessFinished = not openPaths.length + outOfProcessFinished = not outOfProcessPaths.length + checkFinished = -> + resolve() if outOfProcessFinished and inProcessFinished + + unless outOfProcessFinished.length + flags = 'g' + flags += 'i' if regex.ignoreCase + + task = Task.once require.resolve('./replace-handler'), outOfProcessPaths, regex.source, flags, replacementText, -> + outOfProcessFinished = true + checkFinished() + + task.on 'replace:path-replaced', iterator + task.on 'replace:file-error', (error) -> iterator(null, error) + + for buffer in @project.getBuffers() + continue unless buffer.getPath() in filePaths + replacements = buffer.replace(regex, replacementText, iterator) + iterator({filePath: buffer.getPath(), replacements}) if replacements + + inProcessFinished = true + checkFinished() diff --git a/spec/helpers.js b/spec/helpers.js index 52a5dd3b98..d3d0bf7584 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -1,26 +1,16 @@ /** @babel */ +import {GitRepositoryAsync} from 'atom' import fs from 'fs-plus' import path from 'path' import temp from 'temp' -import FileDiff from '../lib/file-diff' -import {createObjectsFromString} from '../lib/common' - -function readFileSync (filePath) { - return fs.readFileSync(path.join(__dirname, filePath), 'utf-8') -} - -function createFileDiffsFromString (str) { - return createObjectsFromString(str, 'FILE', FileDiff) -} - -function createFileDiffsFromPath (filePath) { - let fileStr = readFileSync(filePath) - return createFileDiffsFromString(fileStr) -} +import GitService from '../lib/git-service' +import FileListStore from '../lib/file-list-store' +import FileListViewModel from '../lib/file-list-view-model' +import DiffViewModel from '../lib/diff-view-model' // Lifted from atom/atom -function buildMouseEvent (type, properties) { +export function buildMouseEvent (type, properties) { if (properties.detail == null) { properties.detail = 1 } @@ -56,17 +46,35 @@ function buildMouseEvent (type, properties) { temp.track() -function copyRepository (name = 'test-repo') { - const workingDirPath = temp.mkdirSync('git-prototype-fixture') +export function copyRepository (name = 'test-repo') { + const workingDirPath = temp.mkdirSync('git-fixture-') fs.copySync(path.join(__dirname, 'fixtures', name), workingDirPath) fs.renameSync(path.join(workingDirPath, 'git.git'), path.join(workingDirPath, '.git')) return fs.realpathSync(workingDirPath) } -module.exports = { - createFileDiffsFromString, - createFileDiffsFromPath, - readFileSync, - buildMouseEvent, - copyRepository +export async function createFileListStore (name) { + const repoPath = copyRepository(name) + if (!name) { + // If we're using the default fixture, put some changes in it. + fs.writeFileSync(path.join(repoPath, 'README.md'), 'who can make the sun rise') + fs.writeFileSync(path.join(repoPath, 'README2.md'), 'me too') + } + + const gitService = new GitService(GitRepositoryAsync.open(repoPath)) + + const fileListStore = new FileListStore(gitService) + await fileListStore.loadFromGit() + return fileListStore +} + +export async function createFileListViewModel (name) { + const fileListStore = await createFileListStore(name) + return new FileListViewModel(fileListStore, fileListStore.gitService) +} + +export async function createDiffViewModel (pathName, repoName) { + const fileListViewModel = await createFileListViewModel(repoName) + const gitService = fileListViewModel.gitService + return new DiffViewModel({pathName, fileListViewModel, gitService}) } diff --git a/spec/hunk-line-spec.js b/spec/hunk-line-spec.js deleted file mode 100644 index 65db86e5ce..0000000000 --- a/spec/hunk-line-spec.js +++ /dev/null @@ -1,129 +0,0 @@ -/** @babel */ - -import {GitRepositoryAsync} from 'atom' -import path from 'path' -import fs from 'fs-plus' -import FileList from '../lib/file-list' -import HunkLine from '../lib/hunk-line' -import GitService from '../lib/git-service' -import {waitsForPromise, runs} from './async-spec-helpers' -import {copyRepository} from './helpers' - -describe('HunkLine', () => { - let fileList = null - let repoPath = null - let gitService = null - - const fileName = 'README.md' - let filePath = null - - beforeEach(() => { - repoPath = copyRepository() - - filePath = path.join(repoPath, fileName) - fs.writeFileSync(filePath, "i'm new here\n") - - gitService = new GitService(GitRepositoryAsync.open(repoPath)) - - fileList = new FileList([], gitService, {stageOnChange: true}) - waitsForPromise(() => fileList.loadFromGitUtils()) - }) - - it('roundtrips toString and HunkLine.fromString', () => { - let line = ' 89 --- - # default, the type it should be, etc. A simple example:' - let hunkLine = HunkLine.fromString(line) - expect(hunkLine.toString()).toEqual(line) - - line = ' 435 432 scopeDescriptor = options.scope' - hunkLine = HunkLine.fromString(line) - expect(hunkLine.toString()).toEqual(line) - - line = ' --- 434 + # Some new linesssss' - hunkLine = HunkLine.fromString(line) - expect(hunkLine.toString()).toEqual(line) - - line = '✓ --- 434 + # Some new linesssss' - hunkLine = HunkLine.fromString(line) - expect(hunkLine.toString()).toEqual(line) - - line = ' 85 85 #' - hunkLine = HunkLine.fromString(line) - expect(hunkLine.toString()).toEqual(line) - }) - - it('emits and event when HunkLine::fromString() is called', () => { - let changeHandler = jasmine.createSpy() - let line = ' 89 --- - # default, the type it should be, etc. A simple example:' - let hunkLine = new HunkLine() - - hunkLine.onDidChange(changeHandler) - hunkLine.fromString(line) - expect(changeHandler).toHaveBeenCalled() - expect(hunkLine.toString()).toEqual(line) - }) - - describe('staging', () => { - let getFirstLine - let changeStagednessAndWait - - beforeEach(() => { - getFirstLine = () => { - const diff = fileList.getFileFromPathName(fileName) - expect(diff).not.toBeUndefined() - - const hunks = diff.getHunks() - const hunk = hunks[0] - expect(hunk).not.toBeUndefined() - - const lines = hunk.getLines() - return lines[0] - } - - changeStagednessAndWait = (stage) => { - const changeHandler = jasmine.createSpy() - runs(() => { - const line = getFirstLine() - expect(line.isStaged()).toEqual(!stage) - - fileList.onDidUserChange(changeHandler) - - if (stage) { - line.stage() - } else { - line.unstage() - } - }) - waitsFor(() => changeHandler.callCount === 1) - } - }) - - describe('.stage()', () => { - it('stages', () => { - changeStagednessAndWait(true) - runs(async () => { - await fileList.loadFromGitUtils() - - const line = getFirstLine() - expect(line.isStaged()).toEqual(true) - }) - }) - }) - - describe('.unstage()', () => { - it('unstages', () => { - changeStagednessAndWait(true) - runs(async () => { - await fileList.loadFromGitUtils() - }) - - changeStagednessAndWait(false) - runs(async () => { - await fileList.loadFromGitUtils() - - const line = getFirstLine() - expect(line.isStaged()).toEqual(false) - }) - }) - }) - }) -}) diff --git a/spec/main-spec.js b/spec/main-spec.js index 76fc7aacac..90575cbf39 100644 --- a/spec/main-spec.js +++ b/spec/main-spec.js @@ -1,30 +1,21 @@ /** @babel */ -import path from 'path' import DiffViewModel from '../lib/diff-view-model' -import {createFileDiffsFromPath} from './helpers' +import {copyRepository} from './helpers' import {it, beforeEach, afterEach} from './async-spec-helpers' -function createFileDiffs (filePath) { - return createFileDiffsFromPath(filePath) -} - describe('Git Main Module', function () { - let gitPackage, gitPackageModule, fileDiffs, fileList + let gitPackage, gitPackageModule beforeEach(async function () { jasmine.useRealClock() - fileDiffs = createFileDiffs('fixtures/two-file-diff.txt') + const repoPath = copyRepository('dummy-atom') + atom.project.setPaths([repoPath]) gitPackage = atom.packages.loadPackage('git') gitPackageModule = gitPackage.mainModule - fileList = gitPackageModule.getFileListViewModel().getFileList() - spyOn(fileList, 'loadFromGitUtils').andCallFake(() => { - fileList.setFiles(fileDiffs) - }) - await atom.packages.activatePackage('git') }) @@ -35,12 +26,11 @@ describe('Git Main Module', function () { }) it('opens the diff for the open file when "git:open-file-diff" is triggered', async function () { - atom.project.setPaths([path.join(__dirname)]) - await atom.workspace.open('fixtures/two-file-diff.txt') + await atom.workspace.open('src/config.coffee') await gitPackageModule.openDiffForActiveEditor() let paneItem = atom.workspace.getActivePaneItem() expect(paneItem instanceof DiffViewModel).toBe(true) expect(paneItem.pending).toBe(true) - expect(paneItem.uri).toContain('two-file-diff.txt') + expect(paneItem.uri).toContain('config.coffee') }) })