Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Discard lines in file diff #487

Merged
merged 60 commits into from
Feb 10, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
5178dbf
Make Unstaged Changes div focusable
kuychaco Jan 24, 2017
e10790b
Rename command for discarding changes in file
kuychaco Jan 24, 2017
eb6dcbe
Add discardChangesInBuffer function with tests
kuychaco Jan 26, 2017
476e12a
Modify buffer in transaction to allow undoing discard actions
kuychaco Jan 26, 2017
e3c4daf
Implement GitController#discardLines and test
kuychaco Jan 26, 2017
eb6d14e
Add discard selection context menu option for untaged file diff
kuychaco Jan 26, 2017
6ff442f
Add TODO to handle no-new-line case
kuychaco Jan 26, 2017
0263946
:fire: console.log
kuychaco Jan 26, 2017
9bd128a
Add createBlob and restoreBlob to GitShellOutStrategy
kuychaco Jan 27, 2017
569a1fa
Create FileDiscardHistory model
kuychaco Jan 27, 2017
2044feb
Add ability to undo last discard action
kuychaco Jan 27, 2017
4a5a489
Fix borked test
kuychaco Jan 27, 2017
3d0df0f
Throw error if there's no match when discarding added lines
kuychaco Jan 27, 2017
097782b
Add FilePatch-header
simurai Jan 24, 2017
803e8d5
:shirt:
kuychaco Jan 27, 2017
853b630
Merge remote-tracking branch 'origin/master' into ku-discard-lines
kuychaco Jan 27, 2017
5093966
Wire up buttons in file diff header
kuychaco Jan 27, 2017
e2a9737
Move discard context menu items to separate section
kuychaco Jan 27, 2017
8455e3f
Add to GitShellOutStrategy#isPartiallyStaged
kuychaco Jan 27, 2017
2b1fdc0
Only show diff view button for viewing corresonding diff if one exists
kuychaco Jan 27, 2017
42c6a61
Align FilePatchView-title
simurai Jan 27, 2017
d1862a8
Add `github:view-corresponding-diff` command
kuychaco Jan 27, 2017
2650171
Add command `github:undo-last-file-diff-discard`
kuychaco Jan 27, 2017
d672567
:shirt:
kuychaco Jan 27, 2017
3f4ce84
Undo discard with `cmd-z`
kuychaco Jan 27, 2017
9a5747e
:fire: Remove toggle styles
simurai Jan 27, 2017
bba2bfc
Truncate FilePatchView-title
simurai Jan 27, 2017
cac3ccf
Merge branch 'ku-discard-lines' of https://github.com/atom/github int…
simurai Jan 27, 2017
357a813
Perform safety check before performing destructive action to avoid races
kuychaco Jan 27, 2017
2adf514
Dispose of newly created buffer after discard
kuychaco Jan 27, 2017
d72302d
:fire: comment
kuychaco Jan 27, 2017
fb0d10a
:art: use `until` in tests
kuychaco Feb 1, 2017
038c09f
Fix test
kuychaco Feb 1, 2017
2f218fd
Fix flakey tests using `until`
kuychaco Feb 1, 2017
9c8a2e0
:art:
kuychaco Feb 1, 2017
455cf45
:shirt:
kuychaco Feb 1, 2017
9ebd6a7
Quietly select corresponding item in StagingView
kuychaco Feb 2, 2017
be5e400
Use `core:undo` instead of `cmd-z`
kuychaco Feb 5, 2017
1738ea5
:art: Create `CannotRestoreError`
kuychaco Feb 5, 2017
34bdd81
Log non-CannotRestoreError errors
kuychaco Feb 5, 2017
d6799a0
:art: use assert.async.isTrue rather than until
kuychaco Feb 5, 2017
5281ad9
:art: use assert.async.equal instead of until
kuychaco Feb 5, 2017
8073c37
:shirt:
kuychaco Feb 5, 2017
bb3d953
Allow blob creation with stdin in GSOS#createBlob
kuychaco Feb 5, 2017
771c582
Fix test description
kuychaco Feb 5, 2017
03a9a68
Add GSOS#getBlobContents(sha)
kuychaco Feb 5, 2017
11960f9
Persist discard history in git config across refresh and Atom windows
kuychaco Feb 8, 2017
3f3633d
Limit discard history to prevent unbounded growth
kuychaco Feb 8, 2017
0a222b3
Merge remote-tracking branch 'origin/master' into ku-discard-lines
kuychaco Feb 8, 2017
4057109
Clear discard history if snapshot is expired
kuychaco Feb 9, 2017
18d768e
:shirt:
kuychaco Feb 9, 2017
3dd6f96
Add button in file diff to stage or unstage all
kuychaco Feb 9, 2017
1a048a0
Clean up window focus listener
kuychaco Feb 9, 2017
7ffcfb4
Set default discard history to empty object
kuychaco Feb 9, 2017
684d729
getHistoryForPath -> getLastHistorySnapshotsForPath
kuychaco Feb 10, 2017
50b306d
Open pre-discard versin of file in new buffer
kuychaco Feb 10, 2017
a219518
Move hasUndoHistory method from GitController to FilePatchController
kuychaco Feb 10, 2017
7e62f54
Add test for openFileInNewBuffer
kuychaco Feb 10, 2017
b788334
Fix tests
kuychaco Feb 10, 2017
06cfcd5
:art:
kuychaco Feb 10, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/controllers/file-patch-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,26 @@ export default class FilePatchController {
);
} else {
// NOTE: Outer div is required for etch to render elements correctly
const filePath = this.props.filePatch.getPath();
const hasUndoHistory = this.props.repository ? this.hasUndoHistory() : false;
return (
<div className="github-PaneView pane-item">
<FilePatchView
ref="filePatchView"
commandRegistry={this.props.commandRegistry}
attemptLineStageOperation={this.attemptLineStageOperation}
didSurfaceFile={this.didSurfaceFile}
didDiveIntoCorrespondingFilePatch={this.diveIntoCorrespondingFilePatch}
attemptHunkStageOperation={this.attemptHunkStageOperation}
hunks={hunks}
filePath={filePath}
stagingStatus={this.props.stagingStatus}
isPartiallyStaged={this.props.isPartiallyStaged}
registerHunkView={this.props.registerHunkView}
openCurrentFile={this.openCurrentFile}
discardLines={this.props.discardLines}
undoLastDiscard={this.undoLastDiscard}
hasUndoHistory={hasUndoHistory}
/>
</div>
);
Expand Down Expand Up @@ -164,6 +172,14 @@ export default class FilePatchController {
}
}

@autobind
diveIntoCorrespondingFilePatch() {
const filePath = this.props.filePatch.getPath();
const stagingStatus = this.props.stagingStatus === 'staged' ? 'unstaged' : 'staged';
this.props.quietlySelectItem(filePath, stagingStatus);
return this.props.didDiveIntoFilePath(filePath, stagingStatus, {amending: this.props.isAmending});
}

didUpdateFilePatch() {
// FilePatch was mutated so all we need to do is re-render
return etch.update(this);
Expand All @@ -187,4 +203,14 @@ export default class FilePatchController {
textEditor.setCursorBufferPosition(position);
return textEditor;
}

@autobind
undoLastDiscard() {
return this.props.undoLastDiscard(this.props.filePatch.getPath());
}

@autobind
hasUndoHistory() {
return this.props.repository.hasUndoHistory(this.props.filePatch.getPath());
}
}
108 changes: 106 additions & 2 deletions lib/controllers/git-controller.js
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';
Expand All @@ -18,11 +18,14 @@ 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';
import {CannotRestoreError} from '../models/file-discard-history';

const nullFilePatchState = {
filePath: null,
filePatch: null,
stagingStatus: null,
partiallyStaged: null,
};

export default class GitController extends React.Component {
Expand Down Expand Up @@ -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}
quietlySelectItem={this.quietlySelectItem}
openFiles={this.openFiles}
discardLines={this.discardLines}
undoLastDiscard={this.undoLastDiscard}
/>
</EtchWrapper>
</PaneItem>
Expand All @@ -211,9 +220,10 @@ export default class GitController extends React.Component {

const staged = stagingStatus === 'staged';
const filePatch = await repository.getFilePatchForPath(filePath, {staged, amending: 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();
Expand Down Expand Up @@ -336,4 +346,98 @@ 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.'});
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
};
const snapshots = await this.props.repository.storeBeforeAndAfterBlobs(relFilePath, isSafe, () => {
this.discardChangesInBuffer(buffer, this.state.filePatch, lines);
});
if (disposable) { disposable.dispose(); }
return snapshots;
}

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.notifyInabilityToUndo(relFilePath, 'You have unsaved changes.');
return false;
}
return true;
};
try {
await this.props.repository.attemptToRestoreBlob(filePath, isSafe);
} catch (e) {
if (e instanceof CannotRestoreError) {
this.notifyInabilityToUndo(relFilePath, 'Contents have been modified since last discard.');
} else if (e.stdErr.match(/fatal: Not a valid object name/)) {
this.notifyInabilityToUndo(relFilePath, 'Discard history has expired.');
this.props.repository.clearDiscardHistoryForPath(filePath);
} else {
// eslint-disable-next-line no-console
console.error(e);
}
}
}

notifyInabilityToUndo(filePath, description) {
const openPreDiscardVersion = () => this.openFileBeforeLastDiscard(filePath);
this.props.notificationManager.addError(
'Cannot undo last discard.',
{
description: `${description} Would you like to open pre-discard version of "${filePath}" in new buffer?`,
buttons: [{
text: 'Open in new buffer',
onDidClick: openPreDiscardVersion,
dismissable: true,
}],
},
);
}

async openFileBeforeLastDiscard(filePath) {
const {beforeSha} = await this.props.repository.getLastHistorySnapshotsForPath(filePath);
const contents = await this.props.repository.getBlobContents(beforeSha);
const editor = await this.props.workspace.open();
editor.setText(contents);
return editor;
}

@autobind
quietlySelectItem(filePath, stagingStatus) {
if (this.gitPanelController) {
return this.gitPanelController.getWrappedComponent().quietlySelectItem(filePath, stagingStatus);
} else {
return null;
}
}
}
5 changes: 5 additions & 0 deletions lib/controllers/git-panel-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,9 @@ export default class GitPanelController {
isFocused() {
return this.refs.gitPanel.isFocused();
}

@autobind
quietlySelectItem(filePath, stagingStatus) {
return this.refs.gitPanel.quitelySelectItem(filePath, stagingStatus);
}
}
35 changes: 35 additions & 0 deletions lib/discard-changes-in-buffer.js
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 => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chipmunk hunk?!?!

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();
}
40 changes: 38 additions & 2 deletions lib/git-shell-out-strategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/;

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -99,6 +99,9 @@ export default class GitShellOutStrategy {
err.command = formattedArgs;
return Promise.reject(err);
}
if (redirectFilePath) {
return writeFile(redirectFilePath, stdout);
}
return stdout;
});
});
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -445,6 +461,26 @@ export default class GitShellOutStrategy {
return [];
}
}

async createBlob({filePath, stdin} = {}) {
let output;
if (filePath) {
output = await this.exec(['hash-object', '-w', filePath]);
} else if (stdin) {
output = await this.exec(['hash-object', '-w', '--stdin'], stdin);
} else {
throw new Error('Must supply file path or stdin');
}
return output.trim();
}

async restoreBlob(filePath, sha) {
await this.exec(['cat-file', '-p', sha], null, null, path.join(this.workingDir, filePath));
}

async getBlobContents(sha) {
return await this.exec(['cat-file', '-p', sha]);
}
}

function buildAddedFilePatch(filePath, contents, stats) {
Expand Down
8 changes: 8 additions & 0 deletions lib/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 fs.writeFile and friends return Promises automatically if you omitted the callback, but now I can't find it. It's certainly not in Node 6.5.0, though.

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 => {
Expand Down
Loading