GrepServlet: add HTML view for content search

The +grep endpoint currently supports JSON responses only. Add a
basic Soy-backed HTML page with a search form and linked file/line
results, reusing the existing bounded grep implementation.

HTML requests without the s parameter show the search form without
running a search. JSON behavior is unchanged and still requires s.

Issue: 376381593
Change-Id: Ie5b2a513d61fb4c44d6efba868ae6faa9aa866d0
Signed-off-by: Devansh Varshney <devansh@libreoffice.org>
diff --git a/java/com/google/gitiles/GitilesFilter.java b/java/com/google/gitiles/GitilesFilter.java
index 3f35e9f..b696841 100644
--- a/java/com/google/gitiles/GitilesFilter.java
+++ b/java/com/google/gitiles/GitilesFilter.java
@@ -264,7 +264,7 @@
       case LOG:
         return new LogServlet(accessFactory, renderer, linkifier());
       case GREP:
-        return new GrepServlet(accessFactory);
+        return new GrepServlet(accessFactory, renderer);
       case DESCRIBE:
         return new DescribeServlet(accessFactory);
       case ARCHIVE:
diff --git a/java/com/google/gitiles/GrepServlet.java b/java/com/google/gitiles/GrepServlet.java
index bae8139..8f3e698 100644
--- a/java/com/google/gitiles/GrepServlet.java
+++ b/java/com/google/gitiles/GrepServlet.java
@@ -18,10 +18,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
 import com.google.gitiles.GitilesRequestFailureException.FailureReason;
 import com.google.gson.reflect.TypeToken;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.diff.RawText;
@@ -48,8 +50,30 @@
   @VisibleForTesting static final int MAX_MATCHES = 1000;
   private static final int MAX_BLOB_SIZE = 1 << 20; // 1 MB
 
-  protected GrepServlet(GitilesAccess.Factory accessFactory) {
-    super(null, accessFactory);
+  protected GrepServlet(GitilesAccess.Factory accessFactory, Renderer renderer) {
+    super(renderer, accessFactory);
+  }
+
+  @Override
+  protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException {
+    GitilesView view = ViewFilter.getView(req);
+    String substring =
+        Strings.nullToEmpty(Iterables.getFirst(view.getParameters().get(SUBSTRING_PARAM), null));
+
+    List<Map<String, Object>> matches = Lists.newArrayList();
+    if (!substring.isEmpty()) {
+      matches = toSoyMatches(view, grep(ServletUtils.getRepository(req), view, substring).matches);
+    }
+
+    Map<String, Object> data = Maps.newHashMapWithExpectedSize(6);
+    data.put("title", "Grep - " + view.getRevision().getName());
+    data.put("revision", view.getRevision().getName());
+    data.put("path", Strings.nullToEmpty(view.getPathPart()));
+    data.put("substring", substring);
+    data.put("hasSearch", !substring.isEmpty());
+    data.put("matches", matches);
+
+    renderHtml(req, res, "com.google.gitiles.templates.GrepDetail.grepDetail", data);
   }
 
   @Override
@@ -82,6 +106,14 @@
     return new GrepResult(matches);
   }
 
+  private static List<Map<String, Object>> toSoyMatches(GitilesView view, List<Match> matches) {
+    List<Map<String, Object>> data = Lists.newArrayListWithCapacity(matches.size());
+    for (Match match : matches) {
+      data.add(match.toSoyData(view));
+    }
+    return data;
+  }
+
   private static void grepPath(
       ObjectReader reader, RevTree root, String path, String substring, List<Match> matches)
       throws IOException {
@@ -204,5 +236,15 @@
       this.lineNumber = lineNumber;
       this.line = line;
     }
+
+    Map<String, Object> toSoyData(GitilesView view) {
+      Map<String, Object> data = Maps.newHashMapWithExpectedSize(4);
+      data.put("path", path);
+      data.put("lineNumber", lineNumber);
+      data.put("line", line);
+      data.put(
+          "url", GitilesView.path().copyFrom(view).setPathPart(path).toUrl() + "#" + lineNumber);
+      return data;
+    }
   }
 }
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java
index 0087e04..fe22701 100644
--- a/java/com/google/gitiles/Renderer.java
+++ b/java/com/google/gitiles/Renderer.java
@@ -81,6 +81,7 @@
           "DiffDetail.soy",
           "Doc.soy",
           "Error.soy",
+          "GrepDetail.soy",
           "HostIndex.soy",
           "LogDetail.soy",
           "ObjectDetail.soy",
diff --git a/javatests/com/google/gitiles/GrepServletTest.java b/javatests/com/google/gitiles/GrepServletTest.java
index 2ccd951..b940f44 100644
--- a/javatests/com/google/gitiles/GrepServletTest.java
+++ b/javatests/com/google/gitiles/GrepServletTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import java.util.List;
 import org.junit.Test;
@@ -55,6 +56,33 @@
   }
 
   @Test
+  public void grepHtmlShowsSearchForm() throws Exception {
+    repo.branch("master").commit().add("foo", "contents").create();
+
+    FakeHttpServletResponse res = buildResponse("/repo/+grep/master", "format=html", SC_OK);
+
+    assertThat(res.getActualBodyString()).contains("name=\"s\"");
+    assertThat(res.getActualBodyString()).contains("Search");
+  }
+
+  @Test
+  public void grepHtmlSearchesFileContents() throws Exception {
+    repo.branch("master")
+        .commit()
+        .add("dir/a.txt", "alpha\nneedle here\n")
+        .add("dir/b.txt", "no match\n")
+        .create();
+
+    FakeHttpServletResponse res =
+        buildResponse("/repo/+grep/master", "format=html&s=needle", SC_OK);
+
+    assertThat(res.getActualBodyString()).contains("dir/a.txt");
+    assertThat(res.getActualBodyString()).contains("2: needle here");
+    assertThat(res.getActualBodyString()).contains("#2");
+    assertThat(res.getActualBodyString()).doesNotContain("dir/b.txt");
+  }
+
+  @Test
   public void grepJsonDoesNotSearchNonExistingPathPrefix() throws Exception {
     repo.branch("master").commit().add("src/a.txt", "needle\n").create();
 
diff --git a/resources/com/google/gitiles/static/base.css b/resources/com/google/gitiles/static/base.css
index 095cc99..d5054e7 100644
--- a/resources/com/google/gitiles/static/base.css
+++ b/resources/com/google/gitiles/static/base.css
@@ -502,6 +502,12 @@
   }
 }
 
+/* GrepDetail.soy */
+
+.GrepResults-item {
+  margin-bottom: 15px;
+}
+
 /* LogDetail.soy */
 
 .LogNav {
diff --git a/resources/com/google/gitiles/templates/GrepDetail.soy b/resources/com/google/gitiles/templates/GrepDetail.soy
new file mode 100644
index 0000000..5dedc25
--- /dev/null
+++ b/resources/com/google/gitiles/templates/GrepDetail.soy
@@ -0,0 +1,71 @@
+// Copyright 2026 Google Inc. All Rights Reserved.
+//
+// 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.
+{namespace com.google.gitiles.templates.GrepDetail}
+
+import * as common from 'com/google/gitiles/templates/Common.soy';
+
+/**
+ * Detail page for file-content search results.
+ */
+{template grepDetail stricthtml="false"}
+  {@param title: ?}
+  {@param repositoryName: ?}
+  {@param? menuEntries: ?}
+  {@param? customVariant: ?}
+  {@param breadcrumbs: ?}
+  {@param revision: string}
+  {@param path: string}
+  {@param substring: string}
+  {@param hasSearch: bool}
+  {@param matches: list<?>}
+
+{call common.header data="all" /}
+
+<form method="GET" class="GrepSearch">
+  <input type="text" name="s" value="{$substring}" class="GrepSearch-input">
+  <button type="submit" class="GrepSearch-button">
+    {msg desc="button text for grep search"}Search{/msg}
+  </button>
+</form>
+
+{if $path}
+  <p class="GrepSearch-path">
+    {msg desc="search path label"}Searching in{/msg}
+    {sp}<span class="u-monospace">{$path}</span>
+  </p>
+{/if}
+
+{if $hasSearch}
+  {if length($matches)}
+    <ol class="GrepResults">
+      {for $match in $matches}
+        <li class="GrepResults-item">
+          <a class="u-monospace" href="{$match.url}">
+            {$match.path}
+          </a>
+          <pre class="u-pre GrepResults-line"><code class="u-monospace">{$match.lineNumber}: {$match.line}</code></pre>
+        </li>
+      {/for}
+    </ol>
+  {else}
+    <p class="GrepResults-empty">
+      {msg desc="message shown when grep search has no matches"}No matches.{/msg}
+    </p>
+  {/if}
+{/if}
+
+{call common.footer}
+  {param customVariant: $customVariant /}
+{/call}
+{/template}