Inline Edit: Suggest paths to add

Offer a suggestion box completing any known file path in the
repository whose path name contains the letters offered by the user.
E.g. "AddFile" would suggest the first three files of this change.

Instead of loading content into a small primitive text area, load the
file into an EditScreen where CodeMirror is configured.  This makes it
easier to begin editing the file right away and do something useful
with it.

Change-Id: I2d34bdb65b909def24ffc56e8136efd305fec3b1
This commit is contained in:
Shawn Pearce
2015-01-02 22:03:20 -08:00
parent a368744fb4
commit 8ca03a6455
10 changed files with 304 additions and 305 deletions

View File

@@ -2744,6 +2744,11 @@ The request parameter `reviewed` changes the response to return a list
of the paths the caller has marked as reviewed. Clients that also
need the FileInfo should make two requests.
The request parameter `q` changes the response to return a list
of all files (modified or unmodified) that contain that substring
in the path name. This is useful to implement suggestion services
finding a file by partial name.
.Request
----
GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/?reviewed HTTP/1.0

View File

@@ -0,0 +1,71 @@
//Copyright (C) 2013 The Android Open Source Project
//
//Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
//
//http://www.apache.org/licenses/LICENSE-2.0
//
//Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
package com.google.gerrit.client.change;
import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwtexpui.globalkey.client.GlobalKey;
import com.google.gwtexpui.user.client.PluginSafePopupPanel;
class AddFileAction {
private final Change.Id changeId;
private final RevisionInfo revision;
private final ChangeScreen2.Style style;
private final Widget addButton;
private AddFileBox addBox;
private PopupPanel popup;
AddFileAction(Change.Id changeId, RevisionInfo revision,
ChangeScreen2.Style style, Widget addButton) {
this.changeId = changeId;
this.revision = revision;
this.style = style;
this.addButton = addButton;
}
public void onEdit() {
if (popup != null) {
popup.hide();
return;
}
if (addBox == null) {
addBox = new AddFileBox(changeId, revision);
}
addBox.clearPath();
final PluginSafePopupPanel p = new PluginSafePopupPanel(true);
p.setStyleName(style.replyBox());
p.addAutoHidePartner(addButton.getElement());
p.addCloseHandler(new CloseHandler<PopupPanel>() {
@Override
public void onClose(CloseEvent<PopupPanel> event) {
if (popup == p) {
popup = null;
}
}
});
p.add(addBox);
p.showRelativeTo(addButton);
GlobalKey.dialog(p);
addBox.setFocus(true);
popup = p;
}
}

View File

@@ -0,0 +1,159 @@
//Copyright (C) 2013 The Android Open Source Project
//
//Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
//
//http://www.apache.org/licenses/LICENSE-2.0
//
//Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
package com.google.gerrit.client.change;
import com.google.gerrit.client.Dispatcher;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.changes.ChangeApi;
import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
import com.google.gerrit.client.rpc.Natives;
import com.google.gerrit.client.ui.RemoteSuggestBox;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
import com.google.gwt.event.logical.shared.SelectionEvent;
import com.google.gwt.event.logical.shared.SelectionHandler;
import com.google.gwt.uibinder.client.UiBinder;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.uibinder.client.UiHandler;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.HTMLPanel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class AddFileBox extends Composite {
interface Binder extends UiBinder<HTMLPanel, AddFileBox> {}
private static final Binder uiBinder = GWT.create(Binder.class);
private final Change.Id changeId;
private final RevisionInfo revision;
@UiField Button open;
@UiField Button cancel;
@UiField(provided = true)
RemoteSuggestBox path;
AddFileBox(Change.Id changeId, RevisionInfo revision) {
this.changeId = changeId;
this.revision = revision;
path = new RemoteSuggestBox(new PathSuggestOracle());
path.addSelectionHandler(new SelectionHandler<String>() {
@Override
public void onSelection(SelectionEvent<String> event) {
open(event.getSelectedItem());
}
});
path.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
@Override
public void onClose(CloseEvent<RemoteSuggestBox> event) {
hide();
}
});
initWidget(uiBinder.createAndBindUi(this));
}
void setFocus(boolean focus) {
path.setFocus(focus);
}
void clearPath() {
path.setText("");
}
@UiHandler("open")
void onOpen(@SuppressWarnings("unused") ClickEvent e) {
open(path.getText());
}
private void open(String path) {
hide();
Gerrit.display(Dispatcher.toEditScreen(
new PatchSet.Id(changeId, revision._number()),
path));
}
@UiHandler("cancel")
void onCancel(@SuppressWarnings("unused") ClickEvent e) {
hide();
}
private void hide() {
for (Widget w = getParent(); w != null; w = w.getParent()) {
if (w instanceof PopupPanel) {
((PopupPanel) w).hide();
break;
}
}
}
private class PathSuggestOracle extends HighlightSuggestOracle {
@Override
protected void onRequestSuggestions(final Request req, final Callback cb) {
ChangeApi.revision(changeId.get(), revision.name())
.view("files")
.addParameter("q", req.getQuery())
.background()
.get(new AsyncCallback<JsArrayString>() {
@Override
public void onSuccess(JsArrayString result) {
List<Suggestion> r = new ArrayList<>();
for (String path : Natives.asList(result)) {
r.add(new PathSuggestion(path));
}
cb.onSuggestionsReady(req, new Response(r));
}
@Override
public void onFailure(Throwable caught) {
List<Suggestion> none = Collections.emptyList();
cb.onSuggestionsReady(req, new Response(none));
}
});
}
}
private static class PathSuggestion implements Suggestion {
private final String path;
PathSuggestion(String path) {
this.path = path;
}
@Override
public String getDisplayString() {
return path;
}
@Override
public String getReplacementString() {
return path;
}
}
}

View File

@@ -16,40 +16,22 @@ limitations under the License.
-->
<ui:UiBinder
xmlns:ui='urn:ui:com.google.gwt.uibinder'
xmlns:c='urn:import:com.google.gwtexpui.globalkey.client'
xmlns:f='urn:import:com.google.gerrit.client.change'
xmlns:u='urn:import:com.google.gerrit.client.ui'
xmlns:g='urn:import:com.google.gwt.user.client.ui'>
<ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
<ui:style>
.fileContent {
background-color: white;
font-family: monospace;
}
.cancel { float: right; }
</ui:style>
<g:HTMLPanel>
<div class='{res.style.section}'>
<div>
<ui:msg>Path:</ui:msg>
</div>
<div>
<f:FileTextBox ui:field='file' visibleLength='79'/>
</div>
<div>
<ui:msg>Content:</ui:msg>
</div>
<c:NpTextArea
visibleLines='30'
characterWidth='78'
styleName='{style.fileContent}'
ui:field='content'/>
<ui:msg>Path: <u:RemoteSuggestBox ui:field='path' visibleLength='86'/></ui:msg>
</div>
<div class='{res.style.section}'>
<g:Button ui:field='save'
title='Create new revision edit'
<g:Button ui:field='open'
title='Open file in editor'
styleName='{res.style.button}'>
<ui:attribute name='title'/>
<div><ui:msg>Save</ui:msg></div>
<div><ui:msg>Open</ui:msg></div>
</g:Button>
<g:Button ui:field='cancel'
styleName='{res.style.button}'

View File

@@ -193,7 +193,7 @@ public class ChangeScreen2 extends Screen {
private IncludedInAction includedInAction;
private PatchSetsAction patchSetsAction;
private DownloadAction downloadAction;
private EditFileAction editFileAction;
private AddFileAction addFileAction;
public ChangeScreen2(Change.Id changeId, String base, String revision,
boolean openReplyBox, FileTable.Mode mode) {
@@ -448,9 +448,9 @@ public class ChangeScreen2 extends Screen {
editMode.setVisible(fileTableMode == FileTable.Mode.REVIEW);
addFile.setVisible(!editMode.isVisible());
reviewMode.setVisible(!editMode.isVisible());
editFileAction = new EditFileAction(
new PatchSet.Id(changeId, edit == null ? rev._number() : 0),
"", "", style, editMessage, reply);
addFileAction = new AddFileAction(
changeId, info.revision(revision),
style, addFile);
} else {
editMode.setVisible(false);
addFile.setVisible(false);
@@ -625,7 +625,7 @@ public class ChangeScreen2 extends Screen {
@UiHandler("addFile")
void onAddFile(@SuppressWarnings("unused") ClickEvent e) {
editFileAction.onEdit();
addFileAction.onEdit();
}
private void refreshFileTable() {

View File

@@ -1,80 +0,0 @@
//Copyright (C) 2013 The Android Open Source Project
//
//Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
//
//http://www.apache.org/licenses/LICENSE-2.0
//
//Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
package com.google.gerrit.client.change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwtexpui.globalkey.client.GlobalKey;
import com.google.gwtexpui.user.client.PluginSafePopupPanel;
class EditFileAction {
private final PatchSet.Id id;
private final String content;
private final String file;
private final ChangeScreen2.Style style;
private final Widget editMessageButton;
private final Widget relativeTo;
private EditFileBox editBox;
private PopupPanel popup;
EditFileAction(
PatchSet.Id id,
String content,
String file,
ChangeScreen2.Style style,
Widget editButton,
Widget relativeTo) {
this.id = id;
this.content = content;
this.file = file;
this.style = style;
this.editMessageButton = editButton;
this.relativeTo = relativeTo;
}
public void onEdit() {
if (popup != null) {
popup.hide();
return;
}
if (editBox == null) {
editBox = new EditFileBox(
id,
content,
file);
}
final PluginSafePopupPanel p = new PluginSafePopupPanel(true);
p.setStyleName(style.replyBox());
p.addAutoHidePartner(editMessageButton.getElement());
p.addCloseHandler(new CloseHandler<PopupPanel>() {
@Override
public void onClose(CloseEvent<PopupPanel> event) {
if (popup == p) {
popup = null;
}
}
});
p.add(editBox);
p.showRelativeTo(relativeTo);
GlobalKey.dialog(p);
popup = p;
}
}

View File

@@ -1,119 +0,0 @@
//Copyright (C) 2013 The Android Open Source Project
//
//Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
//
//http://www.apache.org/licenses/LICENSE-2.0
//
//Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
package com.google.gerrit.client.change;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.VoidResult;
import com.google.gerrit.client.changes.ChangeEditApi;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.ui.TextBoxChangeListener;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.uibinder.client.UiBinder;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.uibinder.client.UiHandler;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.HTMLPanel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.TextBoxBase;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwtexpui.globalkey.client.NpTextArea;
class EditFileBox extends Composite {
interface Binder extends UiBinder<HTMLPanel, EditFileBox> {}
private static final Binder uiBinder = GWT.create(Binder.class);
private final PatchSet.Id id;
private final String fileName;
private final String fileContent;
@UiField FileTextBox file;
@UiField NpTextArea content;
@UiField Button save;
@UiField Button cancel;
EditFileBox(
PatchSet.Id id,
String fileC,
String fileName) {
this.id = id;
this.fileName = fileName;
this.fileContent = fileC;
initWidget(uiBinder.createAndBindUi(this));
new EditFileBoxListener(content);
new EditFileBoxListener(file);
}
@Override
protected void onLoad() {
file.set(id, content);
file.setText(fileName);
file.setEnabled(fileName.isEmpty());
content.setText(fileContent);
save.setEnabled(false);
Scheduler.get().scheduleDeferred(new ScheduledCommand() {
@Override
public void execute() {
if (fileName.isEmpty()) {
file.setFocus(true);
} else {
content.setFocus(true);
}
}});
}
@UiHandler("save")
void onSave(@SuppressWarnings("unused") ClickEvent e) {
ChangeEditApi.put(id.getParentKey().get(), file.getText(), content.getText(),
new GerritCallback<VoidResult>() {
@Override
public void onSuccess(VoidResult result) {
Gerrit.display(PageLinks.toChangeInEditMode(id.getParentKey()));
hide();
}
});
}
@UiHandler("cancel")
void onCancel(@SuppressWarnings("unused") ClickEvent e) {
hide();
}
protected void hide() {
for (Widget w = getParent(); w != null; w = w.getParent()) {
if (w instanceof PopupPanel) {
((PopupPanel) w).hide();
break;
}
}
}
private class EditFileBoxListener extends TextBoxChangeListener {
public EditFileBoxListener(TextBoxBase base) {
super(base);
}
@Override
public void onTextChanged(String newText) {
save.setEnabled(!file.getText().trim().isEmpty()
&& !newText.trim().equals(fileContent));
}
}
}

View File

@@ -1,73 +0,0 @@
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.client.change;
import com.google.gerrit.client.changes.ChangeEditApi;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.rpc.HttpCallback;
import com.google.gerrit.client.rpc.HttpResponse;
import com.google.gerrit.client.rpc.NativeString;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gwt.event.dom.client.BlurEvent;
import com.google.gwt.event.dom.client.BlurHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwtexpui.globalkey.client.NpTextArea;
import com.google.gwtexpui.globalkey.client.NpTextBox;
class FileTextBox extends NpTextBox {
private HandlerRegistration blurHandler;
private NpTextArea textArea;
private PatchSet.Id id;
@Override
protected void onLoad() {
blurHandler = addBlurHandler(new BlurHandler() {
@Override
public void onBlur(BlurEvent event) {
loadFileContent();
}
});
}
@Override
protected void onUnload() {
super.onUnload();
blurHandler.removeHandler();
}
void set(PatchSet.Id id, NpTextArea content) {
this.id = id;
this.textArea = content;
}
private void loadFileContent() {
ChangeEditApi.get(id, getText(), new HttpCallback<NativeString>() {
@Override
public void onSuccess(HttpResponse<NativeString> result) {
textArea.setText(result.getResult().asString());
}
@Override
public void onFailure(Throwable caught) {
if (RestApi.isNotFound(caught)) {
// that means that the file doesn't exist in the repository
} else {
GerritCallback.showFailure(caught);
}
}
});
}
}

View File

@@ -187,7 +187,7 @@ class RevisionApiImpl extends RevisionApi.NotImplemented implements RevisionApi
return ImmutableSet.copyOf((Iterable<String>) listFiles
.get().setReviewed(true)
.apply(revision).value());
} catch (OrmException e) {
} catch (OrmException | IOException e) {
throw new RestApiException("Cannot list reviewed files", e);
}
}

View File

@@ -43,8 +43,11 @@ import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
@@ -53,6 +56,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -96,6 +100,9 @@ public class Files implements ChildCollection<RevisionResource, FileResource> {
@Option(name = "--reviewed")
boolean reviewed;
@Option(name = "-q")
String query;
private final Provider<ReviewDb> db;
private final Provider<CurrentUser> self;
private final FileInfoJson fileInfoJson;
@@ -125,11 +132,13 @@ public class Files implements ChildCollection<RevisionResource, FileResource> {
@Override
public Response<?> apply(RevisionResource resource) throws AuthException,
BadRequestException, ResourceNotFoundException, OrmException {
if (base != null && reviewed) {
throw new BadRequestException("cannot combine base and reviewed");
} else if (reviewed) {
BadRequestException, ResourceNotFoundException, OrmException,
RepositoryNotFoundException, IOException {
checkOptions();
if (reviewed) {
return Response.ok(reviewed(resource));
} else if (query != null) {
return Response.ok(query(resource));
}
PatchSet basePatchSet = null;
@@ -152,6 +161,51 @@ public class Files implements ChildCollection<RevisionResource, FileResource> {
}
}
private void checkOptions() throws BadRequestException {
int supplied = 0;
if (base != null) {
supplied++;
}
if (reviewed) {
supplied++;
}
if (query != null) {
supplied++;
}
if (supplied > 1) {
throw new BadRequestException("cannot combine base, reviewed, query");
}
}
private List<String> query(RevisionResource resource)
throws RepositoryNotFoundException, IOException {
Repository git =
gitManager.openRepository(resource.getChange().getProject());
try {
TreeWalk tw = new TreeWalk(git);
try {
RevCommit c = new RevWalk(tw.getObjectReader())
.parseCommit(ObjectId.fromString(
resource.getPatchSet().getRevision().get()));
tw.addTree(c.getTree());
tw.setRecursive(true);
List<String> paths = new ArrayList<>();
while (tw.next() && paths.size() < 20) {
String s = tw.getPathString();
if (s.contains(query)) {
paths.add(s);
}
}
return paths;
} finally {
tw.release();
}
} finally {
git.close();
}
}
private List<String> reviewed(RevisionResource resource)
throws AuthException, OrmException {
CurrentUser user = self.get();