-
Notifications
You must be signed in to change notification settings - Fork 393
Discard lines in file diff #487
Changes from 36 commits
5178dbf
e10790b
eb6dcbe
476e12a
e3c4daf
eb6d14e
6ff442f
0263946
9bd128a
569a1fa
2044feb
4a5a489
3d0df0f
097782b
803e8d5
853b630
5093966
e2a9737
8455e3f
2b1fdc0
42c6a61
d1862a8
2650171
d672567
3f4ce84
9a5747e
bba2bfc
cac3ccf
357a813
2adf514
d72302d
fb0d10a
038c09f
2f218fd
9c8a2e0
455cf45
9ebd6a7
be5e400
1738ea5
34bdd81
d6799a0
5281ad9
8073c37
bb3d953
771c582
03a9a68
11960f9
3f3633d
0a222b3
4057109
18d768e
3dd6f96
1a048a0
7ffcfb4
684d729
50b306d
a219518
7e62f54
b788334
06cfcd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
import path from 'path'; | ||
|
||
import {CompositeDisposable, Disposable, File} from 'atom'; | ||
import {CompositeDisposable, Disposable, File, TextBuffer} from 'atom'; | ||
|
||
import React from 'react'; | ||
import {autobind} from 'core-decorators'; | ||
|
@@ -18,11 +18,13 @@ import GitPanelController from './git-panel-controller'; | |
import StatusBarTileController from './status-bar-tile-controller'; | ||
import ModelObserver from '../models/model-observer'; | ||
import ModelStateRegistry from '../models/model-state-registry'; | ||
import discardChangesInBuffer from '../discard-changes-in-buffer'; | ||
|
||
const nullFilePatchState = { | ||
filePath: null, | ||
filePatch: null, | ||
stagingStatus: null, | ||
partiallyStaged: null, | ||
}; | ||
|
||
export default class GitController extends React.Component { | ||
|
@@ -166,6 +168,7 @@ export default class GitController extends React.Component { | |
} | ||
|
||
renderFilePatchController() { | ||
const hasUndoHistory = this.hasUndoHistory(); | ||
return ( | ||
<div> | ||
<Commands registry={this.props.commandRegistry} target="atom-workspace"> | ||
|
@@ -182,9 +185,15 @@ export default class GitController extends React.Component { | |
commandRegistry={this.props.commandRegistry} | ||
filePatch={this.state.filePatch} | ||
stagingStatus={this.state.stagingStatus} | ||
isAmending={this.state.amending} | ||
isPartiallyStaged={this.state.partiallyStaged} | ||
onRepoRefresh={this.onRepoRefresh} | ||
didSurfaceFile={this.surfaceFromFileAtPath} | ||
didDiveIntoFilePath={this.diveIntoFilePatchForPath} | ||
openFiles={this.openFiles} | ||
discardLines={this.discardLines} | ||
undoLastDiscard={this.undoLastDiscard} | ||
hasUndoHistory={hasUndoHistory} | ||
/> | ||
</EtchWrapper> | ||
</PaneItem> | ||
|
@@ -210,9 +219,10 @@ export default class GitController extends React.Component { | |
if (!repository) { return null; } | ||
|
||
const filePatch = await repository.getFilePatchForPath(filePath, {staged: stagingStatus === 'staged', amending}); | ||
const partiallyStaged = await repository.isPartiallyStaged(filePath); | ||
return new Promise(resolve => { | ||
if (filePatch) { | ||
this.setState({filePath, filePatch, stagingStatus}, () => { | ||
this.setState({filePath, filePatch, stagingStatus, partiallyStaged}, () => { | ||
// TODO: can be better done w/ a prop? | ||
if (activate && this.filePatchControllerPane) { | ||
this.filePatchControllerPane.activate(); | ||
|
@@ -335,4 +345,70 @@ export default class GitController extends React.Component { | |
handleChangeTab(activeTab) { | ||
this.setState({activeTab}); | ||
} | ||
|
||
@autobind | ||
async discardLines(lines) { | ||
const relFilePath = this.state.filePatch.getPath(); | ||
const absfilePath = path.join(this.props.repository.getWorkingDirectoryPath(), relFilePath); | ||
let buffer, disposable; | ||
const isSafe = async () => { | ||
const editor = this.props.workspace.getTextEditors().find(e => e.getPath() === absfilePath); | ||
if (editor) { | ||
buffer = editor.getBuffer(); | ||
if (buffer.isModified()) { | ||
this.props.notificationManager.addError('Cannot discard lines.', {description: 'You have unsaved changes.'}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! |
||
return false; | ||
} | ||
} else { | ||
buffer = new TextBuffer({filePath: absfilePath, load: true}); | ||
await new Promise(resolve => { | ||
disposable = buffer.onDidReload(() => { | ||
disposable.dispose(); | ||
resolve(); | ||
}); | ||
}); | ||
} | ||
return true; | ||
}; | ||
await this.props.repository.storeBeforeAndAfterBlobs(relFilePath, isSafe, () => { | ||
this.discardChangesInBuffer(buffer, this.state.filePatch, lines); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential race condition here with user interaction. If we create a text buffer for an unopened file, and the user modified the file, when we save the buffer at the end of To mitigate this risk, we can take the before snapshot before checking if the buffer is modified, bail out if it is, and then discard changes and take the after snapshot. That way the safety check and the destructive operation are right next to each other leaving less room for race issues. |
||
}); | ||
if (disposable) { disposable.dispose(); } | ||
} | ||
|
||
discardChangesInBuffer(buffer, filePatch, lines) { | ||
discardChangesInBuffer(buffer, filePatch, lines); | ||
} | ||
|
||
@autobind | ||
async undoLastDiscard(filePath) { | ||
const relFilePath = this.state.filePatch.getPath(); | ||
const absfilePath = path.join(this.props.repository.getWorkingDirectoryPath(), relFilePath); | ||
const isSafe = () => { | ||
const editor = this.props.workspace.getTextEditors().find(e => e.getPath() === absfilePath); | ||
if (editor && editor.getBuffer().isModified()) { | ||
this.props.notificationManager.addError( | ||
'Cannot undo last discard.', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like there's some duplication here that could be refactored out, maybe an |
||
{description: 'You have unsaved changes.'}, | ||
); | ||
return false; | ||
} | ||
return true; | ||
}; | ||
try { | ||
await this.props.repository.attemptToRestoreBlob(filePath, isSafe); | ||
} catch (e) { | ||
if (e.message === 'Cannot restore file. Contents have been modified since last discard.') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than key on the exact error message here I'd personally add another attribute to the const e = new Error('Cannot undo last discard.');
e.userFacing = true;
throw e; try {
} catch (e) {
if (e.isUserFacing) {
// ...
} Feels a little less fragile to me. If we change the wording of the error message later -- or introduce some internationalization -- we don't need to update it in two places. |
||
this.props.notificationManager.addError( | ||
'Cannot undo last discard.', | ||
{description: 'Contents have been modified since last discard.'}, | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, also, this drops any other errors on the floor completely. Maybe at least |
||
} | ||
} | ||
|
||
@autobind | ||
hasUndoHistory() { | ||
return this.props.repository.hasUndoHistory(this.state.filePatch.getPath()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
export default function discardChangesInBuffer(buffer, filePatch, discardedLines) { | ||
buffer.transact(() => { | ||
let addedCount = 0; | ||
let removedCount = 0; | ||
let deletedCount = 0; | ||
filePatch.getHunks().forEach(hunk => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
hunk.getLines().forEach(line => { | ||
if (discardedLines.has(line)) { | ||
if (line.status === 'deleted') { | ||
const row = (line.oldLineNumber - deletedCount) + addedCount - removedCount - 1; | ||
buffer.insert([row, 0], line.text + '\n'); | ||
addedCount++; | ||
} else if (line.status === 'added') { | ||
const row = line.newLineNumber + addedCount - removedCount - 1; | ||
if (buffer.lineForRow(row) === line.text) { | ||
buffer.deleteRow(row); | ||
removedCount++; | ||
} else { | ||
throw new Error(buffer.lineForRow(row) + ' does not match ' + line.text); | ||
} | ||
} else if (line.status === 'nonewline') { | ||
// TODO: handle no new line case | ||
} else { | ||
throw new Error(`unrecognized status: ${line.status}. Must be 'added' or 'deleted'`); | ||
} | ||
} | ||
if (line.getStatus() === 'deleted') { | ||
deletedCount++; | ||
} | ||
}); | ||
}); | ||
}); | ||
|
||
buffer.save(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ import {parse as parseDiff} from 'what-the-diff'; | |
|
||
import GitPromptServer from './git-prompt-server'; | ||
import AsyncQueue from './async-queue'; | ||
import {readFile, fsStat, deleteFileOrFolder} from './helpers'; | ||
import {readFile, writeFile, fsStat, deleteFileOrFolder} from './helpers'; | ||
|
||
const LINE_ENDING_REGEX = /\r?\n/; | ||
|
||
|
@@ -36,7 +36,7 @@ export default class GitShellOutStrategy { | |
} | ||
|
||
// Execute a command and read the output using the embedded Git environment | ||
exec(args, stdin = null, useGitPromptServer = false) { | ||
exec(args, stdin = null, useGitPromptServer = false, redirectFilePath = null) { | ||
/* eslint-disable no-console */ | ||
const subscriptions = new CompositeDisposable(); | ||
return this.commandQueue.push(async () => { | ||
|
@@ -99,6 +99,9 @@ export default class GitShellOutStrategy { | |
err.command = formattedArgs; | ||
return Promise.reject(err); | ||
} | ||
if (redirectFilePath) { | ||
return writeFile(redirectFilePath, stdout); | ||
} | ||
return stdout; | ||
}); | ||
}); | ||
|
@@ -268,6 +271,19 @@ export default class GitShellOutStrategy { | |
return rawDiffs[0]; | ||
} | ||
|
||
async isPartiallyStaged(filePath) { | ||
const args = ['status', '--short', '--', filePath]; | ||
const output = await this.exec(args); | ||
const results = output.trim().split(LINE_ENDING_REGEX); | ||
if (results.length === 2) { | ||
return true; | ||
} else if (results.length === 1) { | ||
return ['MM', 'AM', 'MD'].includes(results[0].slice(0, 2)); | ||
} else { | ||
throw new Error(`Unexpected output for ${args.join(' ')}: ${output}`); | ||
} | ||
} | ||
|
||
/** | ||
* Miscellaneous getters | ||
*/ | ||
|
@@ -445,6 +461,15 @@ export default class GitShellOutStrategy { | |
return []; | ||
} | ||
} | ||
|
||
async createBlob(filePath) { | ||
const output = await this.exec(['hash-object', '-w', filePath]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So convenient ✨ |
||
return output.trim(); | ||
} | ||
|
||
async restoreBlob(filePath, sha) { | ||
await this.exec(['cat-file', '-p', sha], null, null, path.join(this.workingDir, filePath)); | ||
} | ||
} | ||
|
||
function buildAddedFilePatch(filePath, contents, stats) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,14 @@ export function readFile(absoluteFilePath, encoding = 'utf8') { | |
}); | ||
} | ||
|
||
export function writeFile(absoluteFilePath, contents) { | ||
return new Promise((resolve, reject) => { | ||
fs.writeFile(absoluteFilePath, contents, err => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could swear I saw a pull request on node.js that would make Fake edit: here it is; nodejs/node#5020. Looks like it won't be in Node 6 in any case. |
||
if (err) { return reject(err); } else { return resolve(); } | ||
}); | ||
}); | ||
} | ||
|
||
export function deleteFileOrFolder(path) { | ||
return new Promise((resolve, reject) => { | ||
fs.remove(path, err => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
export default class FileDiscardHistory { | ||
constructor(createBlob, restoreBlob) { | ||
this.createBlob = createBlob; | ||
this.restoreBlob = restoreBlob; | ||
this.blobHistoryByFilePath = new Map(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, these will vanish if you relaunch Atom. Any way we could store them in the serialized package state, or maybe The specific scenario where this would break would be:
|
||
} | ||
|
||
async storeBlobs(filePath, isSafe, destructiveAction) { | ||
const beforeSha = await this.createBlob(filePath); | ||
const isNotSafe = !(await isSafe()); | ||
if (isNotSafe) { return; } | ||
destructiveAction(); | ||
const afterSha = await this.createBlob(filePath); | ||
const snapshots = {beforeSha, afterSha}; | ||
const history = this.blobHistoryByFilePath.get(filePath); | ||
if (history) { | ||
history.push(snapshots); | ||
} else { | ||
this.blobHistoryByFilePath.set(filePath, [snapshots]); | ||
} | ||
} | ||
|
||
async attemptToRestoreBlob(filePath, isSafe) { | ||
const history = this.blobHistoryByFilePath.get(filePath); | ||
const {beforeSha, afterSha} = history[history.length - 1]; | ||
const currentSha = await this.createBlob(filePath); | ||
if (currentSha === afterSha && isSafe()) { | ||
await this.restoreBlob(filePath, beforeSha); | ||
history.pop(); | ||
} else { | ||
throw new Error('Cannot restore file. Contents have been modified since last discard.'); | ||
} | ||
} | ||
|
||
hasUndoHistory(filePath) { | ||
const history = this.blobHistoryByFilePath.get(filePath); | ||
return !!history && history.length > 0; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are the tradeoffs here between using the
core:undo
event, versus creating our own github-specific undo command and binding it to the same default key thatcore:undo
uses?Using
core:undo
core:undo
. If someone's customized theircore:undo
keystroke for whatever reason, we could automatically be consistent.core:undo
will collide with existing implementations. I think this means that users would have no way to do a "real" undo without some keymap shenanigans.Creating new
github:
-specific undo commandgithub:
namespace in the command palette with "undo" actions.