From a6170c0f822946fdff5a9d3f5335fc911af0048d Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 11:17:43 -0400 Subject: [PATCH 01/20] Holy :fire: --- lib/diff-hunk.js | 74 ++------------- lib/diff-view-model.js | 77 ++++++++++----- lib/event-transactor.js | 72 --------------- lib/file-diff.js | 116 +++++------------------ lib/file-list-component.js | 5 +- lib/file-list-view-model.js | 48 ++++++---- lib/file-list.js | 149 +----------------------------- lib/git-package.js | 12 +-- lib/git-service.js | 12 +++ lib/hunk-line-component.js | 1 - lib/hunk-line.js | 54 ++--------- spec/diff-view-model-spec.js | 2 +- spec/file-diff-spec.js | 2 +- spec/file-diff-view-model-spec.js | 2 +- spec/file-list-view-model-spec.js | 2 +- spec/hunk-line-spec.js | 2 +- 16 files changed, 154 insertions(+), 476 deletions(-) delete mode 100644 lib/event-transactor.js diff --git a/lib/diff-hunk.js b/lib/diff-hunk.js index 37c11750b1..31a65eda16 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,16 +71,6 @@ 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') return `HUNK ${this.getHeader()}\n${lines}` @@ -135,12 +89,8 @@ export default class DiffHunk { lines.push(line) } - this.syncState(() => { - this.transactor.transact(() => { - this.setHeader(header) - this.setLines(lines) - }) - }) + this.setHeader(header) + this.setLines(lines) } static fromString (hunkStr): DiffHunk { @@ -160,11 +110,7 @@ export default class DiffHunk { 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-view-model.js b/lib/diff-view-model.js index b977568d35..7ff57de71b 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -1,6 +1,7 @@ /* @flow */ import {Emitter, CompositeDisposable} from 'atom' +import GitService from './git-service' import DiffSelection from './diff-selection' import DiffHunk from './diff-hunk' import {ifNotNull} from './common' @@ -12,6 +13,7 @@ import type FileDiffViewModel from './file-diff-view-model' import type {SelectionMode} from './diff-selection' export default class DiffViewModel { + gitService: GitService; uri: string; pathName: string; deserializer: string; @@ -23,19 +25,17 @@ export default class DiffViewModel { selections: Array; selectionSubscriptions: CompositeDisposable; selectionState: Object; - subscriptions: CompositeDisposable; - 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, fileDiffViewModel, fileList, pending, gitService}: {uri: string, pathName: string, deserializer: string, fileDiffViewModel: FileDiffViewModel, fileList: FileList, pending: boolean, gitService: GitService}) { // TODO: I kind of hate that the URI and deserializer string are in this // model. Worth creating another model on top of this? + 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))) // mode is stored here _and_ in the selection. Mouse interactions store // line-mode selections, but moving the keyboard interactions into line mode @@ -46,7 +46,6 @@ export default class DiffViewModel { } destroy () { - this.subscriptions.dispose() this.selectionSubscriptions.dispose() } @@ -54,14 +53,14 @@ export default class DiffViewModel { return this.emitter.on('did-change', callback) } - onDidTerminatePendingState (callback: Function): Disposable { - return this.emitter.on('did-terminate-pending-state', callback) - } - emitChangeEvent () { this.emitter.emit('did-change') } + onDidTerminatePendingState (callback: Function): Disposable { + return this.emitter.on('did-terminate-pending-state', callback) + } + setSelection (selection: DiffSelection) { if (this.selectionSubscriptions) { this.selectionSubscriptions.dispose() @@ -238,24 +237,60 @@ export default class DiffViewModel { this.emitChangeEvent() } - toggleSelectedLinesStageStatus () { + async toggleSelectedLinesStageStatus (): Promise { + const linesByHunk = {} for (let fileDiffIndex in this.selectionState) { fileDiffIndex = parseInt(fileDiffIndex, 10) let fileDiff = this.getFileDiffs()[fileDiffIndex] + let stage = null 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) + for (let diffHunkIndex in selectedHunkIndices) { + diffHunkIndex = parseInt(diffHunkIndex, 10) + let selectedHunk = fileDiff.getHunks()[diffHunkIndex] + + const hunkLines = selectedHunk.getLines() + + let linesToStage = [] + + let selectedLineIndices = selectedHunkIndices[diffHunkIndex] + if (selectedLineIndices instanceof Set) { + for (let lineIndex of selectedLineIndices.values()) { + const line = hunkLines[lineIndex] + linesToStage.push(line) } + } else if (selectedLineIndices) { + linesToStage = hunkLines + } + + if (!stage) { + // TODO: This isn't terribly right. We're toggling all lines based on + // the state of the first line. + const firstLine = linesToStage[0] + stage = !firstLine.isStaged() + } + + for (let line of linesToStage) { + line.setIsStaged(stage) + } + + if (stage) { + linesByHunk[selectedHunk.toString()] = {linesToStage} + } else { + linesByHunk[selectedHunk.toString()] = {linesToUnstage: linesToStage} } - }) + } + + stage = stage || true + const patches = await this.gitService.calculatePatchTexts(linesByHunk, stage) + await this.stagePatches(fileDiff, patches, stage) + } + } + + async stagePatches (fileDiff: FileDiff, patches: Array, stage: boolean): Promise { + if (stage) { + return this.gitService.stagePatches(fileDiff, patches) + } else { + return this.gitService.unstagePatches(fileDiff, patches) } } 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..7866a0e375 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 {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,20 +185,6 @@ 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') return `FILE ${this.getNewPathName()} - ${this.getChangeStatus()} - ${this.getStageStatus()}\n${hunks}` @@ -256,14 +198,10 @@ export default class FileDiff { // $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) - }) - }) + this.setNewPathName(pathName) + this.setOldPathName(pathName) + this.setChangeStatus(changeStatus) + this.setHunks(hunks) } static fromString (diffStr) { @@ -305,18 +243,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..3a8fc7beda 100644 --- a/lib/file-list-component.js +++ b/lib/file-list-component.js @@ -35,6 +35,7 @@ export default class FileListComponent { acceptProps ({fileListViewModel}: FileListComponentProps): Promise { this.fileList = fileListViewModel.getFileList() this.fileListViewModel = fileListViewModel + this.commitBoxViewModel = fileListViewModel.commitBoxViewModel let updatePromise = Promise.resolve() @@ -46,8 +47,8 @@ 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.fileListViewModel.onDidUpdate(() => etch.update(this))) + this.subscriptions.add(this.fileListViewModel.onSelectionChanged(() => this.selectionDidChange())) this.subscriptions.add(atom.commands.add(this.element, { 'core:move-up': () => this.fileListViewModel.moveSelectionUp(), diff --git a/lib/file-list-view-model.js b/lib/file-list-view-model.js index dfa804ad80..08f33eec6c 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -1,6 +1,7 @@ /* @flow */ import {Emitter, CompositeDisposable} from 'atom' +import GitService from './git-service' import CommitBoxViewModel from './commit-box-view-model' import type {Disposable} from 'atom' @@ -8,20 +9,21 @@ import type FileList from './file-list' import type FileDiff from './file-diff' export default class FileListViewModel { + gitService: GitService; fileList: FileList; selectedIndex: number; emitter: Emitter; commitBoxViewModel: CommitBoxViewModel; subscriptions: CompositeDisposable; - constructor (fileList: FileList) { + constructor (fileList: FileList, gitService: GitService) { + this.gitService = gitService this.fileList = fileList 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 () { @@ -29,28 +31,28 @@ export default class FileListViewModel { } async update (): Promise { - return Promise - .all([ - this.commitBoxViewModel.update(), - this.fileList.loadFromGitUtils() - ]) - .then(() => { return }) + await Promise.all([ + this.commitBoxViewModel.update(), + this.fileList.loadFromGitUtils() + ]) + + this.emitUpdateEvent() } - onDidChange (callback: Function): Disposable { - return this.emitter.on('did-change', callback) + onDidUpdate (callback: Function): Disposable { + return this.emitter.on('did-update', callback) } - emitChangeEvent () { - this.emitter.emit('did-change') + emitUpdateEvent () { + this.emitter.emit('did-update') } - 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 { @@ -71,7 +73,7 @@ export default class FileListViewModel { setSelectedIndex (index: number) { this.selectedIndex = index - this.emitChangeEvent() + this.emitSelectionChangedEvent() } getSelectedFile (): FileDiff { @@ -99,8 +101,16 @@ export default class FileListViewModel { this.setSelectedIndex(Math.min(this.selectedIndex + 1, filesLengthIndex)) } - toggleSelectedFilesStageStatus () { + toggleSelectedFilesStageStatus (): Promise { const file = this.getSelectedFile() + let stagePromise = null + if (file.getStageStatus() === 'unstaged') { + stagePromise = this.gitService.stageFile(file) + } else { + stagePromise = this.gitService.unstageFile(file) + } + file.toggleStageStatus() + return stagePromise.then(_ => { return }) } } diff --git a/lib/file-list.js b/lib/file-list.js index 85162524bf..84430f46dd 100644 --- a/lib/file-list.js +++ b/lib/file-list.js @@ -1,143 +1,22 @@ /* @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. + constructor (files: Array, gitService: GitService) { 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 { @@ -154,16 +33,10 @@ export default class FileList { } 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 { @@ -224,20 +97,6 @@ export default class FileList { 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') } @@ -252,7 +111,6 @@ export default class FileList { 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() @@ -290,9 +148,6 @@ export default class FileList { files.push(fileDiff) } - this.transactor.transact(() => { - this.setFiles(files) - }) - this.isSyncing = false + this.setFiles(files) } } diff --git a/lib/git-package.js b/lib/git-package.js index 0989399e3a..676dc54190 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -67,7 +67,7 @@ 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()) @@ -77,7 +77,7 @@ export default class GitPackage { promises.push(statusBarTile.getItem().update()) } - return Promise.all(promises).then(() => { return }) + await Promise.all(promises) } serialize (): GitState { @@ -94,9 +94,8 @@ 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 fileList = new FileList([], this.getGitService()) + this.fileListViewModel = new FileListViewModel(fileList, this.getGitService()) this.subscriptions.add(this.fileListViewModel.onDidCommit(() => this.onDidCommit())) } @@ -202,12 +201,13 @@ export default class GitPackage { const fileDiff = this.getFileListViewModel().getDiffForPathName(pathName) const fileDiffViewModel = new FileDiffViewModel(fileDiff) return new DiffViewModel({ + gitService: this.getGitService(), uri, pathName, fileDiffViewModel, pending: !!pending, deserializer: 'GitDiffPaneItem', - fileList: new FileList([fileDiff], this.getGitService(), {stageOnChange: false}) + fileList: new FileList([fileDiff], this.getGitService()) }) } } diff --git a/lib/git-service.js b/lib/git-service.js index c143a46ed2..6aeceac10d 100644 --- a/lib/git-service.js +++ b/lib/git-service.js @@ -173,6 +173,18 @@ export default class GitService { return this.statuses[path] } + stageFile (file: FileDiff): Promise { + const fileName = file.getOldFileName() || file.getNewFileName() + // $FlowSilence + return this.stagePath(fileName) + } + + unstageFile (file: FileDiff): Promise { + const fileName = file.getOldFileName() || file.getNewFileName() + // $FlowSilence + return this.unstagePath(fileName) + } + stagePath (path: string): Promise { return this.stageAllPaths([path]) } 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..35e7006ac3 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() || '---' @@ -100,16 +66,12 @@ export default class HunkLine { 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.content = content || '' + this.lineOrigin = lineOrigin + this.setIsStaged(staged === '✓') - this.emitChangeEvent() - }) + this.oldLineNumber = oldLine === '---' ? null : parseInt(oldLine, 10) + this.newLineNumber = newLine === '---' ? null : parseInt(newLine, 10) } static fromString (str: string): HunkLine { @@ -136,10 +98,6 @@ export default class HunkLine { } else { this.staged = false } - - this.syncState(() => { - this.emitChangeEvent() - }) } static fromGitUtilsObject (obj): HunkLine { diff --git a/spec/diff-view-model-spec.js b/spec/diff-view-model-spec.js index 0b9f2ba84f..6ccafdf97a 100644 --- a/spec/diff-view-model-spec.js +++ b/spec/diff-view-model-spec.js @@ -9,7 +9,7 @@ import {createFileDiffsFromPath, copyRepository} from './helpers' function createDiffs (filePath, gitService) { let fileDiffs = createFileDiffsFromPath(filePath) - let viewModel = new DiffViewModel({fileList: new FileList(fileDiffs, gitService, {stageOnChange: true})}) + let viewModel = new DiffViewModel({fileList: new FileList(fileDiffs, gitService)}) spyOn(viewModel.fileList, 'stageLines') return viewModel } diff --git a/spec/file-diff-spec.js b/spec/file-diff-spec.js index d3d8c12555..cdf7218eb4 100644 --- a/spec/file-diff-spec.js +++ b/spec/file-diff-spec.js @@ -31,7 +31,7 @@ describe('FileDiff', function () { const gitService = new GitService(GitRepositoryAsync.open(repoPath)) - fileList = new FileList([], gitService, {stageOnChange: true}) + fileList = new FileList([], gitService) filePath = path.join(repoPath, fileName) diff --git a/spec/file-diff-view-model-spec.js b/spec/file-diff-view-model-spec.js index ca410bf299..b9b3ea0c09 100644 --- a/spec/file-diff-view-model-spec.js +++ b/spec/file-diff-view-model-spec.js @@ -10,7 +10,7 @@ import {copyRepository} from './helpers' import {waitsForPromise} from './async-spec-helpers' async function createDiffViewModel (gitService, fileName) { - const fileList = new FileList([], gitService, {stageOnChange: false}) + const fileList = new FileList([], gitService) await fileList.loadFromGitUtils() const fileDiff = fileList.getFileFromPathName(fileName) expect(fileDiff).toBeDefined() diff --git a/spec/file-list-view-model-spec.js b/spec/file-list-view-model-spec.js index e9f83cc042..0c7da44ce3 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -8,7 +8,7 @@ import {createFileDiffsFromPath, copyRepository} from './helpers' function createFileList (filePath, gitService) { let fileDiffs = createFileDiffsFromPath(filePath) - let fileList = new FileList(fileDiffs, gitService, {stageOnChange: false}) + let fileList = new FileList(fileDiffs, gitService) return new FileListViewModel(fileList) } diff --git a/spec/hunk-line-spec.js b/spec/hunk-line-spec.js index 65db86e5ce..c1b2a09287 100644 --- a/spec/hunk-line-spec.js +++ b/spec/hunk-line-spec.js @@ -25,7 +25,7 @@ describe('HunkLine', () => { gitService = new GitService(GitRepositoryAsync.open(repoPath)) - fileList = new FileList([], gitService, {stageOnChange: true}) + fileList = new FileList([], gitService) waitsForPromise(() => fileList.loadFromGitUtils()) }) From 526033b39ceff72a6fac9ce7fbd458d2d3884695 Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 11:48:12 -0400 Subject: [PATCH 02/20] :fire: down the old path-based staging. --- lib/file-list.js | 5 +- lib/git-service.js | 117 +++++++++++++++++---------------------------- 2 files changed, 47 insertions(+), 75 deletions(-) diff --git a/lib/file-list.js b/lib/file-list.js index 84430f46dd..0a03056a92 100644 --- a/lib/file-list.js +++ b/lib/file-list.js @@ -112,8 +112,7 @@ export default class FileList { async loadFromGitUtils (): Promise { let files = [] - // FIXME: for now, we need to get the stati for the diff stuff to work. :/ - await this.gitService.getStatuses() + const statuses = await this.gitService.getStatuses() let unifiedDiffs = await this.gitService.getDiffs('all') // TODO: It's a bummer these lines happen sequentially @@ -136,7 +135,7 @@ export default class FileList { for (let diff of unifiedDiffs) { // $FlowFixMe - const statusFile = this.gitService.getStatus(diff.oldFile().path()) || this.gitService.getStatus(diff.newFile().path()) + const statusFile = statuses[diff.oldFile().path()] || statuses[diff.newFile().path()] // $FlowFixMe let fileDiff = this.getOrCreateFileFromPathName(diff.newFile().path()) diff --git a/lib/git-service.js b/lib/git-service.js index 6aeceac10d..fa35aaff98 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,93 +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] - } - stageFile (file: FileDiff): Promise { - const fileName = file.getOldFileName() || file.getNewFileName() - // $FlowSilence - return this.stagePath(fileName) - } - - unstageFile (file: FileDiff): Promise { - const fileName = file.getOldFileName() || file.getNewFileName() - // $FlowSilence - return this.unstagePath(fileName) - } - - stagePath (path: string): Promise { - return this.stageAllPaths([path]) - } - - stageAllPaths (paths: Array): 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 { From a8f7e90989a5d7c549ea900be0a467f284ae8fbc Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 12:05:31 -0400 Subject: [PATCH 03/20] Get file staging working again. --- lib/file-list-component.js | 37 +++++++++++++++---------------------- lib/file-list-view-model.js | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/lib/file-list-component.js b/lib/file-list-component.js index 3a8fc7beda..edeca4d510 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,7 @@ export default class FileListComponent { } acceptProps ({fileListViewModel}: FileListComponentProps): Promise { - this.fileList = fileListViewModel.getFileList() - this.fileListViewModel = fileListViewModel + this.viewModel = fileListViewModel this.commitBoxViewModel = fileListViewModel.commitBoxViewModel @@ -47,15 +43,15 @@ export default class FileListComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.fileListViewModel.onDidUpdate(() => etch.update(this))) - this.subscriptions.add(this.fileListViewModel.onSelectionChanged(() => this.selectionDidChange())) + this.subscriptions.add(this.viewModel.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', { @@ -70,7 +66,7 @@ export default class FileListComponent { } async selectionDidChange (): Promise { - await this.fileListViewModel.openSelectedFileDiff() + await this.viewModel.openSelectedFileDiff() this.focus() return etch.update(this) @@ -81,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)} @@ -106,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) { @@ -115,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-view-model.js b/lib/file-list-view-model.js index 08f33eec6c..f1daf38908 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -2,6 +2,7 @@ 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' @@ -77,13 +78,17 @@ export default class FileListViewModel { } getSelectedFile (): FileDiff { - return this.fileList.getFiles()[this.selectedIndex] + return this.getFileAtIndex(this.selectedIndex) } openSelectedFileDiff (): Promise { return this.fileList.openFileDiff(this.getSelectedFile()) } + getFileAtIndex (index: number): FileDiff { + return this.fileList.getFiles()[index] + } + getDiffForPathName (name: string): FileDiff { return this.getFileList().getOrCreateFileFromPathName(name) } @@ -101,16 +106,25 @@ export default class FileListViewModel { this.setSelectedIndex(Math.min(this.selectedIndex + 1, filesLengthIndex)) } - toggleSelectedFilesStageStatus (): Promise { - 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() return stagePromise.then(_ => { return }) } + + toggleSelectedFilesStageStatus (): Promise { + const file = this.getSelectedFile() + return this.toggleFileStageStatus(file) + } + + getFileDiffViewModels (): Array { + return this.fileList.getFiles().map(fileDiff => new FileDiffViewModel(fileDiff)) + } } From 942757ce8dc8e94d4a0b83423b7ea29760ad1c4c Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 14:34:02 -0400 Subject: [PATCH 04/20] Get line staging working again. --- lib/diff-view-model.js | 12 +++++++----- lib/git-package.js | 6 ++++-- lib/git-service.js | 3 +-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index 7ff57de71b..7ba2d01b21 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -242,7 +242,7 @@ export default class DiffViewModel { for (let fileDiffIndex in this.selectionState) { fileDiffIndex = parseInt(fileDiffIndex, 10) let fileDiff = this.getFileDiffs()[fileDiffIndex] - let stage = null + let stage = true let selectedHunkIndices = this.selectionState[fileDiffIndex] for (let diffHunkIndex in selectedHunkIndices) { diffHunkIndex = parseInt(diffHunkIndex, 10) @@ -262,7 +262,7 @@ export default class DiffViewModel { linesToStage = hunkLines } - if (!stage) { + if (diffHunkIndex === 0) { // TODO: This isn't terribly right. We're toggling all lines based on // the state of the first line. const firstLine = linesToStage[0] @@ -274,16 +274,18 @@ export default class DiffViewModel { } if (stage) { - linesByHunk[selectedHunk.toString()] = {linesToStage} + linesByHunk[selectedHunk.toString()] = {linesToStage, linesToUnstage: []} } else { - linesByHunk[selectedHunk.toString()] = {linesToUnstage: linesToStage} + linesByHunk[selectedHunk.toString()] = {linesToStage: [], linesToUnstage: linesToStage} } } - stage = stage || true const patches = await this.gitService.calculatePatchTexts(linesByHunk, stage) await this.stagePatches(fileDiff, patches, stage) + // TODO: Handle errors. } + + this.emitChangeEvent() } async stagePatches (fileDiff: FileDiff, patches: Array, stage: boolean): Promise { diff --git a/lib/git-package.js b/lib/git-package.js index 676dc54190..7fdb3f2423 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -200,14 +200,16 @@ export default class GitPackage { const pathName = uri.replace(DiffURI, '') const fileDiff = this.getFileListViewModel().getDiffForPathName(pathName) const fileDiffViewModel = new FileDiffViewModel(fileDiff) + const gitService = this.getGitService() + const fileList = new FileList([fileDiff], gitService) return new DiffViewModel({ - gitService: this.getGitService(), + gitService, uri, pathName, fileDiffViewModel, pending: !!pending, deserializer: 'GitDiffPaneItem', - fileList: new FileList([fileDiff], this.getGitService()) + fileList: fileList }) } } diff --git a/lib/git-service.js b/lib/git-service.js index fa35aaff98..95621bf9c5 100644 --- a/lib/git-service.js +++ b/lib/git-service.js @@ -427,8 +427,7 @@ export default class GitService { return data.index.write() }) .catch(error => { - console.log(error.message) - return console.log(error.stack) + console.error(error) }) } From 0b7da8e1830b17824ad81b8576e5c7e3081a9318 Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 14:55:23 -0400 Subject: [PATCH 05/20] Propagate staged events up. --- lib/diff-component.js | 2 +- lib/diff-view-model.js | 20 ++++++++++++++------ lib/git-package.js | 9 +++++---- 3 files changed, 20 insertions(+), 11 deletions(-) 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-view-model.js b/lib/diff-view-model.js index 7ba2d01b21..877c2975e6 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -49,12 +49,20 @@ export default class DiffViewModel { this.selectionSubscriptions.dispose() } - 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') + } + + onDidChangeSelection (callback: Function): Disposable { + return this.emitter.on('did-change-selection', callback) + } + + emitChangedSelectionEvent () { + this.emitter.emit('did-change-selection') } onDidTerminatePendingState (callback: Function): Disposable { @@ -234,7 +242,7 @@ export default class DiffViewModel { } } - this.emitChangeEvent() + this.emitChangedSelectionEvent() } async toggleSelectedLinesStageStatus (): Promise { @@ -285,7 +293,7 @@ export default class DiffViewModel { // TODO: Handle errors. } - this.emitChangeEvent() + this.emitStagedEvent() } async stagePatches (fileDiff: FileDiff, patches: Array, stage: boolean): Promise { diff --git a/lib/git-package.js b/lib/git-package.js index 7fdb3f2423..75fdad614f 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -41,9 +41,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() @@ -202,7 +200,7 @@ export default class GitPackage { const fileDiffViewModel = new FileDiffViewModel(fileDiff) const gitService = this.getGitService() const fileList = new FileList([fileDiff], gitService) - return new DiffViewModel({ + const viewModel = new DiffViewModel({ gitService, uri, pathName, @@ -211,5 +209,8 @@ export default class GitPackage { deserializer: 'GitDiffPaneItem', fileList: fileList }) + viewModel.onDidStage(() => this.update()) + + return viewModel } } From c7358a615181a82c48bce93965b700c031224f93 Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 15:48:46 -0400 Subject: [PATCH 06/20] Bubble up all the notifications. --- lib/diff-pane-item-component.js | 14 +++++++------ lib/diff-view-model.js | 37 ++++++++++++++++++++++++++------- lib/file-list-component.js | 2 +- lib/file-list-view-model.js | 12 +++++------ lib/file-list.js | 22 +++++++++++++------- lib/git-package.js | 13 +++++------- 6 files changed, 63 insertions(+), 37 deletions(-) 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 877c2975e6..db27f86687 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -4,12 +4,12 @@ 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' export default class DiffViewModel { @@ -18,24 +18,29 @@ export default class DiffViewModel { pathName: string; deserializer: string; fileDiffViewModel: FileDiffViewModel; - fileList: FileList; pending: boolean; selectionMode: SelectionMode; emitter: Emitter; selections: Array; selectionSubscriptions: CompositeDisposable; selectionState: Object; + subscriptions: CompositeDisposable; + fileListViewModel: FileListViewModel; - constructor ({uri, pathName, deserializer, fileDiffViewModel, fileList, pending, gitService}: {uri: string, pathName: string, deserializer: string, fileDiffViewModel: FileDiffViewModel, fileList: FileList, pending: boolean, gitService: GitService}) { + 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.fileListViewModel = fileListViewModel + + this.update() + + this.subscriptions.add(fileListViewModel.fileList.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 @@ -46,9 +51,25 @@ export default class DiffViewModel { } destroy () { + this.subscriptions.dispose() this.selectionSubscriptions.dispose() } + update () { + const fileDiff = this.fileListViewModel.getDiffForPathName(this.pathName) + this.fileDiffViewModel = new FileDiffViewModel(fileDiff) + + if (this.emitter) this.emitUpdatedEvent() + } + + onDidUpdate (callback: Function): Disposable { + return this.emitter.on('did-update', callback) + } + + emitUpdatedEvent () { + this.emitter.emit('did-update') + } + onDidStage (callback: Function): Disposable { return this.emitter.on('did-stage', callback) } @@ -321,7 +342,7 @@ export default class DiffViewModel { } getFileDiffs (): Array { - return this.fileList.getFiles() + return [this.fileDiffViewModel.fileDiff] } openFileAtSelection () { @@ -333,7 +354,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.fileList.getObjectAtPosition(firstPosition) let lineObject if (lineOrHunk instanceof DiffHunk) { diff --git a/lib/file-list-component.js b/lib/file-list-component.js index edeca4d510..fff468edc3 100644 --- a/lib/file-list-component.js +++ b/lib/file-list-component.js @@ -43,7 +43,7 @@ export default class FileListComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.viewModel.onDidUpdate(() => etch.update(this))) + this.subscriptions.add(this.viewModel.fileList.onDidUpdate(() => etch.update(this))) this.subscriptions.add(this.viewModel.onSelectionChanged(() => this.selectionDidChange())) this.subscriptions.add(atom.commands.add(this.element, { diff --git a/lib/file-list-view-model.js b/lib/file-list-view-model.js index f1daf38908..92017dc4f9 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -36,16 +36,14 @@ export default class FileListViewModel { this.commitBoxViewModel.update(), this.fileList.loadFromGitUtils() ]) - - this.emitUpdateEvent() } - onDidUpdate (callback: Function): Disposable { - return this.emitter.on('did-update', callback) + onDidStage (callback: Function): Disposable { + return this.emitter.on('did-stage', callback) } - emitUpdateEvent () { - this.emitter.emit('did-update') + emitStagedEvent () { + this.emitter.emit('did-stage') } onSelectionChanged (callback: Function): Disposable { @@ -116,6 +114,8 @@ export default class FileListViewModel { // TODO: Handle errors. file.toggleStageStatus() + + this.emitStagedEvent() return stagePromise.then(_ => { return }) } diff --git a/lib/file-list.js b/lib/file-list.js index 0a03056a92..84f22a9166 100644 --- a/lib/file-list.js +++ b/lib/file-list.js @@ -1,8 +1,10 @@ /* @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' @@ -12,13 +14,23 @@ export default class FileList { gitService: GitService; fileCache: ObjectMap; files: Array; + emitter: Emitter; constructor (files: Array, gitService: GitService) { + this.emitter = new Emitter() this.gitService = gitService this.fileCache = {} this.setFiles(files || []) } + 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) { @@ -101,14 +113,6 @@ export default class FileList { 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 = [] @@ -148,5 +152,7 @@ export default class FileList { } this.setFiles(files) + + this.emitUpdateEvent() } } diff --git a/lib/git-package.js b/lib/git-package.js index 75fdad614f..05b61738d6 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -5,7 +5,6 @@ import FileList from './file-list' 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' @@ -95,6 +94,7 @@ export default class GitPackage { const fileList = new FileList([], this.getGitService()) this.fileListViewModel = new FileListViewModel(fileList, this.getGitService()) this.subscriptions.add(this.fileListViewModel.onDidCommit(() => this.onDidCommit())) + this.subscriptions.add(this.fileListViewModel.onDidStage(() => this.update())) } return this.fileListViewModel @@ -196,18 +196,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 fileList = new FileList([fileDiff], gitService) + const fileListViewModel = this.getFileListViewModel() const viewModel = new DiffViewModel({ gitService, + fileListViewModel, uri, pathName, - fileDiffViewModel, - pending: !!pending, - deserializer: 'GitDiffPaneItem', - fileList: fileList + pending, + deserializer: 'GitDiffPaneItem' }) viewModel.onDidStage(() => this.update()) From 57375e404b3ba19f127194dacb086c237e01af7d Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 16:08:15 -0400 Subject: [PATCH 07/20] FileList => FileListStore --- lib/diff-view-model.js | 4 +-- lib/file-list-component.js | 2 +- lib/{file-list.js => file-list-store.js} | 7 +++--- lib/file-list-view-model.js | 31 +++++++++++------------- lib/git-package.js | 11 ++++++--- 5 files changed, 28 insertions(+), 27 deletions(-) rename lib/{file-list.js => file-list-store.js} (96%) diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index db27f86687..f20d75c011 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -40,7 +40,7 @@ export default class DiffViewModel { this.update() - this.subscriptions.add(fileListViewModel.fileList.onDidUpdate(() => 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 @@ -354,7 +354,7 @@ export default class DiffViewModel { const [firstPosition] = selection.getRange() const pathName = this.getFileDiffs()[firstPosition[0]].getNewPathName() - const lineOrHunk = this.fileListViewModel.fileList.getObjectAtPosition(firstPosition) + const lineOrHunk = this.fileListViewModel.getFileListStore().getObjectAtPosition(firstPosition) let lineObject if (lineOrHunk instanceof DiffHunk) { diff --git a/lib/file-list-component.js b/lib/file-list-component.js index fff468edc3..541ad8f48d 100644 --- a/lib/file-list-component.js +++ b/lib/file-list-component.js @@ -43,7 +43,7 @@ export default class FileListComponent { if (this.subscriptions) this.subscriptions.dispose() this.subscriptions = new CompositeDisposable() - this.subscriptions.add(this.viewModel.fileList.onDidUpdate(() => etch.update(this))) + 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, { diff --git a/lib/file-list.js b/lib/file-list-store.js similarity index 96% rename from lib/file-list.js rename to lib/file-list-store.js index 84f22a9166..be2d82b8d0 100644 --- a/lib/file-list.js +++ b/lib/file-list-store.js @@ -9,18 +9,17 @@ import type {ObjectMap} from './common' import type HunkLine from './hunk-line' import type DiffHunk from './diff-hunk' -// FileList contains a collection of FileDiff objects -export default class FileList { +export default class FileListStore { gitService: GitService; fileCache: ObjectMap; files: Array; emitter: Emitter; - constructor (files: Array, gitService: GitService) { + constructor (gitService: GitService) { this.emitter = new Emitter() this.gitService = gitService this.fileCache = {} - this.setFiles(files || []) + this.setFiles([]) } onDidUpdate (callback: Function): Disposable { diff --git a/lib/file-list-view-model.js b/lib/file-list-view-model.js index 92017dc4f9..a071bc330b 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -6,20 +6,20 @@ 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' export default class FileListViewModel { gitService: GitService; - fileList: FileList; + fileListStore: FileListStore; selectedIndex: number; emitter: Emitter; commitBoxViewModel: CommitBoxViewModel; subscriptions: CompositeDisposable; - constructor (fileList: FileList, gitService: GitService) { + constructor (fileListStore: FileListStore, gitService: GitService) { this.gitService = gitService - this.fileList = fileList + this.fileListStore = fileListStore this.selectedIndex = 0 this.subscriptions = new CompositeDisposable() this.emitter = new Emitter() @@ -31,11 +31,8 @@ export default class FileListViewModel { this.subscriptions.dispose() } - async update (): Promise { - await Promise.all([ - this.commitBoxViewModel.update(), - this.fileList.loadFromGitUtils() - ]) + update (): Promise { + return this.commitBoxViewModel.update() } onDidStage (callback: Function): Disposable { @@ -62,8 +59,8 @@ export default class FileListViewModel { this.emitter.emit('did-commit') } - getFileList (): FileList { - return this.fileList + getFileListStore (): FileListStore { + return this.fileListStore } getSelectedIndex (): number { @@ -80,19 +77,19 @@ export default class FileListViewModel { } openSelectedFileDiff (): Promise { - return this.fileList.openFileDiff(this.getSelectedFile()) + return this.fileListStore.openFileDiff(this.getSelectedFile()) } getFileAtIndex (index: number): FileDiff { - return this.fileList.getFiles()[index] + 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 () { @@ -100,7 +97,7 @@ 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)) } @@ -125,6 +122,6 @@ export default class FileListViewModel { } getFileDiffViewModels (): Array { - return this.fileList.getFiles().map(fileDiff => new FileDiffViewModel(fileDiff)) + return this.fileListStore.getFiles().map(fileDiff => new FileDiffViewModel(fileDiff)) } } diff --git a/lib/git-package.js b/lib/git-package.js index 05b61738d6..1d908a09d4 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -1,7 +1,7 @@ /* @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' @@ -68,6 +68,7 @@ export default class GitPackage { const promises = [] promises.push(this.getFileListViewModel().update()) + promises.push(this.getFileListStore().loadFromGitUtils()) const statusBarTile = this.statusBarTile if (statusBarTile) { @@ -91,8 +92,8 @@ export default class GitPackage { getFileListViewModel (): FileListViewModel { if (!this.fileListViewModel) { - const fileList = new FileList([], this.getGitService()) - this.fileListViewModel = new FileListViewModel(fileList, this.getGitService()) + 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())) } @@ -100,6 +101,10 @@ export default class GitPackage { return this.fileListViewModel } + getFileListStore (): FileListStore { + return this.getFileListViewModel().getFileListStore() + } + getDiffPaneItems (): Array<{pane: Pane, item: DiffViewModel}> { const panes = atom.workspace.getPanes() const itemsToClose = [] From 410af0ea27b3dc4c80cd0fae57293cc399eb5ca0 Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 17:24:34 -0400 Subject: [PATCH 08/20] New fixture repo --- .../dummy-atom/git.git/COMMIT_EDITMSG | 11 + spec/fixtures/dummy-atom/git.git/HEAD | 1 + spec/fixtures/dummy-atom/git.git/config | 7 + spec/fixtures/dummy-atom/git.git/description | 1 + .../git.git/hooks/applypatch-msg.sample | 15 + .../git.git/hooks/commit-msg.sample | 24 + .../git.git/hooks/post-update.sample | 8 + .../git.git/hooks/pre-applypatch.sample | 14 + .../git.git/hooks/pre-commit.sample | 49 + .../dummy-atom/git.git/hooks/pre-push.sample | 53 + .../git.git/hooks/pre-rebase.sample | 169 +++ .../git.git/hooks/prepare-commit-msg.sample | 36 + .../dummy-atom/git.git/hooks/update.sample | 128 ++ spec/fixtures/dummy-atom/git.git/index | Bin 0 -> 261 bytes spec/fixtures/dummy-atom/git.git/info/exclude | 6 + spec/fixtures/dummy-atom/git.git/logs/HEAD | 1 + .../dummy-atom/git.git/logs/refs/heads/master | 1 + .../25/9905570d5a5253c139ead02042aa88ade66da8 | Bin 0 -> 45 bytes .../58/e0826234eb7b82ffce8b717302bd991b1b0269 | 3 + .../66/f07516e37d3f32eb8c7cb2405b40796be98f3f | Bin 0 -> 12638 bytes .../b8/ed79fd6dfe9d2fafd97058f0c7d624688a547f | Bin 0 -> 11859 bytes .../dc/56415965e522162912c3dd812843e44a629c2f | Bin 0 -> 92 bytes .../dummy-atom/git.git/refs/heads/master | 1 + spec/fixtures/dummy-atom/src/config.coffee | 1260 +++++++++++++++++ spec/fixtures/dummy-atom/src/workspace.coffee | 1083 ++++++++++++++ 25 files changed, 2871 insertions(+) create mode 100644 spec/fixtures/dummy-atom/git.git/COMMIT_EDITMSG create mode 100644 spec/fixtures/dummy-atom/git.git/HEAD create mode 100644 spec/fixtures/dummy-atom/git.git/config create mode 100644 spec/fixtures/dummy-atom/git.git/description create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/applypatch-msg.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/commit-msg.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/post-update.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/pre-applypatch.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/pre-commit.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/pre-push.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/pre-rebase.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/prepare-commit-msg.sample create mode 100755 spec/fixtures/dummy-atom/git.git/hooks/update.sample create mode 100644 spec/fixtures/dummy-atom/git.git/index create mode 100644 spec/fixtures/dummy-atom/git.git/info/exclude create mode 100644 spec/fixtures/dummy-atom/git.git/logs/HEAD create mode 100644 spec/fixtures/dummy-atom/git.git/logs/refs/heads/master create mode 100644 spec/fixtures/dummy-atom/git.git/objects/25/9905570d5a5253c139ead02042aa88ade66da8 create mode 100644 spec/fixtures/dummy-atom/git.git/objects/58/e0826234eb7b82ffce8b717302bd991b1b0269 create mode 100644 spec/fixtures/dummy-atom/git.git/objects/66/f07516e37d3f32eb8c7cb2405b40796be98f3f create mode 100644 spec/fixtures/dummy-atom/git.git/objects/b8/ed79fd6dfe9d2fafd97058f0c7d624688a547f create mode 100644 spec/fixtures/dummy-atom/git.git/objects/dc/56415965e522162912c3dd812843e44a629c2f create mode 100644 spec/fixtures/dummy-atom/git.git/refs/heads/master create mode 100644 spec/fixtures/dummy-atom/src/config.coffee create mode 100644 spec/fixtures/dummy-atom/src/workspace.coffee 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 0000000000000000000000000000000000000000..9fc0c7c8e20b0ddd21b9e7ad2d405681ba9506f0 GIT binary patch literal 261 zcmZ?q402{*U|<5_u#cPKfHXtcvv1rWnvsElg+t^)7Xw4%5+Ls@P)LM2pmzJ5Xmz zmVVyxw(@W8zq$JBZx%#+IDSnfqbsDIK?G!8d45rLaY15oD$GcbF$^I=uC74Jl)*^B zkV|zYYdCLIQ1C&^R~HnVR&}g>mb(I|6)0!GbtlX*GWDsFn5NL-yNw#oPrQ=m=-Z@U WtzKF$*kO9!&7;I=XMniY{$~JCP*>If literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8de27458d9680311387f1f3f240be465764fe1ef GIT binary patch literal 45 zcmb?Q0quQha~wx@?fcAMkwbpy1_d-pjpT8_rU;75Sk_2l3@9laFoRv_E&wI= zg6Zleh(Ta}yU)2xX4V1_grs?n7ms0&&{etIym|L~GwX1+JPcnv`j>zG)BkxJ9*0%= zw@tMw!(g+RmaBTQT$MYgvrRp?b5!TAk7|2-@oYIiUDnmQd|uV~qd1(E+adq^#k^Xt z%hhGxy?kFT);sU2_2AC4{U5&1U)5z%FBgM5r^Wgtf6m{7J4JoInBpB#(Sy� zr|ac>P>0EKaa0{|R>itnF2b~27uBpD-MNP$o-RVQsMp0}QnJJPq(bvqfg$imF*_^H z@t^l-PbvsSjTU7X7Z`V>qmIKd=&hLPaQE(oKbPxuwKxvNV#zqP6Vw5iAA@nm^) zRF(kaUif~wE-`qq4rdj}WU&nIxvT869d>uI%b?`-X0=cdY-B8MS{@af*;<W2@;A{(6#F7HgIkpaCfH<3hdgk+TS{3VZb}p0v@vu4%>uSzTE^Ti~ zYqHp>i~=gDm$Py-TONnOcjqm#wU)bs?cnog==|m=oCBkR%>vZ3u8t}oi}b!+0EV-Z za*@q!INlB$!|~#IHGL+qTwagIWop@voC}9;J1l2Y!P8|#)F&@74U#SP9@tC<4$qe5 zYJx=yNj(!bcXFshS%6kfiud5#us%Npp`4Zz;02Q6kc4+mjxR9oK@BN8FWwA)ft=pa zAEV{!cxx11f*{Ke#rzc9e^!Qhb$qhMhC*np%VS7pqdnzZVj9DN0G4MU0c}!;<5dYl zSh39_j31771=^fY?}eYaKauHAAZQLtJi+kWVJz%Fz6t+x_9UA+>|ZRtS*)G?AAK%x z+zYSCL+cfV)>#F?d02w*Mz!(=!ejQOG9o~lt~b= zkfz2ILgDg4pMVI_C;&(E5=7xYgK)}j7}%CWEl9KAdS*8W4d}x;7CPU|cZpnSSWMQ{ zdl36>7(%J8OENwn8I1rmy7*s?t^KkL2Zod|T~6vZ!;|&;wBCL2K*+dSKH$>_GYnO) zADlpo0kJ!L=PGUIY`L25OqQF)dW(q0JagI`)#7NWn8KErWY`xZ)B@|zrO3Vmvb=49 zPL8?TVgS;!f+#V*rkn*mL3vh-QnyF78wPePZHufGp#%D?PnMe*gbvKeay=P^r=hMy zqL^gq6$p?wrW#==y|1Pq6-bYAK||#A3D7X!7R>^Rfz`4&{%JYe%u6U&+j8B}ti5kB zp~40qE}?4*n5DYGW((|wqheOmBmpI)VOxD$#1Z>`X@li-A(S#NJ{X~p4SoA5cmziv zb#9|$m{x#17coQ~Mq>&{uBM{WMgi)swQg`aMmAmtQ>i=~4<0=n08!Y4wrIl|U@k^U z-sP%XkhaBKh8pN&PVzBxDDPqR*Q19f@V=pKN96?OrMLz9!UOR72dwB5p!nk9?Z8mx zy+@=1Pm^K+^C43LO3OmxibQ)2oO~L5ut(}!;VGvX>i+%BDJ32B(`(SO@-t~^OrjCN zDb$3Oa2b&p#IPL-yRV)>ldldUQ-Y?P$KC)I{>zyRk3>x_KC%JX zOeE9wYEyo61M)sbsO%>zx{et0exmcfYd3NtRgNAmPic!6v#l^T;W(yH<18i{g>KC?44gaC zPH0GURoN~2xq;et*&HZdqShAD!lQAfepo`W0$AIE-j&1ckcN=9i^*&= zC9lBpo!MhJbjG)W5)5_~QhHI6BjjfLtI%}z_sh{AF*pAuJd7MU86M^!25h@D;HHKIS`RXK&# zGKND_F8IeGLP2Kljf>goNkKc$0%%o9hC<+oK`(K4LHXsXnrM^(eAho*gruSxq1~}! z;3Yhyb^H03Us67WvFuo)SlgGZ<)e{H1=qI=I>G2yO~bSOeF!hc31QRC0b{+PAzg>@ zz2b1fmGQq&ZTu5f_8O~1=ke+RDiM|EVDnamYY+Gw!-U()QUNaAQ71u(m*2w z$36~J8Jtv@Br4f}0sLm<5SvFHl7?}=-dFXeKuo7DPYc8}VFq!h7&E9~KrYV~*8Zd{ zrZe~`K#uzOo5P@bC@IldRdayod?uRuRZOP~b*@W;CJvjQKRc;r({C2*A4D~#H&=6C zugDfjp43;H1H%upN6BPF08}?ibXy5t5$=2n=HVtGUZB<`4Li77o4Fuj+k8Mo(QApc znL#K_#|<{ghCkEpiJV&(?@F+sj>L#3O;WQNB>+WQz!um5Lo-aHY{YPcYYQr1)#zv% z!6b(9sqn0a$zgGfGbzC}lq)n99lPNG zD&ULl@GslpU#SROyWI%f5`r4cKR8-02i%&$VX=adZJ`1|^C`SKBq+#VHN^$7W6u0E z;So{a4(d*eMS0t$UuD-4(B|sxR|pqEJqQZ0&2|nNp1oWy5mK3gi0L6|Py(Ad8eDe! zD-I%wEEnkib+NMk-!BiBQw{m6%KAebBS-&_HBuGb)qjHX@%e3h4-bcmaZ4YUGTFFt zxmni?!5DLrdh8W&@Ckr@JSLjEQRaR*V86M4p3eoB?Tg!BjSS^{BNBC8t(hr8ozb2T(@x?S@Esv$Kn@{R5&9CVLt`~jGLu8;B08}j^Vvu> z5Eh2m@Lh4dsMeb)^1^PMnc5MV@|xO2a8e+HIzfORARNvL*p?tX{!r75k9-7bQOt0i zW^h7&`}TXJW8c2r6_ID*Tgp}L)sh+h7}0((Gy~@ue*Yj0)1Uy+(AY=%NMbBv+=K6m)w?MzJ~gjtGNuqZQ}v@` zsHx2%lGXDE#cEvv==y;^+c}yQ?-44W?#ykJ2U|21_ccq#JSG@{pwnt`E{y>*u3R_O z6YIua`8rI0-w*fiA0iug|9;pBWB;2z4Bk|0xC)b6U-=JxcL4MCT{kI!tU?%H^-g<^ zC(u|xaxx*5BLyV_5#92SdUP!4YRGc!c1Vtrn$W$#8U%P8JTtLsqB|7oCKdmUKLmTo|fe_B6`y z0y!e2Ik6*;!^Pztz9^I$}loJ$HJ!_%piXE+WA40A} zhji026@k6{P);`MvWZc`kG;}TC9$udZY47pmjl8FoR1MXoXuoRqLaI%?MD3BcrN&^ z-@uQBe5)<|RMirO5`gP1Y43OvgPUW@Q+S-KbNh>~fl`HUGUx5x-#07)LQ-Scc{{j2 zc%u_?-#Mud%8C8&I6<768PcaPJZVszE}^N|9DWhXd}h@VHjO@#WVY*S+NVL$fnpr5M{L1|623`e(s@4Q3FT?aNHE6?m}6*i9JSd;2lzVGeMaci0NWa_0aQbrOVob z-vymKDeWipZY+oSu2{gkSZ#+rO^v<+8!-lg0B*Fa}*ba-$Ek$PFbVAMTk&Z3@9;r@V!pB%rKd6R)Nf~WhI2*8(7vf6@ zAtU?=>e-f*3iNBqARE?9)+LF z0fey>Fv`$l))nvXOug26;|e91t*B zA4vA2Wl4u63T38Rl_Z^}J1z-POo&?NdIw&q{+X39i%Fu+E%_%_Eiq35&QpkhXD4-R zp<4`A3rnhN)^!>lxvj>JzAy|VG%^;XQvl0P$2@uvrGnPKTAeYHQmN|QGE+oMaskl` zHs->Do^%zN10ujO(0Igf#j-AkTbOnU>r4`StLhLr2{;&9*c+7|dkdq^wEhh8zxA0;+0d^@UYWrbnvd+_khmb~RlCG_m& zqc>YBk8>D|N490Pltf)%AeXuOi5~#2bsNc>2(qmZl1d7I)tnkUT?od!)&)c|=XR;G zignRM(P2prV?0JFytjz1IIX;pPKOW%uxj{$fsl-MF@ahOi1v?d-M(n|Z*^aWt;U(1 z=^LhK} zgBIWe=l~FVuINxq1dxTtWGHZ%EukeCKPr1i;XAEjm2p5iu~r%6tg*7D4(qHL&PlueHBks*CspX?i`rwmw}|P&;ickZAdzrUsJr_c2xQ$i&8h zvu9W6UZ(**Rg?H^ZK;$5Qg{B33w;?COx+TfNwy|@{08zh4fd^x>9`t4qDs}KD4pc#o=GX?oqd4wK){4EFgx515pvgkF2jf82kr; z1^MoQ#zjUP8Sw|;>hV`%U^V>)ao2n^u|PIgAk002LX57Qx^c4IauP1YRXCz<4Y&dL zh2S%HlVl%%{W|II`KEFKGZa8=5FYC6Kf?Eg0CDlU{IGr@E{Av#e}(rVT$+?KybQsg zq^C$O9NCfSgaT{AwiYs&4RkRK^cf7l!FKN%h5$qloWrdv_a9ajqH(NO^%PK!C0~m& zXQF+0-|)QxG1Vf@#~hALfDx7GfgA*zD9x_UX` zp+m$fD8)cm$gS&eaAY;m3hx&iNOw*RTk#qMt71`r|U zE|S;yvp$PnUjE+gtsbYo&#FU_l#tnFa^hwB%$fB7MYFrKa&yloRlGv-Q8X84LalmM zR=w!VcQ)DL5eV)1#C!h4L*gl&)@Yo8X7gOt8Zgo-Lojz*PIo2T2GQ@aA(>K7F>=GA zQ70DyAX772!cKI7Q>|pMOcV*)pa5qGGb2#P*V5!R64yIkCvY(pbLJ>0imiO#8HBRv zUj)O+ouTMV7@M=jDif8Yt+3awfD+@NNuQ0O{i!~hp&tvMX>}lh(|rVuCQQW<<(-bB z%@`kKCz$SnbhHHFJe}dXq z=_syH(H3&L>ck3SFKT-RcEBc(kFdK<=CWjI1!Jr=3}tH?n$MhRH zB)1>tmpXcHGKef5rCUIsEE95$!H#BbNj9ysBsmAuCj5~K4fPYvX0~Lf3U9fv1l2o8 z(?Rdc6%*V$&g<%)6_HdS1gHbcKLfp84D`?1b&uQV4WJV)L6&h-y(Lse1tE-`i*q1_ z(i{k5!QLpmf(~G;Wz1t3JXJom12-#P8zK&*%#7HQb|IcQf*WHpL!2+Fz1+`e1`h#M zW6N8{vqq~&p#*S)1}zvI|J%}JnvfJl*Si$a$*g-KK^79qP{p1UvY|FEu%Vhqyw!o% z>_D_D>fwSUi&q9rQYD%c;lfS=YT6W7CleuhuLPhH{|pCrFnQcQsRrn2BL*yQAp!(U zNHnkLXc^Z~9vO*&MXhoQ%fnpJuy&Kx)Epd4$@-&`s-0PDkV~?GpJ*n_btZ<#1hNl} zHu6MY%3TO7l4vyG?KuFxnhPf_|cn!aN+ZdasxJ;`SsPMVh(^)vmTe_yPAoSw&35R*oJw+xD>6 zE%zeYPBMjChb96YH9B+dKj=ULbO%ZS0X0`rWy75vZjr(beL!*_`eakIZbgW}w|P(f zL6)$@@*kPNX`_+;`zz{tU@Yadj&h1D2?t)`p}4M~#F(rBPU*VWkf$*O4h5<$B#&TEwJ*3^_<26Xnb==BMj(Rn&D{QQn38JY-QU zkP{cIex75`H+ma(J@R|i`g}L+`JZu#hT|>NJ91-ftOm*&{48XGI?t53{~eJkMT>Cu z7RTia-l}NTaM|P#noVk?THh1;Y`K&@q~>e4HDMvbONo>E5qjwxkx{$~G1XI7$hkBN z_by7`E(PzIjM|SIYmHHSf^{}|yGlsHvc$`Rb zFg$|yxCBl@49c9S*tUAox1-IX`WtF!aD(RL9SfhYH8xzn+#F(Jj<;A%l!2wyCJ$Im*R4dZtxR1 z6Ybn{A8Aachg0CyUPc;n=o^-ep(Iv>*v)7NE=fIT1*=hplRz$!BSm+~Bm_081(Fm> zti{Jv6y!L07w-mjGFFbT$zj*%Uzka2-nnhCLpgvvv#=7P4uw?kON4eQWrbdTg!*Nk z3Rhq_tA>LU7eArJ`o@Nf=2zA!mto-4s(v*?)JS&$ktLZN)NbjmB>kPc3H%jM^mU7n zsBjc%di5k+abE1JlbJmi4?F17Ox!h^ISt_=Bb=V?Y11XjX@O;{i!X(v#&> z&aFy9hou$PTyXdbM<$Vv+?A_;{#ko0vpRd&F>A>WktTE(kUK(jqq)Qv z>$s)7x)}$)xtY!pPkEj8?bxD8_z;)FBb@LB|d#dqP8pu-~e#YW0^Q%#Y$opzA3&wO)BtrPr{ zvtof~x+s&(=%H@DcVQcPxoeO03NlGsAe5>$XdMH8Hn|lQ=s;qzx^ekyruNzN9A|z# zb+Lvgge#3p4?xmNS4@$n6%N64azW*1!`!~wP!};0hg1{%aTv5M@HFe_GzLd_jM<_^ z9432#FD#@n({lqXZz`sHYoOE@OH94h%9k)8$%Dgh_=l&N?k(Uxv2PH-js)D^?}u!Qn}pAPqU< z(J9Hx4RNls3ymL``Yb$h0olS5<~#-1{illadA+}4L#`&c7W>*BxB$l?Us=oc_Cv`E z#s(sAw2?rO+9dlU7Dn?}Zsepyi61AleAY+9lEVjfR+$6l!$ zKlw6}+bZ}nCv9)M4e_b%drUBOs@XK=Jg{4qa3@RbAx~8uoxj9~NH(i-c$a^`u4Z<_ z&&{LW>E4Badx*q)FT9uIyST%Gw%-W1vrpbR{EAp*z7FE@BhSK~golzv_YrW);(77X zRcrTounrNPlxZ$NhiRy+SwBKj=;SsyM6C-i76ZT;`bcUwLY!iU=e+s#7z_e|sHmJR z0SzRP8wZRX2zu#1(YHj5@f2Z33BJj<4^f2aawH007$pAGhEo^C-$|4Q#!A87+dcw~R+}dBTMq1@tQ!aZgT2?`Mb0X$z2I zf7b6Ajr1EZt!NP>gdBi+jnHW(D20|Fq4Zq8W*lQXVt%0E7tQ{5=hofop`!+;g}Zm} zMnwK7ckh$w6i|Y`dlTZp**+*eM0Jpz~*XWelRD9k1Vc;_DHCo8&RsyVe-PmE6I|9&m%3YBI+>TI2 zv2W?vf<&R9OB3@&zxe2s?5FMG-C zL$($JJnaWURw&hxSW_MsX_s{vS=i$z@W}G>I!{KpiK6`Q!%;s6hp!$7y)DO%VGV zr_XTUpv=Q!*f2hK4_;rz+^Rq!bq>vFl8mX>J~UtC(5ooREU_L)n(YNu(n=(L|E}8H zmQ~LO_*8U}t${6GT=C*>xE6ev57r3JNjnQ^I9_Sbc0UYxB?WXUK#WiTbj)66xB^_A z`9NDgac8e1w-(shh)r>)SSAK~3z&o z_}snyWBxo*kI$DrGN?-@l4Ug*=%U-m^$u`u-~1y<*t!K)4&bX(gI>^qyI3}dJsX5* zhdqs`F!x`CD~vtqe@mMK^((lhv>f7t z1dV}B`YD*p1a4!VFZ0K1(8yB|hi6K=9AD*BgxRaf2Vu7O(7! zu|!uQORB5qR-Mf^wO`99LF6rscMV1Z92`p1YxhjDBx^N(YDwxBKVGZrKsF(QE;pdk z=C)g!iJLE9?*BO}R*T`?K}uT;0zPnuFUYZ48LO43f(?c;27HScg@XtO*`r+~JpPjb zDUBB*Zsba#-n#rZoZMpUyr&TbjK#>E=C>}9iy-SJJ0)sh$SqABL=FYv=o3Vx^qaca zWcjX%Em7anQ-cCeTi^=pDf3D`XtT?_IE|N}AtSpNp~&q3ZYRfT(yIY!m(b`P6pHF$&NJAER41n#cp+mKGc;8>79i$Vg` ze9QTEsRgW z+ZYI|)PD%NHl(lg-Iq>n0qmcSw(JJ`H{m#tpY$3dIK8#f4MbU|=nORA9@#eey7zR# zeKSGz1S#S&rX&!~7!GuuxN}c7*zZA^Iq>e^-8RMrfvwn{$^2zOP|CZj43J!w z$Vl<eJQ-((L!(vIG(UU2wLYb2u%b?U z1q`x=$rg>s+lQTG5IN4r%4a5jG?N89$aS~4;9fri!NJ&&aL@g3f-Eq$PGZz{0H~J3KpNX-zdk{qSmvtHdb?eD=BEV;_6B>B9XNz4 z@>mD;XAh)C>Z0W&EKks{V&2Xno{ykWrFQEmpYDaj1qcPHt~3oq--slsuZUppiz?<3 za)F){tCb?1ZHqJ($%z_NCMSpV5uPCjpYv0jH!}(!lGS)kxmM7;;EA7@BZ(0XHfY2+ z+(nF`TD=XNF-VLjEFx)US(PsekTV3^cor97D^TIo<{VjoW8&k)d`se!Sw2F0odrmz z--qlV&iKjgbWaCX9yMR*rJ8Sk5RJCqP0qFTqN-`fIbnBZL{WwBiJoNZv}nQfgv0@6 zYSEjPCxMfi9M+>^9Tb-Uy-F8qit^SKn#aL%&bS-S5I9BgimjIQYDld%0;&>iz;2MtvW_2V6is)KidC|x*{A*G!VX|=?{ zguheY&eb&Ol|j|Fd;s;z!cDSKnkY(LZ+I(>g`fqb>9;WuEi-0|^2lfuM*cwZILS!4 zkdBvLyqLwK8#Acx%Eq^dE7X502zzC4x-lLlK80pBq5~Kq&H}&G09}|9=iMq~4PLj! zo4_-pGKE|nf`3lz`d;{&7o3=7=$~2954PgD7;$<>1HP+!26K=#K)A_AuaAgF_2lX@ ze)E>6m!y*x7yEIDbmmX^72CI91SMSn!P_QrfWm7&C9RF&WGEy9S-C(H(k^S%LF|iW zHV}APKOptOzI=nj7$`DXZE(i`Z+@FE@wujW7!wP?htKM@-phllPu29+YqH=!^HWc} zum~Sn;YWLMVuNOI#E-Z?d=eY!y>J3ARwc7mt9s4Ph+{1I3YEkx{Ig_qUbtv{U_!>Z zKh8$ahT#CQJ4W&qNU_%Fj@renxk8SpNMUYF>&aC`8(t1E_ata!6cf-K4`&*Y?A_A(gub?^nS3Rn# z{=WF$T~=7bq?M|E$#TP1?o|erb>pj;_)z@q5a-K&iHf{`{=$*IBzRQ@6YZ z-{6bbjBCKJP*jra_)kvz`W1#I^!Oaswlo~Amw&;hgICWWhgblInm3k=rn9tcoE4Gs zr^i#S8bI9V<_Nh*?L%bH{HBB{-?oAjD1srBW#!~@jn~=&`Ea0+T6%(TDY|?kG6Kx_x>DF9z8{jD}EEcV#17OAEzKO&!4VV*;P(ccAbA`Xt8g-J0e?_uj-Iws4 zewABqw7$uDK-jhxRVql;e5mG|xsD)9GZ7=&Ql?DBCw+Y)6=>U-!R>47o^pdg;<5(x zwoSC*lM(nK#c$M1RgF&$(Sb)X9KXQv&04kZFZ`f`+$}*(B-NL(;`ioi<#!-ZLJv`A zKJj0Z5U}5o36>`7B5}>LSn6!>&7EFNlEw$1CU8ybGNFW80%r}gg zoo9`k2#64)CfGDMu==#;5&sRgf8zU?o`1_$9N8AD zlAQg%EX^Hik!jh*TKw%$+Ng-)ToLf)c@}fTl8-`AP#+K%W2FK*ty2-F=~kukDa}Ov zbPrSa;jYvMd&CeIP$7OxTU3&xOuRR0PcOYRHEHLoHz#6-0pu=0T90;cEo3nRdRT2lBm-WjZSv*nCK_ZI4Lw4 zg;tthzrdDhtK@j{m85K_hob`^^nZU_iWN*z+c3-yvv8n@iEmM1tg%ZPdsVQPY@t);pbA zZa0_?S2_!&r0KM)PZ!BZ;1t{V1=fEPT;fXJkXiPO@s~@p!`8p zzdvTEk5llC>{#Cm|JGyQyL~+fEF_|ZUK~zaV#}JfcYrS)(`f_OM_7&3*z`cNmSAB3 zu>wgd&_5&)U7XgADM)_R8yP^md3i*FL$dws}?13U7f~&$j-aM(E23*Yx6Y^J%3m*W}N5z@90~`^) M{P~^#0l4T2EoP(k5dZ)H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2011ce79e6348faedff5c1bff7008e8630cf023e GIT binary patch literal 11859 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 literal 0 HcmV?d00001 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() From 93a61a8066ed4e3b69cb1f4ce601aa2bc53ad5b4 Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 17:25:05 -0400 Subject: [PATCH 09/20] Update the specs. --- spec/diff-component-spec.js | 23 ++--- spec/diff-hunk-spec.js | 46 ---------- spec/diff-selection-spec.js | 19 +--- spec/diff-view-model-spec.js | 145 +++--------------------------- spec/event-transactor-spec.js | 60 ------------- spec/file-diff-spec.js | 62 ++----------- spec/file-diff-view-model-spec.js | 8 +- spec/file-list-component-spec.js | 23 ++--- spec/file-list-spec.js | 98 -------------------- spec/file-list-store-spec.js | 30 +++++++ spec/file-list-view-model-spec.js | 22 ++--- spec/helpers.js | 46 +++++++--- spec/hunk-line-spec.js | 129 -------------------------- 13 files changed, 110 insertions(+), 601 deletions(-) delete mode 100644 spec/event-transactor-spec.js delete mode 100644 spec/file-list-spec.js create mode 100644 spec/file-list-store-spec.js delete mode 100644 spec/hunk-line-spec.js 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..3e7d87d372 100644 --- a/spec/diff-hunk-spec.js +++ b/spec/diff-hunk-spec.js @@ -12,16 +12,6 @@ describe('DiffHunk', 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) - }) - it('stages all lines with ::stage() and unstages all lines with ::unstage()', function () { expect(diffHunk.getStageStatus()).toBe('unstaged') @@ -47,42 +37,6 @@ 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' 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 6ccafdf97a..4ea7c825d5 100644 --- a/spec/diff-view-model-spec.js +++ b/spec/diff-view-model-spec.js @@ -1,17 +1,11 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import DiffViewModel from '../lib/diff-view-model' 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)}) - spyOn(viewModel.fileList, 'stageLines') - return viewModel +import {beforeEach} from './async-spec-helpers' +import {createDiffViewModel} from './helpers' + +function createDiffs () { + return createDiffViewModel('src/config.coffee', 'dummy-atom') } function expectHunkToBeSelected (isSelected, viewModel, fileDiffIndex, diffHunkIndex) { @@ -27,23 +21,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 +40,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 +58,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 +85,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 +418,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 +434,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 +447,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 +462,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: 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]` 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 cdf7218eb4..6943cb3d7b 100644 --- a/spec/file-diff-spec.js +++ b/spec/file-diff-spec.js @@ -4,24 +4,17 @@ 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 FileListStore from '../lib/file-list-store' import GitService from '../lib/git-service' import {createFileDiffsFromPath, copyRepository} from './helpers' import {it, runs} 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', () => { + xdescribe('staging', () => { const fileName = 'README.md' let repoPath let filePath - let fileList + let fileListStore let getDiff let callAndWaitForEvent @@ -31,14 +24,14 @@ describe('FileDiff', function () { const gitService = new GitService(GitRepositoryAsync.open(repoPath)) - fileList = new FileList([], gitService) + fileListStore = new FileListStore(gitService) filePath = path.join(repoPath, fileName) getDiff = async (fileName) => { - await fileList.loadFromGitUtils() + await fileListStore.loadFromGitUtils() - const diff = fileList.getFileFromPathName(fileName) + const diff = fileListStore.getFileFromPathName(fileName) expect(diff).toBeDefined() return diff @@ -47,7 +40,7 @@ describe('FileDiff', function () { callAndWaitForEvent = (fn) => { const changeHandler = jasmine.createSpy() runs(async () => { - fileList.onDidUserChange(changeHandler) + fileListStore.onDidUpdate(changeHandler) await fn() }) waitsFor(() => changeHandler.callCount === 1) @@ -187,45 +180,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 b9b3ea0c09..4fd4cd467b 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) - await fileList.loadFromGitUtils() - const fileDiff = fileList.getFileFromPathName(fileName) + const fileListStore = new FileListStore(gitService) + await fileListStore.loadFromGitUtils() + 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 0c7da44ce3..a332a00945 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -1,25 +1,13 @@ /** @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) - return new FileListViewModel(fileList) -} +import {beforeEach} 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 +16,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 +25,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) diff --git a/spec/helpers.js b/spec/helpers.js index 52a5dd3b98..00d5261e5d 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -1,26 +1,31 @@ /** @babel */ +import {GitRepositoryAsync} from 'atom' import fs from 'fs-plus' import path from 'path' import temp from 'temp' +import GitService from '../lib/git-service' +import FileListStore from '../lib/file-list-store' import FileDiff from '../lib/file-diff' +import FileListViewModel from '../lib/file-list-view-model' +import DiffViewModel from '../lib/diff-view-model' import {createObjectsFromString} from '../lib/common' -function readFileSync (filePath) { +export function readFileSync (filePath) { return fs.readFileSync(path.join(__dirname, filePath), 'utf-8') } -function createFileDiffsFromString (str) { +export function createFileDiffsFromString (str) { return createObjectsFromString(str, 'FILE', FileDiff) } -function createFileDiffsFromPath (filePath) { +export function createFileDiffsFromPath (filePath) { let fileStr = readFileSync(filePath) return createFileDiffsFromString(fileStr) } // Lifted from atom/atom -function buildMouseEvent (type, properties) { +export function buildMouseEvent (type, properties) { if (properties.detail == null) { properties.detail = 1 } @@ -56,17 +61,34 @@ 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.loadFromGitUtils() + 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) + return new DiffViewModel({pathName, fileListViewModel}) } diff --git a/spec/hunk-line-spec.js b/spec/hunk-line-spec.js deleted file mode 100644 index c1b2a09287..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) - 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) - }) - }) - }) - }) -}) From 0012d65feb4caacd1e25f46865efca3abb2eb28e Mon Sep 17 00:00:00 2001 From: joshaber Date: Tue, 22 Mar 2016 17:34:11 -0400 Subject: [PATCH 10/20] Update more specs --- spec/file-diff-spec.js | 8 +++++--- spec/helpers.js | 15 --------------- spec/main-spec.js | 22 ++++++---------------- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/spec/file-diff-spec.js b/spec/file-diff-spec.js index 6943cb3d7b..c358f5d55e 100644 --- a/spec/file-diff-spec.js +++ b/spec/file-diff-spec.js @@ -6,7 +6,7 @@ import fs from 'fs-plus' import FileListStore from '../lib/file-list-store' import GitService from '../lib/git-service' -import {createFileDiffsFromPath, copyRepository} from './helpers' +import {copyRepository, createDiffViewModel} from './helpers' import {it, runs} from './async-spec-helpers' describe('FileDiff', function () { @@ -170,8 +170,10 @@ describe('FileDiff', function () { }) }) - it('returns "partial" from getStageStatus() when some of the hunks are staged', function () { - let fileDiff = createFileDiffsFromPath('fixtures/two-file-diff.txt')[0] + it('returns "partial" from getStageStatus() when some of the hunks are staged', async () => { + const viewModel = await createDiffViewModel('src/config.coffee', 'dummy-atom') + + let fileDiff = viewModel.getFileDiffs()[0] expect(fileDiff.getStageStatus()).toBe('unstaged') fileDiff.getHunks()[1].stage() diff --git a/spec/helpers.js b/spec/helpers.js index 00d5261e5d..bfc373f8d7 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -6,23 +6,8 @@ import path from 'path' import temp from 'temp' import GitService from '../lib/git-service' import FileListStore from '../lib/file-list-store' -import FileDiff from '../lib/file-diff' import FileListViewModel from '../lib/file-list-view-model' import DiffViewModel from '../lib/diff-view-model' -import {createObjectsFromString} from '../lib/common' - -export function readFileSync (filePath) { - return fs.readFileSync(path.join(__dirname, filePath), 'utf-8') -} - -export function createFileDiffsFromString (str) { - return createObjectsFromString(str, 'FILE', FileDiff) -} - -export function createFileDiffsFromPath (filePath) { - let fileStr = readFileSync(filePath) - return createFileDiffsFromString(fileStr) -} // Lifted from atom/atom export function buildMouseEvent (type, properties) { 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') }) }) From 388d30a7559d04194b6bd152110132acd4d8b52b Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 08:40:50 -0400 Subject: [PATCH 11/20] Fix the committing spec. --- spec/commit-box-view-model-spec.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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() From 964e0c22019bdc84ed28793e62538980d0dfa6af Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 08:42:28 -0400 Subject: [PATCH 12/20] Install twice because reasons. --- script/cibuild | 1 + 1 file changed, 1 insertion(+) 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}" From 58a49ecd793f74777e217ab79aa7e319cbbee641 Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 13:34:56 -0400 Subject: [PATCH 13/20] Decompose staging a bit more. --- lib/diff-view-model.js | 108 +++++++++++++++++++---------------------- lib/git-service.js | 10 ++-- 2 files changed, 53 insertions(+), 65 deletions(-) diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index f20d75c011..68b17cb8ce 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -11,6 +11,8 @@ import type {Disposable} from 'atom' import type FileDiff from './file-diff' import type FileListViewModel from './file-list-view-model' import type {SelectionMode} from './diff-selection' +import type HunkLine from './hunk-line' +import type {ObjectMap} from './common' export default class DiffViewModel { gitService: GitService; @@ -266,81 +268,69 @@ export default class DiffViewModel { this.emitChangedSelectionEvent() } - async toggleSelectedLinesStageStatus (): Promise { - const linesByHunk = {} - for (let fileDiffIndex in this.selectionState) { - fileDiffIndex = parseInt(fileDiffIndex, 10) - let fileDiff = this.getFileDiffs()[fileDiffIndex] - let stage = true - let selectedHunkIndices = this.selectionState[fileDiffIndex] - for (let diffHunkIndex in selectedHunkIndices) { - diffHunkIndex = parseInt(diffHunkIndex, 10) - let selectedHunk = fileDiff.getHunks()[diffHunkIndex] - - const hunkLines = selectedHunk.getLines() - - let linesToStage = [] - - let selectedLineIndices = selectedHunkIndices[diffHunkIndex] - if (selectedLineIndices instanceof Set) { - for (let lineIndex of selectedLineIndices.values()) { - const line = hunkLines[lineIndex] - linesToStage.push(line) - } - } else if (selectedLineIndices) { - linesToStage = hunkLines - } + getSelectedLines (): Array { + const lines = [] - if (diffHunkIndex === 0) { - // TODO: This isn't terribly right. We're toggling all lines based on - // the state of the first line. - const firstLine = linesToStage[0] - stage = !firstLine.isStaged() - } + const fileDiff = this.getFileDiff() + const selectedHunkIndices = this.selectionState[0] + for (let diffHunkIndex in selectedHunkIndices) { + diffHunkIndex = parseInt(diffHunkIndex, 10) - for (let line of linesToStage) { - line.setIsStaged(stage) - } + const selectedHunk = fileDiff.getHunks()[diffHunkIndex] + const hunkLines = selectedHunk.getLines() - if (stage) { - linesByHunk[selectedHunk.toString()] = {linesToStage, linesToUnstage: []} - } else { - linesByHunk[selectedHunk.toString()] = {linesToStage: [], linesToUnstage: linesToStage} + 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) } - - const patches = await this.gitService.calculatePatchTexts(linesByHunk, stage) - await this.stagePatches(fileDiff, patches, stage) - // TODO: Handle errors. } - this.emitStagedEvent() + return lines } - async stagePatches (fileDiff: FileDiff, patches: Array, stage: boolean): Promise { - if (stage) { - return this.gitService.stagePatches(fileDiff, patches) - } else { - return this.gitService.unstagePatches(fileDiff, patches) - } + 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 + }, {}) } - 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()) + async toggleSelectedLinesStageStatus (): Promise { + const lines = this.getSelectedLines() + const groupedLines = this.groupedLinesByHunk(lines) + + // TODO: This is pretty wrong. + const stage = !lines[0].isStaged() + for (const line of lines) { + line.setIsStaged(stage) } - } - toggleHunkStageStatus (diffHunk: DiffHunk) { - if (diffHunk.getStageStatus() === 'unstaged') { - diffHunk.stage() - } else { // staged and partial hunks will be fully unstaged - diffHunk.unstage() + const patches = await this.gitService.calculatePatchTexts(groupedLines, stage) + + if (stage) { + await this.gitService.stagePatches(this.getFileDiff(), patches) + } else { + await this.gitService.unstagePatches(this.getFileDiff(), patches) } + + // TODO: handle errors + + this.emitStagedEvent() } + getFileDiff (): FileDiff { return this.fileDiffViewModel.fileDiff } + getFileDiffs (): Array { return [this.fileDiffViewModel.fileDiff] } diff --git a/lib/git-service.js b/lib/git-service.js index 95621bf9c5..ad3e88b446 100644 --- a/lib/git-service.js +++ b/lib/git-service.js @@ -275,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) } From 00071a4562f8bfd7614a5ff0887a1b7efa1bf6eb Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 14:10:36 -0400 Subject: [PATCH 14/20] Bring the staging specs back. --- spec/file-diff-spec.js | 170 +----------------------------- spec/file-list-view-model-spec.js | 130 ++++++++++++++++++++++- 2 files changed, 131 insertions(+), 169 deletions(-) diff --git a/spec/file-diff-spec.js b/spec/file-diff-spec.js index c358f5d55e..decb453b40 100644 --- a/spec/file-diff-spec.js +++ b/spec/file-diff-spec.js @@ -1,175 +1,9 @@ /** @babel */ -import {GitRepositoryAsync} from 'atom' -import path from 'path' -import fs from 'fs-plus' - -import FileListStore from '../lib/file-list-store' -import GitService from '../lib/git-service' -import {copyRepository, createDiffViewModel} from './helpers' -import {it, runs} from './async-spec-helpers' +import {createDiffViewModel} from './helpers' +import {it} from './async-spec-helpers' describe('FileDiff', function () { - xdescribe('staging', () => { - const fileName = 'README.md' - let repoPath - let filePath - let fileListStore - - let getDiff - let callAndWaitForEvent - - beforeEach(() => { - repoPath = copyRepository() - - const gitService = new GitService(GitRepositoryAsync.open(repoPath)) - - fileListStore = new FileListStore(gitService) - - filePath = path.join(repoPath, fileName) - - getDiff = async (fileName) => { - await fileListStore.loadFromGitUtils() - - const diff = fileListStore.getFileFromPathName(fileName) - expect(diff).toBeDefined() - - return diff - } - - callAndWaitForEvent = (fn) => { - const changeHandler = jasmine.createSpy() - runs(async () => { - fileListStore.onDidUpdate(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('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', async () => { const viewModel = await createDiffViewModel('src/config.coffee', 'dummy-atom') diff --git a/spec/file-list-view-model-spec.js b/spec/file-list-view-model-spec.js index a332a00945..e4ccce022a 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -1,6 +1,8 @@ /** @babel */ -import {beforeEach} from './async-spec-helpers' +import path from 'path' +import fs from 'fs-plus' +import {beforeEach, it} from './async-spec-helpers' import {createFileListViewModel} from './helpers' describe('FileListViewModel', function () { @@ -36,4 +38,130 @@ 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().loadFromGitUtils() + 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') + }) + }) + }) }) From b6b3d19ee42e04345da86172be8d38829dd79bb3 Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:00:26 -0400 Subject: [PATCH 15/20] Whoops. --- lib/git-service.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/git-service.js b/lib/git-service.js index ad3e88b446..a7b144f26b 100644 --- a/lib/git-service.js +++ b/lib/git-service.js @@ -463,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) From e0eb66e83a6a2f1e107a5bdfdaedd97776ec8e10 Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:00:44 -0400 Subject: [PATCH 16/20] Test line staging. --- spec/diff-view-model-spec.js | 130 +++++++++++++++++++++++++++++- spec/file-list-view-model-spec.js | 12 --- spec/helpers.js | 3 +- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/spec/diff-view-model-spec.js b/spec/diff-view-model-spec.js index 4ea7c825d5..81a1e7e43c 100644 --- a/spec/diff-view-model-spec.js +++ b/spec/diff-view-model-spec.js @@ -1,8 +1,11 @@ /** @babel */ +import path from 'path' +import fs from 'fs-plus' import DiffSelection from '../lib/diff-selection' -import {beforeEach} from './async-spec-helpers' -import {createDiffViewModel} from './helpers' +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') @@ -465,4 +468,127 @@ describe('DiffViewModel', function () { expect(args[1]).toEqual({initialLine: 554}) }) }) + + 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().loadFromGitUtils() + } + + 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/file-list-view-model-spec.js b/spec/file-list-view-model-spec.js index e4ccce022a..c4e200932e 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -62,12 +62,10 @@ describe('FileListViewModel', function () { 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') }) @@ -79,12 +77,10 @@ describe('FileListViewModel', function () { 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') }) @@ -98,12 +94,10 @@ describe('FileListViewModel', function () { 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') }) @@ -115,12 +109,10 @@ describe('FileListViewModel', function () { 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') }) @@ -134,12 +126,10 @@ describe('FileListViewModel', function () { 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') }) @@ -153,12 +143,10 @@ describe('FileListViewModel', function () { 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/helpers.js b/spec/helpers.js index bfc373f8d7..bb556b7dcc 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -75,5 +75,6 @@ export async function createFileListViewModel (name) { export async function createDiffViewModel (pathName, repoName) { const fileListViewModel = await createFileListViewModel(repoName) - return new DiffViewModel({pathName, fileListViewModel}) + const gitService = fileListViewModel.gitService + return new DiffViewModel({pathName, fileListViewModel, gitService}) } From 83912e3a679964b12e4fff1a626417ef760747b6 Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:16:05 -0400 Subject: [PATCH 17/20] :fire: fromString Fixes #100 --- lib/common.js | 28 ---------------------------- lib/diff-hunk.js | 25 +------------------------ lib/file-diff.js | 24 ++---------------------- lib/hunk-line.js | 20 -------------------- spec/diff-hunk-spec.js | 23 +++++------------------ 5 files changed, 8 insertions(+), 112 deletions(-) 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-hunk.js b/lib/diff-hunk.js index 31a65eda16..9511121229 100644 --- a/lib/diff-hunk.js +++ b/lib/diff-hunk.js @@ -72,33 +72,10 @@ export default class DiffHunk { } 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.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 { if (!hunk) return diff --git a/lib/file-diff.js b/lib/file-diff.js index 7866a0e375..38afbde0b0 100644 --- a/lib/file-diff.js +++ b/lib/file-diff.js @@ -2,7 +2,7 @@ import path from 'path' import DiffHunk from './diff-hunk' -import {createObjectsFromString, DiffURI} from './common' +import {DiffURI} from './common' import {ifNotNull} from './common' import type {ConvenientPatch, StatusFile} from 'nodegit' @@ -186,30 +186,10 @@ export default class FileDiff { } 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.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 [] diff --git a/lib/hunk-line.js b/lib/hunk-line.js index 35e7006ac3..c9eefe5ee6 100644 --- a/lib/hunk-line.js +++ b/lib/hunk-line.js @@ -60,26 +60,6 @@ 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.content = content || '' - this.lineOrigin = lineOrigin - this.setIsStaged(staged === '✓') - - this.oldLineNumber = oldLine === '---' ? null : parseInt(oldLine, 10) - this.newLineNumber = newLine === '---' ? null : parseInt(newLine, 10) - } - - static fromString (str: string): HunkLine { - const hunkLine = new HunkLine() - hunkLine.fromString(str) - return hunkLine - } - fromGitUtilsObject ({line, isStaged, hunk}: {line: Object, isStaged: boolean, hunk: DiffHunk}) { if (!line) return diff --git a/spec/diff-hunk-spec.js b/spec/diff-hunk-spec.js index 3e7d87d372..a0a1f59262 100644 --- a/spec/diff-hunk-spec.js +++ b/spec/diff-hunk-spec.js @@ -1,15 +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) + 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 () { @@ -38,14 +36,3 @@ describe('DiffHunk', function () { expect(diffHunk.getStageStatus()).toBe('unstaged') }) }) - -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:` From 0afdaafd335ac098622ee257d4daaca649ada738 Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:17:51 -0400 Subject: [PATCH 18/20] s/gitUtils/git --- lib/diff-hunk.js | 4 ++-- lib/file-diff.js | 4 ++-- lib/file-list-store.js | 8 ++++---- lib/git-package.js | 2 +- lib/hunk-line.js | 6 +++--- spec/diff-view-model-spec.js | 2 +- spec/file-diff-view-model-spec.js | 2 +- spec/file-list-view-model-spec.js | 2 +- spec/helpers.js | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/diff-hunk.js b/lib/diff-hunk.js index 9511121229..3392f23c4e 100644 --- a/lib/diff-hunk.js +++ b/lib/diff-hunk.js @@ -76,12 +76,12 @@ export default class DiffHunk { return `HUNK ${this.getHeader()}\n${lines}` } - 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) } diff --git a/lib/file-diff.js b/lib/file-diff.js index 38afbde0b0..03d38b9571 100644 --- a/lib/file-diff.js +++ b/lib/file-diff.js @@ -196,13 +196,13 @@ export default class FileDiff { 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) diff --git a/lib/file-list-store.js b/lib/file-list-store.js index be2d82b8d0..baec533a46 100644 --- a/lib/file-list-store.js +++ b/lib/file-list-store.js @@ -76,9 +76,9 @@ export default class FileListStore { // * 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 + // * Then and the FileDiff::loadFromGit is called and there are changes // in `config.js` - // * `loadFromGitUtils` will use + // * `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 @@ -112,7 +112,7 @@ export default class FileListStore { return this.files.map(file => file.toString()).join('\n') } - async loadFromGitUtils (): Promise { + async loadFromGit (): Promise { let files = [] const statuses = await this.gitService.getStatuses() @@ -146,7 +146,7 @@ export default class FileListStore { const stagedDiff = stagedDiffsByName[diff.newFile().path()] // $FlowFixMe const unstagedDiff = unstagedDiffsByName[diff.oldFile().path()] - await fileDiff.fromGitUtilsObject({diff, stagedDiff, unstagedDiff, statusFile}) + await fileDiff.fromGitObject({diff, stagedDiff, unstagedDiff, statusFile}) files.push(fileDiff) } diff --git a/lib/git-package.js b/lib/git-package.js index 1d908a09d4..1296b43aab 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -68,7 +68,7 @@ export default class GitPackage { const promises = [] promises.push(this.getFileListViewModel().update()) - promises.push(this.getFileListStore().loadFromGitUtils()) + promises.push(this.getFileListStore().loadFromGit()) const statusBarTile = this.statusBarTile if (statusBarTile) { diff --git a/lib/hunk-line.js b/lib/hunk-line.js index c9eefe5ee6..263fb73427 100644 --- a/lib/hunk-line.js +++ b/lib/hunk-line.js @@ -60,7 +60,7 @@ export default class HunkLine { return `${staged} ${oldLine} ${newLine} ${this.getLineOrigin() || ' '} ${this.getContent()}` } - 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 @@ -80,9 +80,9 @@ export default class HunkLine { } } - static fromGitUtilsObject (obj): HunkLine { + static fromGitObject (obj): HunkLine { const hunkLine = new HunkLine() - hunkLine.fromGitUtilsObject(obj) + hunkLine.fromGitObject(obj) return hunkLine } } diff --git a/spec/diff-view-model-spec.js b/spec/diff-view-model-spec.js index 81a1e7e43c..006f7a8bbf 100644 --- a/spec/diff-view-model-spec.js +++ b/spec/diff-view-model-spec.js @@ -498,7 +498,7 @@ describe('DiffViewModel', function () { } refresh = async () => { - await viewModel.fileListViewModel.getFileListStore().loadFromGitUtils() + await viewModel.fileListViewModel.getFileListStore().loadFromGit() } expectStatus = (expectedStatus) => { diff --git a/spec/file-diff-view-model-spec.js b/spec/file-diff-view-model-spec.js index 4fd4cd467b..9de1fad75f 100644 --- a/spec/file-diff-view-model-spec.js +++ b/spec/file-diff-view-model-spec.js @@ -11,7 +11,7 @@ import {waitsForPromise} from './async-spec-helpers' async function createDiffViewModel (gitService, fileName) { const fileListStore = new FileListStore(gitService) - await fileListStore.loadFromGitUtils() + await fileListStore.loadFromGit() const fileDiff = fileListStore.getFileFromPathName(fileName) expect(fileDiff).toBeDefined() diff --git a/spec/file-list-view-model-spec.js b/spec/file-list-view-model-spec.js index c4e200932e..5ebf9cf2a9 100644 --- a/spec/file-list-view-model-spec.js +++ b/spec/file-list-view-model-spec.js @@ -49,7 +49,7 @@ describe('FileListViewModel', function () { filePath = path.join(repoPath, 'README.md') getDiff = async (name) => { - await viewModel.getFileListStore().loadFromGitUtils() + await viewModel.getFileListStore().loadFromGit() return viewModel.getDiffForPathName(name) } }) diff --git a/spec/helpers.js b/spec/helpers.js index bb556b7dcc..d3d0bf7584 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -64,7 +64,7 @@ export async function createFileListStore (name) { const gitService = new GitService(GitRepositoryAsync.open(repoPath)) const fileListStore = new FileListStore(gitService) - await fileListStore.loadFromGitUtils() + await fileListStore.loadFromGit() return fileListStore } From 5b4d58110f471b751b04954e31f0cff540b5652b Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:35:29 -0400 Subject: [PATCH 19/20] Decompose the line staging even more. --- lib/diff-view-model.js | 29 ++--------------------------- lib/file-list-view-model.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index 68b17cb8ce..9f9698b529 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -12,7 +12,6 @@ import type FileDiff from './file-diff' import type FileListViewModel from './file-list-view-model' import type {SelectionMode} from './diff-selection' import type HunkLine from './hunk-line' -import type {ObjectMap} from './common' export default class DiffViewModel { gitService: GitService; @@ -293,40 +292,16 @@ export default class DiffViewModel { return lines } - 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 - }, {}) - } - async toggleSelectedLinesStageStatus (): Promise { const lines = this.getSelectedLines() - const groupedLines = this.groupedLinesByHunk(lines) - // TODO: This is pretty wrong. + // TODO: This is less then ideal. const stage = !lines[0].isStaged() for (const line of lines) { line.setIsStaged(stage) } - const patches = await this.gitService.calculatePatchTexts(groupedLines, stage) - - if (stage) { - await this.gitService.stagePatches(this.getFileDiff(), patches) - } else { - await this.gitService.unstagePatches(this.getFileDiff(), patches) - } - - // TODO: handle errors - - this.emitStagedEvent() + return this.fileListViewModel.stageLines(lines, stage) } getFileDiff (): FileDiff { return this.fileDiffViewModel.fileDiff } diff --git a/lib/file-list-view-model.js b/lib/file-list-view-model.js index a071bc330b..aa1a001b51 100644 --- a/lib/file-list-view-model.js +++ b/lib/file-list-view-model.js @@ -8,6 +8,8 @@ import CommitBoxViewModel from './commit-box-view-model' import type {Disposable} from 'atom' 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 { gitService: GitService; @@ -121,6 +123,40 @@ export default class FileListViewModel { 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)) } From 5b51c2caf9adb36593163ecb41f32341028605ca Mon Sep 17 00:00:00 2001 From: joshaber Date: Wed, 23 Mar 2016 15:39:27 -0400 Subject: [PATCH 20/20] DiffViewModel doesn't emit stage events anymore. --- lib/diff-view-model.js | 8 -------- lib/git-package.js | 5 +---- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/diff-view-model.js b/lib/diff-view-model.js index 9f9698b529..52e44da21b 100644 --- a/lib/diff-view-model.js +++ b/lib/diff-view-model.js @@ -71,14 +71,6 @@ export default class DiffViewModel { this.emitter.emit('did-update') } - onDidStage (callback: Function): Disposable { - return this.emitter.on('did-stage', callback) - } - - emitStagedEvent () { - this.emitter.emit('did-stage') - } - onDidChangeSelection (callback: Function): Disposable { return this.emitter.on('did-change-selection', callback) } diff --git a/lib/git-package.js b/lib/git-package.js index 1296b43aab..3f61507246 100644 --- a/lib/git-package.js +++ b/lib/git-package.js @@ -203,7 +203,7 @@ export default class GitPackage { const pathName = uri.replace(DiffURI, '') const gitService = this.getGitService() const fileListViewModel = this.getFileListViewModel() - const viewModel = new DiffViewModel({ + return new DiffViewModel({ gitService, fileListViewModel, uri, @@ -211,8 +211,5 @@ export default class GitPackage { pending, deserializer: 'GitDiffPaneItem' }) - viewModel.onDidStage(() => this.update()) - - return viewModel } }