Skip to content

Commit 5238590

Browse files
committed
fix: address code review findings
1 parent 42fe827 commit 5238590

13 files changed

Lines changed: 445 additions & 47 deletions

File tree

internal/git/diff.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,21 @@ func getUntrackedDiff(repoDir string) ([]internal.FileDiff, error) {
116116

117117
// buildNewFileDiff synthesizes an added-file diff for untracked content.
118118
func buildNewFileDiff(path, content string) internal.FileDiff {
119+
if strings.ContainsRune(content, '\x00') {
120+
return internal.FileDiff{
121+
Path: path,
122+
Status: internal.StatusBinary,
123+
IsBinary: true,
124+
}
125+
}
126+
119127
lines := strings.Split(content, "\n")
128+
if len(lines) > 0 && lines[len(lines)-1] == "" {
129+
lines = lines[:len(lines)-1]
130+
}
131+
120132
var diffLines []internal.DiffLine
121133
for _, line := range lines {
122-
if line == "" && len(diffLines) > 0 {
123-
continue
124-
}
125134
diffLines = append(diffLines, internal.DiffLine{
126135
Type: internal.LineAdd,
127136
Content: line,

internal/git/diff_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
8+
"github.com/Astro-Han/diffpane/internal"
79
)
810

911
// TestComputeDiffModifiedFile verifies tracked modifications become unified diffs.
@@ -128,6 +130,68 @@ func TestComputeDiffFreshRepoUntrackedFile(t *testing.T) {
128130
}
129131
}
130132

133+
// TestComputeDiffUntrackedFilePreservesInnerBlankLines verifies synthetic diffs
134+
// keep blank lines inside file content instead of dropping them.
135+
func TestComputeDiffUntrackedFilePreservesInnerBlankLines(t *testing.T) {
136+
root := initGitRepo(t)
137+
runGit(t, root, "commit", "--allow-empty", "-m", "init")
138+
139+
baseline, err := GetHeadSHA(root)
140+
if err != nil {
141+
t.Fatalf("GetHeadSHA returned error: %v", err)
142+
}
143+
if err := os.WriteFile(filepath.Join(root, "blank.txt"), []byte("top\n\nbottom\n"), 0o600); err != nil {
144+
t.Fatalf("write file: %v", err)
145+
}
146+
147+
files, err := ComputeDiff(root, baseline)
148+
if err != nil {
149+
t.Fatalf("ComputeDiff returned error: %v", err)
150+
}
151+
if len(files) != 1 {
152+
t.Fatalf("expected 1 file, got %d", len(files))
153+
}
154+
lines := files[0].Hunks[0].Lines
155+
if len(lines) != 3 {
156+
t.Fatalf("line count = %d, want 3", len(lines))
157+
}
158+
if lines[1].Content != "" {
159+
t.Fatalf("middle line = %q, want blank line", lines[1].Content)
160+
}
161+
if files[0].AddCount != 3 {
162+
t.Fatalf("add count = %d, want 3", files[0].AddCount)
163+
}
164+
}
165+
166+
// TestComputeDiffUntrackedBinaryFile verifies untracked binary files are not
167+
// rendered as text hunks.
168+
func TestComputeDiffUntrackedBinaryFile(t *testing.T) {
169+
root := initGitRepo(t)
170+
runGit(t, root, "commit", "--allow-empty", "-m", "init")
171+
172+
baseline, err := GetHeadSHA(root)
173+
if err != nil {
174+
t.Fatalf("GetHeadSHA returned error: %v", err)
175+
}
176+
if err := os.WriteFile(filepath.Join(root, "image.bin"), []byte{0x00, 0x01, 0x02}, 0o600); err != nil {
177+
t.Fatalf("write file: %v", err)
178+
}
179+
180+
files, err := ComputeDiff(root, baseline)
181+
if err != nil {
182+
t.Fatalf("ComputeDiff returned error: %v", err)
183+
}
184+
if len(files) != 1 {
185+
t.Fatalf("expected 1 file, got %d", len(files))
186+
}
187+
if !files[0].IsBinary || files[0].Status != internal.StatusBinary {
188+
t.Fatalf("expected binary status, got %#v", files[0])
189+
}
190+
if len(files[0].Hunks) != 0 {
191+
t.Fatalf("binary file should not have text hunks, got %#v", files[0].Hunks)
192+
}
193+
}
194+
131195
// TestComputeDiffNoChanges verifies a clean worktree produces no diffs.
132196
func TestComputeDiffNoChanges(t *testing.T) {
133197
root := initGitRepo(t)

internal/git/repo.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,29 @@ func ResolveGitDir(repoDir string) string {
9191
return filepath.Clean(filepath.Join(repoDir, gitDir))
9292
}
9393

94+
// GetGitCommonDir returns the common git directory shared by linked worktrees.
95+
func GetGitCommonDir(repoDir string) string {
96+
root, err := FindWorktreeRoot(repoDir)
97+
if err != nil {
98+
return ""
99+
}
100+
101+
out, err := gitOutput(root, "rev-parse", "--git-common-dir")
102+
if err != nil {
103+
return ""
104+
}
105+
106+
commonDir := strings.TrimSpace(out)
107+
if commonDir == "" {
108+
return ""
109+
}
110+
if filepath.IsAbs(commonDir) {
111+
return commonDir
112+
}
113+
114+
return filepath.Clean(filepath.Join(root, commonDir))
115+
}
116+
94117
// gitOutput runs git in dir and returns stdout.
95118
func gitOutput(dir string, args ...string) (string, error) {
96119
// #nosec G204 -- git is fixed and arguments are constructed by trusted callers.

internal/git/repo_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,29 @@ func TestResolveGitDirLinkedWorktree(t *testing.T) {
160160
want := filepath.Join(repo, ".git", "worktrees", "linked")
161161
samePath(t, got, want)
162162
}
163+
164+
// TestGetGitCommonDirNormalRepo resolves the common git dir for a standard repo.
165+
func TestGetGitCommonDirNormalRepo(t *testing.T) {
166+
root := initGitRepo(t)
167+
168+
got := GetGitCommonDir(root)
169+
want := filepath.Join(root, ".git")
170+
samePath(t, got, want)
171+
}
172+
173+
// TestGetGitCommonDirLinkedWorktree resolves the shared git dir for a linked worktree.
174+
func TestGetGitCommonDirLinkedWorktree(t *testing.T) {
175+
repo := initGitRepo(t)
176+
runGit(t, repo, "commit", "--allow-empty", "-m", "root")
177+
178+
worktrees := filepath.Join(t.TempDir(), "worktrees")
179+
if err := os.MkdirAll(worktrees, 0o750); err != nil {
180+
t.Fatalf("mkdir worktrees: %v", err)
181+
}
182+
worktreeDir := filepath.Join(worktrees, "linked")
183+
runGit(t, repo, "worktree", "add", worktreeDir)
184+
185+
got := GetGitCommonDir(worktreeDir)
186+
want := filepath.Join(repo, ".git")
187+
samePath(t, got, want)
188+
}

internal/ui/diffview.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ func RenderDiffView(file *internal.FileDiff, scrollOffset, width, height int) st
2222
for _, diffLine := range hunk.Lines {
2323
switch diffLine.Type {
2424
case internal.LineAdd:
25-
lines = append(lines, StyleAdd.Render(wrapLine("+"+diffLine.Content, width)))
25+
for _, visualLine := range wrapLineParts("+"+diffLine.Content, width) {
26+
lines = append(lines, StyleAdd.Render(visualLine))
27+
}
2628
case internal.LineDel:
27-
lines = append(lines, StyleDel.Render(wrapLine("-"+diffLine.Content, width)))
29+
for _, visualLine := range wrapLineParts("-"+diffLine.Content, width) {
30+
lines = append(lines, StyleDel.Render(visualLine))
31+
}
2832
default:
29-
lines = append(lines, wrapLine(" "+diffLine.Content, width))
33+
lines = append(lines, wrapLineParts(" "+diffLine.Content, width)...)
3034
}
3135
}
3236
}
@@ -51,29 +55,32 @@ func RenderDiffView(file *internal.FileDiff, scrollOffset, width, height int) st
5155

5256
// wrapLine wraps one rendered line by terminal cell width.
5357
func wrapLine(line string, width int) string {
58+
return strings.Join(wrapLineParts(line, width), "\n")
59+
}
60+
61+
// wrapLineParts wraps one rendered line into visual lines by terminal cell width.
62+
func wrapLineParts(line string, width int) []string {
5463
if width <= 0 || runewidth.StringWidth(line) <= width {
55-
return line
64+
return []string{line}
5665
}
5766

58-
var result strings.Builder
67+
var result []string
5968
first := truncateToWidth(line, width)
60-
result.WriteString(first)
69+
result = append(result, first)
6170
remaining := line[len(first):]
6271

6372
for len(remaining) > 0 {
64-
result.WriteString("\n")
6573
prefix := " "
6674
chunkWidth := width - runewidth.StringWidth(prefix)
6775
if chunkWidth <= 0 {
6876
chunkWidth = 1
6977
}
7078
chunk := truncateToWidth(remaining, chunkWidth)
71-
result.WriteString(prefix)
72-
result.WriteString(chunk)
79+
result = append(result, prefix+chunk)
7380
remaining = remaining[len(chunk):]
7481
}
7582

76-
return result.String()
83+
return result
7784
}
7885

7986
// truncateToWidth returns the longest prefix that fits within width cells.

internal/ui/diffview_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package ui
33
import (
44
"strings"
55
"testing"
6+
7+
"github.com/Astro-Han/diffpane/internal"
68
)
79

810
// TestWrapLineShort verifies lines shorter than the viewport stay untouched.
@@ -36,3 +38,39 @@ func TestWrapLineCJK(t *testing.T) {
3638
}
3739
}
3840
}
41+
42+
// TestRenderDiffViewCountsWrappedDisplayLines verifies scroll and height work on
43+
// visual lines after wrapping, not just on raw diff entries.
44+
func TestRenderDiffViewCountsWrappedDisplayLines(t *testing.T) {
45+
file := &internal.FileDiff{
46+
Path: "long.txt",
47+
Hunks: []internal.DiffHunk{{
48+
Header: "@@ -0,0 +1,1 @@",
49+
Lines: []internal.DiffLine{{
50+
Type: internal.LineAdd,
51+
Content: strings.Repeat("a", 12),
52+
}},
53+
}},
54+
}
55+
56+
firstPage := RenderDiffView(file, 0, 8, 2)
57+
firstLines := strings.Split(firstPage, "\n")
58+
if len(firstLines) != 2 {
59+
t.Fatalf("first page line count = %d, want 2", len(firstLines))
60+
}
61+
62+
secondPage := RenderDiffView(file, 1, 8, 2)
63+
secondLines := strings.Split(secondPage, "\n")
64+
if len(secondLines) != 2 {
65+
t.Fatalf("second page line count = %d, want 2", len(secondLines))
66+
}
67+
if !strings.HasPrefix(secondLines[1], " ") {
68+
t.Fatalf("second page should include wrapped continuation, got %q", secondLines[1])
69+
}
70+
71+
thirdPage := RenderDiffView(file, 2, 8, 2)
72+
thirdLines := strings.Split(thirdPage, "\n")
73+
if !strings.HasPrefix(thirdLines[0], " ") {
74+
t.Fatalf("third page should start with wrapped continuation, got %q", thirdLines[0])
75+
}
76+
}

internal/ui/messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "github.com/Astro-Han/diffpane/internal"
44

55
// FilesUpdatedMsg notifies the UI that the computed file list changed.
66
type FilesUpdatedMsg struct {
7+
BaselineSHA string
78
Files []internal.FileDiff
89
ChangedPaths []string
910
}

internal/ui/model.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ type Model struct {
1717
Files []internal.FileDiff
1818
CurrentIdx int
1919

20-
FollowOn bool
21-
ScrollOffset int
22-
NewCount int
23-
NewFiles map[string]bool
24-
Notification string
20+
FollowOn bool
21+
ScrollOffset int
22+
NewCount int
23+
NewFiles map[string]bool
24+
LastChangedPath string
25+
Notification string
2526

2627
OverlayOpen bool
2728
OverlayCursor int
@@ -79,6 +80,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7980
}
8081

8182
func (m Model) handleFilesUpdated(msg FilesUpdatedMsg) (tea.Model, tea.Cmd) {
83+
if msg.BaselineSHA != "" && msg.BaselineSHA != m.BaselineSHA {
84+
return m, nil
85+
}
86+
8287
if m.OverlayOpen {
8388
if m.PendingUpdate == nil {
8489
m.PendingUpdate = &msg
@@ -112,11 +117,13 @@ func (m Model) applyFilesUpdate(msg FilesUpdatedMsg) Model {
112117
m.CurrentIdx = target
113118
m.ScrollOffset = 0
114119
m.NewFiles = make(map[string]bool)
120+
m.LastChangedPath = ""
115121
m.NewCount = 0
116122
} else if !m.FollowOn {
117123
for _, changedPath := range msg.ChangedPaths {
118124
if changedPath != currentPath {
119125
m.NewFiles[changedPath] = true
126+
m.LastChangedPath = changedPath
120127
}
121128
}
122129
m.NewCount = len(m.NewFiles)
@@ -141,6 +148,7 @@ func (m Model) applyFilesUpdate(msg FilesUpdatedMsg) Model {
141148
m.CurrentIdx = 0
142149
m.ScrollOffset = 0
143150
m.NewFiles = make(map[string]bool)
151+
m.LastChangedPath = ""
144152
m.NewCount = 0
145153
}
146154

@@ -180,6 +188,7 @@ func (m Model) handleKey(key string) (tea.Model, tea.Cmd) {
180188
case "f":
181189
m.FollowOn = !m.FollowOn
182190
if m.FollowOn {
191+
m.selectLatestPendingFile()
183192
m.NewCount = 0
184193
m.NewFiles = make(map[string]bool)
185194
}
@@ -223,6 +232,7 @@ func (m Model) handleOverlayKey(key string) (tea.Model, tea.Cmd) {
223232
return m.closeOverlay(), nil
224233
case "f":
225234
m.FollowOn = true
235+
m.selectLatestPendingFile()
226236
m.NewCount = 0
227237
m.NewFiles = make(map[string]bool)
228238
return m.closeOverlay(), nil
@@ -234,13 +244,33 @@ func (m Model) handleOverlayKey(key string) (tea.Model, tea.Cmd) {
234244
func (m Model) closeOverlay() Model {
235245
m.OverlayOpen = false
236246
m.OverlaySnapshot = nil
237-
if m.PendingUpdate != nil {
247+
if m.PendingUpdate != nil && (m.PendingUpdate.BaselineSHA == "" || m.PendingUpdate.BaselineSHA == m.BaselineSHA) {
238248
m = m.applyFilesUpdate(*m.PendingUpdate)
239-
m.PendingUpdate = nil
240249
}
250+
m.PendingUpdate = nil
241251
return m
242252
}
243253

254+
func (m *Model) selectLatestPendingFile() {
255+
if m.LastChangedPath != "" {
256+
for i, file := range m.Files {
257+
if file.Path == m.LastChangedPath {
258+
m.CurrentIdx = i
259+
m.ScrollOffset = 0
260+
return
261+
}
262+
}
263+
}
264+
265+
for i := len(m.Files) - 1; i >= 0; i-- {
266+
if m.NewFiles[m.Files[i].Path] {
267+
m.CurrentIdx = i
268+
m.ScrollOffset = 0
269+
return
270+
}
271+
}
272+
}
273+
244274
// View renders header, content, and footer into the terminal viewport.
245275
func (m Model) View() string {
246276
if m.Width == 0 || m.Height == 0 {

0 commit comments

Comments
 (0)