Add file diff view to staging page, fix title

Rename 'Changes' to 'Staging' in the page title and heading.
File names in both modified and staged file lists are now
clickable links that show the diff for that file. The diff
view shows both unstaged (index vs working tree) and staged
(HEAD vs index) differences.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-27 09:57:53 +01:00
parent fbfffac73f
commit b2b3993d85
6 changed files with 116 additions and 4 deletions

View File

@@ -43,6 +43,19 @@ public class RepoController
return "changes"; return "changes";
} }
@GetMapping("/repo/{name}/diff/{filePath}/**")
public String fileDiff(@PathVariable String name,
jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException
{
String fullPath = request.getRequestURI();
String prefix = "/repo/" + name + "/diff/";
String filePath = fullPath.substring(prefix.length());
model.addAttribute("name", name);
model.addAttribute("filePath", filePath);
model.addAttribute("diff", gitService.getWorkingTreeDiff(name, filePath));
return "file-diff";
}
@GetMapping("/repo/{name}/remote") @GetMapping("/repo/{name}/remote")
public String remote(@PathVariable String name, Model model) throws IOException public String remote(@PathVariable String name, Model model) throws IOException
{ {

View File

@@ -209,6 +209,45 @@ public class GitService
} }
} }
public String getWorkingTreeDiff(String name, String filePath) throws IOException
{
try (Git git = openRepository(name))
{
Repository repo = git.getRepository();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DiffFormatter df = new DiffFormatter(out))
{
df.setRepository(repo);
df.setPathFilter(PathFilter.create(filePath));
// Unstaged: index vs working tree
var dirCache = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
var workTree = new org.eclipse.jgit.treewalk.FileTreeIterator(repo);
List<DiffEntry> unstaged = df.scan(dirCache, workTree);
for (DiffEntry diff : unstaged)
{
df.format(diff);
}
// Staged: HEAD vs index
ObjectId headTree = repo.resolve("HEAD^{tree}");
if (headTree != null)
{
var headIter = new org.eclipse.jgit.treewalk.CanonicalTreeParser();
try (var reader = repo.newObjectReader())
{
headIter.reset(reader, headTree);
}
var indexIter = new org.eclipse.jgit.dircache.DirCacheIterator(repo.readDirCache());
List<DiffEntry> staged = df.scan(headIter, indexIter);
for (DiffEntry diff : staged)
{
df.format(diff);
}
}
}
return out.toString(StandardCharsets.UTF_8);
}
}
public void stageFiles(String name, List<String> files) throws IOException, GitAPIException public void stageFiles(String name, List<String> files) throws IOException, GitAPIException
{ {
try (Git git = openRepository(name)) try (Git git = openRepository(name))

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"> <html xmlns:th="http://www.thymeleaf.org">
<head> <head>
<title th:text="'Changes - ' + ${name}">Changes</title> <title th:text="'Staging - ' + ${name}">Staging</title>
</head> </head>
<body> <body>
<h2>Changes</h2> <h2>Staging</h2>
<h3>Modified Files (unstaged)</h3> <h3>Modified Files (unstaged)</h3>
<form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}"> <form method="post" th:action="@{/repo/{name}/stage(name=${name})}" th:if="${!#lists.isEmpty(modifiedFiles)}">
@@ -15,7 +15,7 @@
</tr> </tr>
<tr th:each="file : ${modifiedFiles}"> <tr th:each="file : ${modifiedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td> <td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td> <td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
</tr> </tr>
</table> </table>
<br> <br>
@@ -32,7 +32,7 @@
</tr> </tr>
<tr th:each="file : ${stagedFiles}"> <tr th:each="file : ${stagedFiles}">
<td><input type="checkbox" name="files" th:value="${file}"></td> <td><input type="checkbox" name="files" th:value="${file}"></td>
<td th:text="${file}"></td> <td><a th:href="@{'/repo/' + ${name} + '/diff/' + ${file}}" th:text="${file}"></a></td>
</tr> </tr>
</table> </table>
<br> <br>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="'Diff - ' + ${filePath}">Diff</title>
</head>
<body>
<h2 th:text="${filePath}">file.txt</h2>
<p><a th:href="@{/repo/{name}/changes(name=${name})}">&lt; Back to staging</a></p>
<pre th:text="${diff}">Diff output here</pre>
<p th:if="${diff.isEmpty()}"><i>No differences found.</i></p>
</body>
</html>

View File

@@ -290,4 +290,16 @@ class RepoControllerTest
verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt")); verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt"));
} }
@Test
void fileDiffShowsDiff() throws Exception
{
when(gitService.getWorkingTreeDiff("myrepo", "src/main.txt")).thenReturn("diff --git a/src/main.txt\n+hello");
mockMvc.perform(get("/repo/myrepo/diff/src/main.txt"))
.andExpect(status().isOk())
.andExpect(view().name("file-diff"))
.andExpect(model().attribute("filePath", "src/main.txt"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("+hello")));
}
} }

View File

@@ -419,4 +419,36 @@ class GitServiceTest
assertEquals("# Test", Files.readString(readme)); assertEquals("# Test", Files.readString(readme));
} }
@Test
void getWorkingTreeDiffShowsUnstagedChanges() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "modified content");
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertFalse(diff.isEmpty());
assertTrue(diff.contains("README.md"));
}
@Test
void getWorkingTreeDiffShowsStagedChanges() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo").resolve("README.md"), "staged content");
gitService.stageFiles("myrepo", List.of("README.md"));
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertFalse(diff.isEmpty());
assertTrue(diff.contains("README.md"));
}
@Test
void getWorkingTreeDiffReturnsEmptyForUnchangedFile() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
String diff = gitService.getWorkingTreeDiff("myrepo", "README.md");
assertTrue(diff.isEmpty());
}
} }