blob: cbf12a340702f62c0397c3f8d9821219a87d4975 [file] [log] [blame]
// 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.editor;
import static com.google.gwt.dom.client.Style.Visibility.HIDDEN;
import static com.google.gwt.dom.client.Style.Visibility.VISIBLE;
import com.google.gerrit.client.DiffWebLinkInfo;
import com.google.gerrit.client.Dispatcher;
import com.google.gerrit.client.Gerrit;
import com.google.gerrit.client.JumpKeys;
import com.google.gerrit.client.VoidResult;
import com.google.gerrit.client.account.EditPreferences;
import com.google.gerrit.client.changes.ChangeApi;
import com.google.gerrit.client.changes.ChangeEditApi;
import com.google.gerrit.client.diff.DiffApi;
import com.google.gerrit.client.diff.DiffInfo;
import com.google.gerrit.client.diff.Header;
import com.google.gerrit.client.info.ChangeInfo;
import com.google.gerrit.client.info.FileInfo;
import com.google.gerrit.client.patches.PatchUtil;
import com.google.gerrit.client.rpc.CallbackGroup;
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.Natives;
import com.google.gerrit.client.rpc.RestApi;
import com.google.gerrit.client.rpc.ScreenLoadCallback;
import com.google.gerrit.client.ui.InlineHyperlink;
import com.google.gerrit.client.ui.Screen;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.extensions.client.KeyMapType;
import com.google.gerrit.extensions.client.Theme;
import com.google.gerrit.reviewdb.client.Patch;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.KeyPressEvent;
import com.google.gwt.event.logical.shared.ResizeEvent;
import com.google.gwt.event.logical.shared.ResizeHandler;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.resources.client.CssResource;
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.Window;
import com.google.gwt.user.client.Window.ClosingEvent;
import com.google.gwt.user.client.Window.ClosingHandler;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.CheckBox;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.HTMLPanel;
import com.google.gwt.user.client.ui.ImageResourceRenderer;
import com.google.gwtexpui.globalkey.client.GlobalKey;
import com.google.gwtexpui.safehtml.client.SafeHtml;
import java.util.List;
import net.codemirror.addon.AddonInjector;
import net.codemirror.addon.Addons;
import net.codemirror.lib.CodeMirror;
import net.codemirror.lib.CodeMirror.ChangesHandler;
import net.codemirror.lib.CodeMirror.CommandRunner;
import net.codemirror.lib.Configuration;
import net.codemirror.lib.KeyMap;
import net.codemirror.lib.MergeView;
import net.codemirror.lib.Pos;
import net.codemirror.mode.ModeInfo;
import net.codemirror.mode.ModeInjector;
import net.codemirror.theme.ThemeLoader;
public class EditScreen extends Screen {
interface Binder extends UiBinder<HTMLPanel, EditScreen> {}
private static final Binder uiBinder = GWT.create(Binder.class);
interface Style extends CssResource {
String fullWidth();
String base();
String hideBase();
}
@Nullable private Project.NameKey projectKey;
private final PatchSet.Id revision;
private final String path;
private final int startLine;
private EditPreferences prefs;
private EditPreferencesAction editPrefsAction;
private MergeView mv;
private CodeMirror cmBase;
private CodeMirror cmEdit;
private HttpResponse<NativeString> content;
private HttpResponse<NativeString> baseContent;
private EditFileInfo editFileInfo;
private JsArray<DiffWebLinkInfo> diffLinks;
@UiField Element header;
@UiField Element project;
@UiField Element filePath;
@UiField FlowPanel linkPanel;
@UiField Element cursLine;
@UiField Element cursCol;
@UiField Element dirty;
@UiField CheckBox showBase;
@UiField Button close;
@UiField Button save;
@UiField Element editor;
@UiField Style style;
private HandlerRegistration resizeHandler;
private HandlerRegistration closeHandler;
private int generation;
public EditScreen(@Nullable Project.NameKey projectKey, Patch.Key patch, int startLine) {
this.projectKey = projectKey;
this.revision = patch.getParentKey();
this.path = patch.get();
this.startLine = startLine - 1;
setRequiresSignIn(true);
add(uiBinder.createAndBindUi(this));
addDomHandler(GlobalKey.STOP_PROPAGATION, KeyPressEvent.getType());
}
@Override
protected void onInitUI() {
super.onInitUI();
setHeaderVisible(false);
setWindowTitle(FileInfo.getFileName(path));
}
@Override
protected void onLoad() {
super.onLoad();
prefs = EditPreferences.create(Gerrit.getEditPreferences());
CallbackGroup group1 = new CallbackGroup();
final CallbackGroup group2 = new CallbackGroup();
final CallbackGroup group3 = new CallbackGroup();
CodeMirror.initLibrary(
group1.add(
new AsyncCallback<Void>() {
final AsyncCallback<Void> themeCallback = group3.addEmpty();
@Override
public void onSuccess(Void result) {
// Load theme after CM library to ensure theme can override CSS.
ThemeLoader.loadTheme(prefs.theme(), themeCallback);
group2.done();
new AddonInjector()
.add(Addons.I.merge_bundled().getName())
.inject(
new AsyncCallback<Void>() {
@Override
public void onFailure(Throwable caught) {}
@Override
public void onSuccess(Void result) {
if (!prefs.showBase() || revision.get() > 0) {
group3.done();
}
}
});
}
@Override
public void onFailure(Throwable caught) {}
}));
ChangeApi.detail(
Project.NameKey.asStringOrNull(projectKey),
revision.getParentKey().get(),
group1.add(
new AsyncCallback<ChangeInfo>() {
@Override
public void onSuccess(ChangeInfo c) {
projectKey = c.projectNameKey();
project.setInnerText(c.project());
SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
}
@Override
public void onFailure(Throwable caught) {}
}));
if (revision.get() == 0) {
ChangeEditApi.getMeta(
Project.NameKey.asStringOrNull(projectKey),
revision,
path,
group1.add(
new AsyncCallback<EditFileInfo>() {
@Override
public void onSuccess(EditFileInfo editInfo) {
editFileInfo = editInfo;
}
@Override
public void onFailure(Throwable e) {}
}));
if (prefs.showBase()) {
ChangeEditApi.get(
projectKey,
revision,
path,
true /* base */,
group1.addFinal(
new HttpCallback<NativeString>() {
@Override
public void onSuccess(HttpResponse<NativeString> fc) {
baseContent = fc;
group3.done();
}
@Override
public void onFailure(Throwable e) {}
}));
} else {
group1.done();
}
} else {
// TODO(davido): We probably want to create dedicated GET EditScreenMeta
// REST endpoint. Abuse GET diff for now, as it retrieves links we need.
DiffApi.diff(Project.NameKey.asStringOrNull(projectKey), revision, path)
.webLinksOnly()
.get(
group1.addFinal(
new AsyncCallback<DiffInfo>() {
@Override
public void onSuccess(DiffInfo diffInfo) {
diffLinks = diffInfo.webLinks();
}
@Override
public void onFailure(Throwable e) {}
}));
}
ChangeEditApi.get(
projectKey,
revision,
path,
group2.add(
new HttpCallback<NativeString>() {
final AsyncCallback<Void> modeCallback = group3.addEmpty();
@Override
public void onSuccess(HttpResponse<NativeString> fc) {
content = fc;
if (revision.get() > 0) {
baseContent = fc;
}
if (prefs.syntaxHighlighting()) {
injectMode(fc.getContentType(), modeCallback);
} else {
modeCallback.onSuccess(null);
}
}
@Override
public void onFailure(Throwable e) {
// "Not Found" means it's a new file.
if (RestApi.isNotFound(e)) {
content = null;
modeCallback.onSuccess(null);
} else {
GerritCallback.showFailure(e);
}
}
}));
group3.addListener(
new ScreenLoadCallback<Void>(this) {
@Override
protected void preDisplay(Void result) {
initEditor();
renderLinks(editFileInfo, diffLinks);
editFileInfo = null;
diffLinks = null;
showBase.setValue(prefs.showBase(), true);
cmBase.refresh();
}
});
}
@Override
public void registerKeys() {
super.registerKeys();
KeyMap localKeyMap = KeyMap.create();
localKeyMap.on("Ctrl-L", gotoLine()).on("Cmd-L", gotoLine()).on("Cmd-S", save());
// TODO(davido): Find a better way to prevent key maps collisions
if (prefs.keyMapType() != KeyMapType.EMACS) {
localKeyMap.on("Ctrl-S", save());
}
cmBase.addKeyMap(localKeyMap);
cmEdit.addKeyMap(localKeyMap);
}
private Runnable gotoLine() {
return () -> cmEdit.execCommand("jumpToLine");
}
@Override
public void onShowView() {
super.onShowView();
Window.enableScrolling(false);
JumpKeys.enable(false);
if (prefs.hideTopMenu()) {
Gerrit.setHeaderVisible(false);
}
resizeHandler =
Window.addResizeHandler(
new ResizeHandler() {
@Override
public void onResize(ResizeEvent event) {
adjustHeight();
}
});
closeHandler =
Window.addWindowClosingHandler(
new ClosingHandler() {
@Override
public void onWindowClosing(ClosingEvent event) {
if (!cmEdit.isClean(generation)) {
event.setMessage(EditConstants.I.closeUnsavedChanges());
}
}
});
generation = cmEdit.changeGeneration(true);
setClean(true);
cmEdit.on(
new ChangesHandler() {
@Override
public void handle(CodeMirror cm) {
setClean(cm.isClean(generation));
}
});
adjustHeight();
cmEdit.on("cursorActivity", updateCursorPosition());
setShowTabs(prefs.showTabs());
setLineLength(prefs.lineLength());
cmEdit.refresh();
cmEdit.focus();
if (startLine > 0) {
cmEdit.scrollToLine(startLine);
}
updateActiveLine();
editPrefsAction = new EditPreferencesAction(this, prefs);
}
@Override
protected void onUnload() {
super.onUnload();
if (cmBase != null) {
cmBase.getWrapperElement().removeFromParent();
}
if (cmEdit != null) {
cmEdit.getWrapperElement().removeFromParent();
}
if (resizeHandler != null) {
resizeHandler.removeHandler();
}
if (closeHandler != null) {
closeHandler.removeHandler();
}
Window.enableScrolling(true);
Gerrit.setHeaderVisible(true);
JumpKeys.enable(true);
}
CodeMirror getEditor() {
return cmEdit;
}
@UiHandler("editSettings")
void onEditSetting(@SuppressWarnings("unused") ClickEvent e) {
editPrefsAction.show();
}
@UiHandler("save")
void onSave(@SuppressWarnings("unused") ClickEvent e) {
save().run();
}
@UiHandler("close")
void onClose(@SuppressWarnings("unused") ClickEvent e) {
if (cmEdit.isClean(generation) || Window.confirm(EditConstants.I.cancelUnsavedChanges())) {
upToChange();
}
}
private void displayBase() {
cmBase.getWrapperElement().getParentElement().removeClassName(style.hideBase());
cmEdit.getWrapperElement().getParentElement().removeClassName(style.fullWidth());
mv.getGapElement().removeClassName(style.hideBase());
setCmBaseValue();
setLineLength(prefs.lineLength());
cmBase.refresh();
}
@UiHandler("showBase")
void onShowBase(ValueChangeEvent<Boolean> e) {
boolean shouldShow = e.getValue();
if (shouldShow) {
if (baseContent == null) {
ChangeEditApi.get(
projectKey,
revision,
path,
true /* base */,
new HttpCallback<NativeString>() {
@Override
public void onSuccess(HttpResponse<NativeString> fc) {
baseContent = fc;
displayBase();
}
@Override
public void onFailure(Throwable e) {}
});
} else {
displayBase();
}
} else {
cmBase.getWrapperElement().getParentElement().addClassName(style.hideBase());
cmEdit.getWrapperElement().getParentElement().addClassName(style.fullWidth());
mv.getGapElement().addClassName(style.hideBase());
}
mv.setShowDifferences(shouldShow);
}
void setOption(String option, String value) {
cmBase.setOption(option, value);
cmEdit.setOption(option, value);
}
void setOption(String option, boolean value) {
cmBase.setOption(option, value);
cmEdit.setOption(option, value);
}
void setOption(String option, double value) {
cmBase.setOption(option, value);
cmEdit.setOption(option, value);
}
void setTheme(Theme newTheme) {
cmBase.operation(() -> cmBase.setOption("theme", newTheme.name().toLowerCase()));
cmEdit.operation(() -> cmEdit.setOption("theme", newTheme.name().toLowerCase()));
}
void setLineLength(int length) {
int adjustedLength = Patch.COMMIT_MSG.equals(path) ? 72 : length;
cmBase.extras().lineLength(adjustedLength);
cmEdit.extras().lineLength(adjustedLength);
}
void setIndentUnit(int indent) {
cmEdit.setOption("indentUnit", Patch.COMMIT_MSG.equals(path) ? 2 : indent);
}
void setShowLineNumbers(boolean show) {
cmBase.setOption("lineNumbers", show);
cmEdit.setOption("lineNumbers", show);
}
void setShowWhitespaceErrors(boolean show) {
cmBase.operation(() -> cmBase.setOption("showTrailingSpace", show));
cmEdit.operation(() -> cmEdit.setOption("showTrailingSpace", show));
}
void setShowTabs(boolean show) {
cmBase.extras().showTabs(show);
cmEdit.extras().showTabs(show);
}
void adjustHeight() {
int height = header.getOffsetHeight();
int rest = Gerrit.getHeaderFooterHeight() + height + 5; // Estimate
mv.getGapElement().getStyle().setHeight(Window.getClientHeight() - rest, Unit.PX);
cmBase.adjustHeight(height);
cmEdit.adjustHeight(height);
}
void setSyntaxHighlighting(boolean b) {
ModeInfo modeInfo = ModeInfo.findMode(content.getContentType(), path);
final String mode = modeInfo != null ? modeInfo.mime() : null;
if (b && mode != null && !mode.isEmpty()) {
injectMode(
mode,
new AsyncCallback<Void>() {
@Override
public void onSuccess(Void result) {
cmBase.setOption("mode", mode);
cmEdit.setOption("mode", mode);
}
@Override
public void onFailure(Throwable caught) {
prefs.syntaxHighlighting(false);
}
});
} else {
cmBase.setOption("mode", (String) null);
cmEdit.setOption("mode", (String) null);
}
}
private void upToChange() {
Gerrit.display(PageLinks.toChangeInEditMode(projectKey, revision.getParentKey()));
}
private void initEditor() {
ModeInfo mode = null;
String editContent = "";
if (content != null && content.getResult() != null) {
editContent = content.getResult().asString();
if (prefs.syntaxHighlighting()) {
mode = ModeInfo.findMode(content.getContentType(), path);
}
}
Configuration cfg =
Configuration.create()
.set("autoCloseBrackets", prefs.autoCloseBrackets())
.set("cursorBlinkRate", prefs.cursorBlinkRate())
.set("cursorHeight", 0.85)
.set("indentUnit", prefs.indentUnit())
.set("keyMap", prefs.keyMapType().name().toLowerCase())
.set("lineNumbers", prefs.hideLineNumbers())
.set("lineWrapping", prefs.lineWrapping())
.set("indentWithTabs", prefs.indentWithTabs())
.set("matchBrackets", prefs.matchBrackets())
.set("mode", mode != null ? mode.mime() : null)
.set("origLeft", editContent)
.set("scrollbarStyle", "overlay")
.set("showTrailingSpace", prefs.showWhitespaceErrors())
.set("styleSelectedText", true)
.set("tabSize", prefs.tabSize())
.set("theme", prefs.theme().name().toLowerCase())
.set("value", "");
if (editContent.contains("\r\n")) {
cfg.set("lineSeparator", "\r\n");
}
mv = MergeView.create(editor, cfg);
cmBase = mv.leftOriginal();
cmBase.getWrapperElement().addClassName(style.base());
cmEdit = mv.editor();
setCmBaseValue();
cmEdit.setValue(editContent);
CodeMirror.addCommand(
"save",
new CommandRunner() {
@Override
public void run(CodeMirror instance) {
save().run();
}
});
}
private void renderLinks(EditFileInfo editInfo, JsArray<DiffWebLinkInfo> diffLinks) {
renderLinksToDiff();
if (editInfo != null) {
renderLinks(Natives.asList(editInfo.webLinks()));
} else if (diffLinks != null) {
renderLinks(Natives.asList(diffLinks));
}
}
private void renderLinks(List<DiffWebLinkInfo> links) {
if (links != null) {
for (DiffWebLinkInfo webLink : links) {
linkPanel.add(webLink.toAnchor());
}
}
}
private void renderLinksToDiff() {
InlineHyperlink sbs = new InlineHyperlink();
sbs.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
sbs.setTargetHistoryToken(
Dispatcher.toPatch(projectKey, "sidebyside", null, new Patch.Key(revision, path)));
sbs.setTitle(PatchUtil.C.sideBySideDiff());
linkPanel.add(sbs);
InlineHyperlink unified = new InlineHyperlink();
unified.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
unified.setTargetHistoryToken(
Dispatcher.toPatch(projectKey, "unified", null, new Patch.Key(revision, path)));
unified.setTitle(PatchUtil.C.unifiedDiff());
linkPanel.add(unified);
}
private Runnable updateCursorPosition() {
return () -> {
// The rendering of active lines has to be deferred. Reflow
// caused by adding and removing styles chokes Firefox when arrow
// key (or j/k) is held down. Performance on Chrome is fine
// without the deferral.
//
Scheduler.get().scheduleDeferred(() -> cmEdit.operation(this::updateActiveLine));
};
}
private void updateActiveLine() {
Pos p = cmEdit.getCursor("end");
cursLine.setInnerText(Integer.toString(p.line() + 1));
cursCol.setInnerText(Integer.toString(p.ch() + 1));
cmEdit.extras().activeLine(cmEdit.getLineHandleVisualStart(p.line()));
}
private void setClean(boolean clean) {
save.setEnabled(!clean);
close.setEnabled(true);
dirty.getStyle().setVisibility(!clean ? VISIBLE : HIDDEN);
}
private Runnable save() {
return () -> {
if (!cmEdit.isClean(generation)) {
close.setEnabled(false);
String text = cmEdit.getValue();
if (Patch.COMMIT_MSG.equals(path)) {
String trimmed = text.trim() + "\r";
if (!trimmed.equals(text)) {
text = trimmed;
cmEdit.setValue(text);
}
}
final int g = cmEdit.changeGeneration(false);
ChangeEditApi.put(
Project.NameKey.asStringOrNull(projectKey),
revision.getParentKey().get(),
path,
text,
new GerritCallback<VoidResult>() {
@Override
public void onSuccess(VoidResult result) {
generation = g;
setClean(cmEdit.isClean(g));
}
@Override
public void onFailure(Throwable caught) {
close.setEnabled(true);
}
});
}
};
}
private void injectMode(String type, AsyncCallback<Void> cb) {
new ModeInjector().add(type).inject(cb);
}
private void setCmBaseValue() {
cmBase.setValue(
baseContent != null && baseContent.getResult() != null
? baseContent.getResult().asString()
: "");
}
}