doortts doortts 2017-03-08
code: Support simplified online commit
@2f7e25aa82e9a1e71e03904acfca8f7971422410
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -6766,3 +6766,21 @@
     padding-top: 5px;
     clear: both;
 }
+
+.file-path-wrap {
+    font-size: 16px;
+    .new-file-name {
+        padding: 0 0;
+        margin-bottom: 0;
+        margin-left: 3px;
+        font-size: 16px;
+        border: none;
+        border-bottom: 1px solid #999;
+        border-radius: 0 !important;
+        height: 36px;
+    }
+}
+
+#new-file-link {
+    margin: 0 5px;
+}
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -18,7 +18,6 @@
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.ObjectId;
-import play.api.data.validation.ValidationError;
 import play.data.Form;
 import play.db.ebean.Transactional;
 import play.libs.Json;
@@ -173,6 +172,11 @@
             preparedBodyText = StringUtils.defaultIfBlank(project.getIssueTemplate(), "");
         }
 
+        if (textFileEditRequested()) {
+            preparedBodyText = GitUtil.getReadTextFile(project,
+                    getBranchNameFromQueryString(), request().getQueryString("path"));
+        }
+
         return ok(create.render("post.new", new Form<>(Posting.class), project, isAllowedToNotice, preparedBodyText));
     }
 
@@ -186,6 +190,14 @@
 
     private static boolean issueTemplateEditRequested() {
         return request().getQueryString("issueTemplate") != null;
+    }
+
+    private static boolean textFileEditRequested() {
+        return request().getQueryString("path") != null;
+    }
+
+    private static String getBranchNameFromQueryString() {
+        return request().getQueryString("branch");
     }
 
     @Transactional
@@ -225,6 +237,11 @@
             return redirect(routes.ProjectApp.project(project.owner, projectName));
         }
 
+        if(StringUtils.isNotEmpty(post.path) && UserApp.currentUser().isMemberOf(project)){
+            GitUtil.commitTextFile(project, post.branch, post.path, post.body, post.title);
+            return redirect(routes.CodeApp.codeBrowserWithBranch(project.owner, project.name, post.branch, HttpUtil.getEncodeEachPathName(post.path)));
+        }
+
         post.save();
         attachUploadFilesToPost(post.asResource());
         NotificationEvent.afterNewPost(post);
app/controllers/CodeApp.java
--- app/controllers/CodeApp.java
+++ app/controllers/CodeApp.java
@@ -1,23 +1,9 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2012 NAVER Corp.
- * http://yobi.io
- *
- * @author Ahn Hyeok Jun
- *
- * 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.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
 package controllers;
 
 import actions.CodeAccessCheckAction;
app/models/Posting.java
--- app/models/Posting.java
+++ app/models/Posting.java
@@ -29,6 +29,14 @@
     @Transient
     public String issueTemplate;
 
+    //ToDo: Sperate it from posting for online commit
+    @Transient
+    public String path;
+
+    //ToDo: Sperate it from posting for online commit
+    @Transient
+    public String branch;
+
     @OneToMany(cascade = CascadeType.ALL)
     public List<PostingComment> comments;
 
app/playRepository/BareCommit.java
--- app/playRepository/BareCommit.java
+++ app/playRepository/BareCommit.java
@@ -22,15 +22,27 @@
 
 import models.Project;
 import models.User;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
+import org.eclipse.jgit.api.errors.JGitInternalException;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.*;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.TreeWalk;
 import utils.Config;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.channels.OverlappingFileLockException;
+import java.text.MessageFormat;
 
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -232,4 +244,134 @@
     public void setRefName(String refName){
         this.refName = refName;
     }
+
+    // Bare commit. It is referenced from https://gist.github.com/porcelli/3882505
+    public ObjectId commitTextFile(final String branchName, final String path, String text, final String message) throws IOException {
+        this.file = new File(this.repository.getDirectory(), path);
+        org.apache.commons.io.FileUtils.write(this.file, text);
+
+        ObjectId commitId = null;
+        Git git = new Git(this.repository);
+        try (final ObjectInserter inserter = git.getRepository().newObjectInserter()) {
+                // Create the in-memory index of the new/updated issue.
+                this.headObjectId = git.getRepository().resolve(this.refName + "^{commit}");
+                final DirCache index = createTemporaryIndex(git, this.headObjectId, path, file);
+                final ObjectId indexTreeId = index.writeTree(inserter);
+
+                // Create a commit object
+                final CommitBuilder commit = getCommitBuilder(message, indexTreeId);
+
+                // Insert the commit into the repository
+                commitId = inserter.insert(commit);
+                inserter.flush();
+
+                final RefUpdate ru = getRefUpdate(branchName, commitId, git);
+                final RefUpdate.Result rc = ru.forceUpdate();
+                switch (rc) {
+                    case NEW:
+                    case FORCED:
+                    case FAST_FORWARD:
+                        break;
+                    case REJECTED:
+                    case LOCK_FAILURE:
+                        throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD, ru.getRef(), rc);
+                    default:
+                        throw new JGitInternalException(MessageFormat.format(JGitText.get().updatingRefFailed, Constants.HEAD, commitId.toString(), rc));
+                }
+        } catch (final Throwable t) {
+            throw new RuntimeException(t);
+        }
+
+        return commitId;
+    }
+
+    private RefUpdate getRefUpdate(String branchName, ObjectId commitId, Git git) throws IOException {
+        final RevWalk revWalk = new RevWalk(git.getRepository());
+        final RevCommit revCommit = revWalk.parseCommit(commitId);
+        final RefUpdate ru = git.getRepository().updateRef("refs/heads/" + branchName);
+        if (this.headObjectId == null) {
+            ru.setExpectedOldObjectId(ObjectId.zeroId());
+        } else {
+            ru.setExpectedOldObjectId(this.headObjectId);
+        }
+        ru.setNewObjectId(commitId);
+        ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false);
+        revWalk.close();
+        return ru;
+    }
+
+    private CommitBuilder getCommitBuilder(String message, ObjectId indexTreeId) {
+        final CommitBuilder commit = new CommitBuilder();
+        commit.setAuthor(this.getPersonIdent());
+        commit.setCommitter(this.getPersonIdent());
+        commit.setEncoding(Constants.CHARACTER_ENCODING);
+        commit.setMessage(message);
+        //headId can be null if the repository has no commit yet
+        if (this.headObjectId != null) {
+            commit.setParentId(this.headObjectId);
+        }
+        commit.setTreeId(indexTreeId);
+        return commit;
+    }
+
+    private static DirCache createTemporaryIndex(final Git git, final ObjectId headId, final String path, final File file) {
+        final DirCache inCoreIndex = DirCache.newInCore();
+        final DirCacheBuilder dcBuilder = inCoreIndex.builder();
+        final ObjectInserter inserter = git.getRepository().newObjectInserter();
+
+        try {
+            if (file != null) {
+                final DirCacheEntry dcEntry = new DirCacheEntry(path);
+                dcEntry.setLength(file.length());
+                dcEntry.setLastModified(file.lastModified());
+                dcEntry.setFileMode(FileMode.REGULAR_FILE);
+
+                final InputStream inputStream = new FileInputStream(file);
+                try {
+                    dcEntry.setObjectId(inserter.insert(Constants.OBJ_BLOB, file.length(), inputStream));
+                } finally {
+                    inputStream.close();
+                }
+
+                dcBuilder.add(dcEntry);
+            }
+
+            if (headId != null) {
+                final TreeWalk treeWalk = new TreeWalk(git.getRepository());
+                final int hIdx = treeWalk.addTree(new RevWalk(git.getRepository()).parseTree(headId));
+                treeWalk.setRecursive(true);
+
+                while (treeWalk.next()) {
+                    final String walkPath = treeWalk.getPathString();
+                    final CanonicalTreeParser hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class);
+
+                    if (!walkPath.equals(path)) {
+                        // add entries from HEAD for all other paths
+                        // create a new DirCacheEntry with data retrieved from HEAD
+                        final DirCacheEntry dcEntry = new DirCacheEntry(walkPath);
+                        dcEntry.setObjectId(hTree.getEntryObjectId());
+                        dcEntry.setFileMode(hTree.getEntryFileMode());
+
+                        // add to temporary in-core index
+                        dcBuilder.add(dcEntry);
+                    }
+                }
+                treeWalk.close();
+            }
+
+            dcBuilder.finish();
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        } finally {
+            inserter.close();
+        }
+
+        if (file == null) {
+            final DirCacheEditor editor = inCoreIndex.editor();
+            editor.add(new DirCacheEditor.DeleteTree(path));
+            editor.finish();
+        }
+
+        return inCoreIndex;
+    }
 }
 
app/utils/GitUtil.java (added)
+++ app/utils/GitUtil.java
@@ -0,0 +1,40 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
+package utils;
+
+import controllers.UserApp;
+import models.Project;
+import org.eclipse.jgit.lib.ObjectId;
+import playRepository.BareCommit;
+import playRepository.RepositoryService;
+
+import java.io.IOException;
+
+public class GitUtil {
+    public static String getReadTextFile(Project project, String branchName, String filenameWithPath) {
+        try {
+            byte[] bytes = RepositoryService.getRepository(project)
+                    .getRawFile(branchName, filenameWithPath);
+            return new String(bytes, FileUtil.detectCharset(bytes));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    public synchronized static void commitTextFile(Project project, String branchName, String path, String text, String msg) {
+        BareCommit bare = new BareCommit(project, UserApp.currentUser());
+        bare.setRefName(branchName);
+        ObjectId objectId = null;
+        try {
+            objectId = bare.commitTextFile(branchName, path, text, msg);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+        play.Logger.debug("Online Commit: " + project.name + ":" + path + ":" + objectId);
+    }
+
+}
app/utils/HttpUtil.java
--- app/utils/HttpUtil.java
+++ app/utils/HttpUtil.java
@@ -218,4 +218,14 @@
             return targetStr;
         }
     }
+
+    // It is made for path which contains UTF8 chars
+    public static String getEncodeEachPathName(String path){
+        String[] paths = path.split("/");
+        String[] encodedPaths = new String[paths.length];
+        for ( int i = 0; i < paths.length; i++ ) {
+            encodedPaths[i] = HttpUtil.encodeUrlString(paths[i]);
+        }
+        return String.join("/", encodedPaths);
+    }
 }
app/views/board/create.scala.html
--- app/views/board/create.scala.html
+++ app/views/board/create.scala.html
@@ -12,15 +12,34 @@
 @import utils.TemplateHelper._
 @import utils.HtmlUtil._
 @import models.enumeration._
-@implicitField = @{ helper.FieldConstructor(simpleForm) }
 
-@titleMessage = @{
-  if( !requestHeader.getQueryString("readme").equals(None) ) {
-      "Update README.md"
-  } else if( !requestHeader.getQueryString("issueTemplate").equals(None) ) {
-      "ISSUE_TEMPLATE.md: Project Issue Template"
-  }
-}
+    @implicitField = @{
+        helper.FieldConstructor(simpleForm)
+    }
+
+    @issueTemplate = @{
+        requestHeader.getQueryString("issueTemplate")
+    }
+
+    @titleMessage = @{
+        if(!requestHeader.getQueryString("readme").equals(None)) {
+            "Update README.md"
+        } else if(!issueTemplate.equals(None)) {
+            "ISSUE_TEMPLATE.md: Project Issue Template"
+        }
+    }
+
+    @path = @{
+        requestHeader.getQueryString("path")
+    }
+
+    @branch = @{
+        requestHeader.getQueryString("branch")
+    }
+
+    @isEdit = @{
+        !requestHeader.getQueryString("edit").equals(None)
+    }
 
 @projectLayout(title, project, utils.MenuType.BOARD) {
 @projectMenu(project, utils.MenuType.BOARD, "main-menu-only")
@@ -29,12 +48,9 @@
     <form action="@routes.BoardApp.newPost(project.owner, project.name)" method="post" enctype="multipart/form-data" class="nm">
       <div class="content-wrap frm-wrap">
         <dl>
-          <dt>
-            <label for="title">@Messages("title")</label>
-          </dt>
           <dd>
             @defining(form.errors().get("title")) { errors =>
-              <input type="text" id="title" name="title" class="zen-mode text title @if(errors != null) {error}" maxlength="250" tabindex="1" value="@titleMessage"/>
+              <input type="text" id="title" name="title" class="zen-mode text title @if(errors != null) {error}" maxlength="250" tabindex="1" value="@titleMessage" placeholder="@if(path.equals(None)){@Messages("title")}else{@Messages("code.commitMsg")}"/>
               @if(errors != null) {
                 <div class="message">
                 @for(error <- errors) {
@@ -45,18 +61,23 @@
             }
           </dd>
             <dd>
-            @if(!requestHeader.getQueryString("issueTemplate").equals(None)){
+            @if(!issueTemplate.equals(None)){
               <div class="attach-wrap">
                 <span class="help help-droppable">@Messages("issue.template.no.attachment.allow")</span>
               </div>
             }
+              @if(!path.equals(None)){
+                <div class="file-path-wrap">
+                  <span class="help file-path">@branch: /@path @if(!isEdit){<input type="text" name="new-file-name" class="new-file-name" value="" placeholder="filename.." tabindex="2">}</span>
+                </div>
+              }
             </dd>
           <dd style="position: relative;">
-            @common.editor("body", preparedPostBody, "tabindex=2")
+            @common.editor("body", preparedPostBody, "tabindex=3")
           </dd>
         </dl>
           @** fileUploader **@
-        @if(requestHeader.getQueryString("issueTemplate").equals(None)) {
+        @if(issueTemplate.equals(None) && path.equals(None)) {
           @if(!UserApp.currentUser.isAnonymous) {
             @common.fileUploader(ResourceType.BOARD_POST, null)
           }
@@ -64,14 +85,16 @@
         @** end of fileUploader **@
 
         <div class="right-txt mt10 mb10">
-          @if(isAllowedToNotice && requestHeader.getQueryString("issueTemplate").equals(None)){
+          @if(isAllowedToNotice && issueTemplate.equals(None) && path.equals(None)){
           <label class="checkbox">
             <input type="checkbox" id="notice" name="notice">
             @Messages("post.notice.label")
           </label>
           }
 
-          <input type="hidden" id="issueTemplate" name="issueTemplate" value="@requestHeader.getQueryString("issueTemplate")">
+          <input type="hidden" id="issueTemplate" name="issueTemplate" value="@issueTemplate">
+          <input type="hidden" id="branch" name="branch" value="@branch">
+          <input type="hidden" id="path" name="path" value="@path">
           @if(isProjectResourceCreatable(UserApp.currentUser(), project, ResourceType.COMMIT)){
             @if(project.isGit && !requestHeader.getQueryString("readme").equals(None)){
             <label class="checkbox">
@@ -107,6 +130,27 @@
         "target": 'textarea[id^=editor-]',
         "url"   : "@Html(routes.ProjectApp.mentionList(project.owner, project.name).toString())"
     });
+
+    // Followings are used for online commit
+  @if(!path.equals(None)) {
+    var textarea = $(".textarea-box > textarea");
+    if (textarea.val() !== "") {
+        textarea.css("overflow", "hidden");
+        textarea.css("height", "1px");
+        var newHeight = (25 + textarea.prop('scrollHeight'));
+        var minHeight = 300;
+        console.log(newHeight);
+        textarea.css("height", (minHeight > newHeight ? minHeight: newHeight) + "px");
+        textarea.css("overflow", "auto");
+    }
+
+    $(".new-file-name").on("keyup", function () {
+        $("#path").val("@path" + $(this).val());
+    });
+
+    $(".project-menu-gruop > li").removeClass("active");
+    $(".code-menu").addClass("active");
+  }
 });
 </script>
 }
app/views/code/partial_view_file.scala.html
--- app/views/code/partial_view_file.scala.html
+++ app/views/code/partial_view_file.scala.html
@@ -59,6 +59,10 @@
             <a href="@filePath" class="ybtn" target="_blank">
                 <i class="yobicon-download-alt yobicon-white vmiddle"></i> Raw
             </a>
+                @if(!UserApp.currentUser().isAnonymous) {
+                    <a href="@routes.BoardApp.newPostForm(project.owner, project.name)?path=@path&branch=@branchItemName(branch)&edit=true" class="ybtn">
+                        Edit</a>
+                }
             }
             <a id="open-in-browser" href="@pathToOpenFile" class="ybtn" target="_blank" data-content='@Messages("code.open.desc")'>
                 <i class="yobicon-download-alt yobicon-white vmiddle"></i> @Messages("code.open")
app/views/code/view.scala.html
--- app/views/code/view.scala.html
+++ app/views/code/view.scala.html
@@ -5,6 +5,7 @@
 * https://yona.io
 **@
 @import com.fasterxml.jackson.databind.node.ObjectNode
+@import org.apache.commons.io._
 @(project:Project, branches:List[String], recursiveData:List[ObjectNode], branch:String, path:String)
 
 @import utils.TemplateHelper._
@@ -34,6 +35,14 @@
             }
             Html("<a href=\"" + getCorrectedPath(basePath, partialPath) + "\">" + p + "</a>")
         }
+    }
+}
+
+@dir = @{
+    if(fieldText(recursiveData.last, "type").eq("folder") && path.length != 0){
+        path + "/"
+    } else {
+        FilenameUtils.getPath(path)
     }
 }
 
@@ -80,6 +89,11 @@
                     <a href="@routes.CodeApp.download(project.owner, project.name, URLEncoder.encode(branch, "UTF-8"))" class="ybtn">
                         @Messages("code.download")</a>
                 </div>
+                    @if(!UserApp.currentUser().isAnonymous) {
+                        <div class="pull-right">
+                            <a id="new-file-link" href="@routes.BoardApp.newPostForm(project.owner, project.name)?path=@dir&branch=@branchItemName(branch)" class="ybtn">@Messages("code.new.file")</a>
+                        </div>
+                    }
                 }
             </div>
 
app/views/projectMenu.scala.html
--- app/views/projectMenu.scala.html
+++ app/views/projectMenu.scala.html
@@ -58,7 +58,7 @@
             @defining(project.menuSetting){ menuSetting =>
             @if(menuSetting.code) {
                 @if(!project.isCodeAccessibleMemberOnly || project.hasMember(UserApp.currentUser())) {
-                    <li class="@isActiveMenu(MenuType.CODE)">
+                    <li class="code-menu @isActiveMenu(MenuType.CODE)">
                         <a href="@routes.CodeApp.codeBrowser(project.owner, project.name)">
                             <span class="menu-name">@Messages("menu.code")</span><span class="short-menu">C</span>
                         </a>
conf/messages
--- conf/messages
+++ conf/messages
@@ -109,6 +109,7 @@
 code.history = Change history
 code.isBinary = Binary file is not shown
 code.looseFileSizeLimitForCodeBrowser = Site Administrator can loose the limit by modifying "application.codeBrowser.viewer.maxFileSize" in the configuration file.
+code.new.file = New file
 code.newer = Newer
 code.noChanges = No changes
 code.nocommits = No commit exists
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -110,6 +110,7 @@
 code.history = 변경이력
 code.isBinary = 이진 파일입니다
 code.looseFileSizeLimitForCodeBrowser = 사이트 관리자는 설정 파일의 "application.codeBrowser.viewer.maxFileSize" 값을 고쳐서 파일 크기 제한을 조절할 수 있습니다.
+code.new.file = 새 파일
 code.newer = 이전
 code.noChanges = 변경 없음
 code.nocommits = 커밋이 존재하지 않습니다
public/javascripts/service/yobi.board.Write.js
--- public/javascripts/service/yobi.board.Write.js
+++ public/javascripts/service/yobi.board.Write.js
@@ -81,7 +81,9 @@
          */
         function _onSubmitForm(){
             if(htElement.welInputTitle.val() == ""){
-                $yobi.showAlert(Messages("post.error.emptyTitle"));
+                $yobi.showAlert(Messages("post.error.emptyTitle"), function() {
+                    $("#title").focus();
+                });
                 return false;
             }
 
public/javascripts/service/yobi.code.Browser.js
--- public/javascripts/service/yobi.code.Browser.js
+++ public/javascripts/service/yobi.code.Browser.js
@@ -519,6 +519,23 @@
             var breadcrumb = $yobi.xssClean(aCrumbs.join(""));
 
             htElement.welBreadCrumbs.html(breadcrumb);
+
+            var path = window.location.hash.substr(1);
+            var $newFileLink = $("#new-file-link");
+            var newPath = updateQueryStringParameter($newFileLink.attr("href"), "path", path + "/");
+
+            $newFileLink.attr("href", newPath);
+        }
+
+        function updateQueryStringParameter(uri, key, value) {
+            var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
+            var separator = uri.indexOf('?') !== -1 ? "&" : "?";
+            if (uri.match(re)) {
+                return uri.replace(re, '$1' + key + "=" + value + '$2');
+            }
+            else {
+                return uri + separator + key + "=" + value;
+            }
         }
 
         _init(htOptions || {});
Add a comment
List