Skip to content

Commit 1583521

Browse files
committed
Merge branch 'fix/mvp-blockers'
Resolve test file conflict by keeping both sides. Fix lint warnings in watcher_test.go (errcheck, gosec) from the merged branch.
2 parents ec652ae + 57792dc commit 1583521

13 files changed

Lines changed: 299 additions & 28 deletions

File tree

internal/git/diff.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ func ComputeDiff(repoDir, baselineSHA string) ([]internal.FileDiff, error) {
1717
if err != nil {
1818
return nil, fmt.Errorf("git diff: %w", err)
1919
}
20+
if baselineSHA == EmptyTreeSHA {
21+
tracked, err = overlayFreshRepoWorktreeChanges(repoDir, tracked)
22+
if err != nil {
23+
return nil, fmt.Errorf("git diff worktree: %w", err)
24+
}
25+
}
2026

2127
untracked, err := getUntrackedDiff(repoDir)
2228
if err != nil {
@@ -61,6 +67,45 @@ func getTrackedDiff(repoDir, baselineSHA string) ([]internal.FileDiff, error) {
6167
return ParseDiff(string(out)), nil
6268
}
6369

70+
// overlayFreshRepoWorktreeChanges replaces cached added-file diffs with the
71+
// current worktree content for unborn-repo paths that were edited after staging.
72+
func overlayFreshRepoWorktreeChanges(repoDir string, tracked []internal.FileDiff) ([]internal.FileDiff, error) {
73+
cmd := exec.Command("git", "diff", "--name-only", "-z", "--")
74+
cmd.Dir = repoDir
75+
out, err := cmd.Output()
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
indexByPath := make(map[string]int, len(tracked))
81+
result := append([]internal.FileDiff(nil), tracked...)
82+
for i, file := range result {
83+
indexByPath[file.Path] = i
84+
}
85+
86+
for _, path := range strings.Split(string(out), "\x00") {
87+
if path == "" {
88+
continue
89+
}
90+
91+
// #nosec G304 -- path comes from git diff output scoped to the repository.
92+
data, readErr := os.ReadFile(filepath.Join(repoDir, path))
93+
if readErr != nil {
94+
continue
95+
}
96+
97+
diff := buildNewFileDiff(path, string(data))
98+
if idx, ok := indexByPath[path]; ok {
99+
result[idx] = diff
100+
continue
101+
}
102+
indexByPath[path] = len(result)
103+
result = append(result, diff)
104+
}
105+
106+
return result, nil
107+
}
108+
64109
// getUntrackedDiff expands untracked files and directories to synthetic added diffs.
65110
// TODO(v2): reads entire file into memory; consider capping read size for large
66111
// generated files and delegating binary detection to git.

internal/git/diff_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,39 @@ func TestComputeDiffFreshRepoUntrackedFile(t *testing.T) {
130130
}
131131
}
132132

133+
// TestComputeDiffFreshRepoStagedThenModifiedFile verifies an unborn repo shows
134+
// the current worktree content even after a file was staged earlier.
135+
func TestComputeDiffFreshRepoStagedThenModifiedFile(t *testing.T) {
136+
root := initGitRepo(t)
137+
138+
baseline, err := GetHeadSHA(root)
139+
if err != nil {
140+
t.Fatalf("GetHeadSHA returned error: %v", err)
141+
}
142+
path := filepath.Join(root, "fresh.txt")
143+
if err := os.WriteFile(path, []byte("a\n"), 0o600); err != nil {
144+
t.Fatalf("write staged file: %v", err)
145+
}
146+
runGit(t, root, "add", "fresh.txt")
147+
if err := os.WriteFile(path, []byte("b\n"), 0o600); err != nil {
148+
t.Fatalf("rewrite worktree file: %v", err)
149+
}
150+
151+
files, err := ComputeDiff(root, baseline)
152+
if err != nil {
153+
t.Fatalf("ComputeDiff returned error: %v", err)
154+
}
155+
if len(files) != 1 || files[0].Path != "fresh.txt" {
156+
t.Fatalf("expected 1 file fresh.txt, got %#v", files)
157+
}
158+
if len(files[0].Hunks) != 1 || len(files[0].Hunks[0].Lines) != 1 {
159+
t.Fatalf("unexpected hunks: %#v", files[0].Hunks)
160+
}
161+
if files[0].Hunks[0].Lines[0].Content != "b" {
162+
t.Fatalf("line content = %q, want current worktree content", files[0].Hunks[0].Lines[0].Content)
163+
}
164+
}
165+
133166
// TestComputeDiffUntrackedFilePreservesInnerBlankLines verifies synthetic diffs
134167
// keep blank lines inside file content instead of dropping them.
135168
func TestComputeDiffUntrackedFilePreservesInnerBlankLines(t *testing.T) {

internal/ui/footer.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package ui
33
import "fmt"
44

55
// RenderFooter renders the one-line footer or a transient notification.
6-
func RenderFooter(followOn bool, notification string) string {
6+
func RenderFooter(followOn bool, notification string, width int) string {
77
if notification != "" {
8-
return StyleAdd.Render(notification)
8+
return clampInlineWidth(StyleAdd.Render(notification), width)
99
}
1010

1111
status := "on"
1212
if !followOn {
1313
status = "off"
1414
}
15-
return StyleDim.Render(fmt.Sprintf("q quit · n/p files · f follow: %s · tab list", status))
15+
return clampInlineWidth(StyleDim.Render(fmt.Sprintf("q quit · n/p files · f follow: %s · tab list", status)), width)
1616
}

internal/ui/header.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import (
88
)
99

1010
// RenderHeader renders the single-line top bar.
11-
func RenderHeader(dirName string, files []internal.FileDiff, currentIdx, newCount int) string {
11+
func RenderHeader(dirName string, files []internal.FileDiff, currentIdx, newCount, width int) string {
1212
if len(files) == 0 {
13-
return StyleDim.Render(fmt.Sprintf("%s · watching", dirName))
13+
return clampInlineWidth(StyleDim.Render(fmt.Sprintf("%s · watching", dirName)), width)
1414
}
1515

1616
currentIdx = min(max(currentIdx, 0), len(files)-1)
@@ -30,7 +30,7 @@ func RenderHeader(dirName string, files []internal.FileDiff, currentIdx, newCoun
3030
result += " " + StyleAdd.Render(fmt.Sprintf("+%d new", newCount))
3131
}
3232

33-
return result
33+
return clampInlineWidth(result, width)
3434
}
3535

3636
// renderFileStats formats one file's change summary for header and overlay views.

internal/ui/header_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99

1010
// TestRenderHeaderEmpty verifies the empty state header shows directory and watching state.
1111
func TestRenderHeaderEmpty(t *testing.T) {
12-
header := RenderHeader("myproject", nil, 0, 0)
12+
header := RenderHeader("myproject", nil, 0, 0, 80)
1313
if !strings.Contains(header, "myproject") || !strings.Contains(header, "watching") {
1414
t.Fatalf("header = %q, want dir name and watching", header)
1515
}
@@ -18,7 +18,7 @@ func TestRenderHeaderEmpty(t *testing.T) {
1818
// TestRenderHeaderSingleFile verifies one file does not show the file counter.
1919
func TestRenderHeaderSingleFile(t *testing.T) {
2020
files := []internal.FileDiff{{Path: "src/auth.ts", AddCount: 3, DelCount: 1}}
21-
header := RenderHeader("myproject", files, 0, 0)
21+
header := RenderHeader("myproject", files, 0, 0, 80)
2222
if strings.Contains(header, "›") {
2323
t.Fatalf("single file header should not show counter, got %q", header)
2424
}
@@ -30,7 +30,7 @@ func TestRenderHeaderMultipleFiles(t *testing.T) {
3030
{Path: "src/auth.ts", AddCount: 3},
3131
{Path: "src/utils.ts", AddCount: 1},
3232
}
33-
header := RenderHeader("myproject", files, 0, 0)
33+
header := RenderHeader("myproject", files, 0, 0, 80)
3434
if !strings.Contains(header, "1/2") {
3535
t.Fatalf("header = %q, want counter 1/2", header)
3636
}
@@ -39,7 +39,7 @@ func TestRenderHeaderMultipleFiles(t *testing.T) {
3939
// TestRenderHeaderNewCount verifies paused follow mode shows the pending new count.
4040
func TestRenderHeaderNewCount(t *testing.T) {
4141
files := []internal.FileDiff{{Path: "a.ts", AddCount: 1}}
42-
header := RenderHeader("myproject", files, 0, 3)
42+
header := RenderHeader("myproject", files, 0, 3, 80)
4343
if !strings.Contains(header, "+3 new") {
4444
t.Fatalf("header = %q, want +3 new", header)
4545
}
@@ -55,7 +55,7 @@ func TestRenderHeaderOutOfRangeIndex(t *testing.T) {
5555
}
5656
}()
5757

58-
header := RenderHeader("myproject", files, 9, 0)
58+
header := RenderHeader("myproject", files, 9, 0, 80)
5959
if !strings.Contains(header, "a.ts") {
6060
t.Fatalf("header = %q, want file path", header)
6161
}

internal/ui/model.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func (m Model) handleFilesUpdated(msg FilesUpdatedMsg) (tea.Model, tea.Cmd) {
9090
if m.PendingUpdate == nil {
9191
m.PendingUpdate = &msg
9292
} else {
93+
m.PendingUpdate.BaselineSHA = msg.BaselineSHA
9394
m.PendingUpdate.Files = msg.Files
9495
m.PendingUpdate.ChangedPaths = append(m.PendingUpdate.ChangedPaths, msg.ChangedPaths...)
9596
}
@@ -144,12 +145,24 @@ func (m Model) applyFilesUpdate(msg FilesUpdatedMsg) Model {
144145
m.LastChangedPath = ""
145146
m.NewCount = 0
146147
} else if !m.FollowOn {
148+
presentPaths := make(map[string]bool, len(m.Files))
149+
for _, file := range m.Files {
150+
presentPaths[file.Path] = true
151+
}
152+
for path := range m.NewFiles {
153+
if !presentPaths[path] || path == currentPath {
154+
delete(m.NewFiles, path)
155+
}
156+
}
147157
for _, changedPath := range msg.ChangedPaths {
148-
if changedPath != currentPath {
158+
if changedPath != currentPath && presentPaths[changedPath] {
149159
m.NewFiles[changedPath] = true
150160
m.LastChangedPath = changedPath
151161
}
152162
}
163+
if m.LastChangedPath != "" && !m.NewFiles[m.LastChangedPath] {
164+
m.LastChangedPath = ""
165+
}
153166
m.NewCount = len(m.NewFiles)
154167

155168
found := false
@@ -333,12 +346,12 @@ func (m Model) View() string {
333346
}
334347

335348
diffHeight := max(0, m.Height-2)
336-
header := RenderHeader(m.DirName, m.Files, m.CurrentIdx, m.NewCount)
337-
footer := RenderFooter(m.FollowOn, m.Notification)
349+
header := RenderHeader(m.DirName, m.Files, m.CurrentIdx, m.NewCount, m.Width)
350+
footer := RenderFooter(m.FollowOn, m.Notification, m.Width)
338351

339352
var content string
340353
if m.OverlayOpen {
341-
content = RenderOverlay(m.OverlaySnapshot, m.OverlayCursor, diffHeight)
354+
content = RenderOverlay(m.OverlaySnapshot, m.OverlayCursor, diffHeight, m.Width)
342355
} else if len(m.Files) > 0 && m.CurrentIdx < len(m.Files) {
343356
content = RenderDiffView(&m.Files[m.CurrentIdx], m.ScrollOffset, m.Width, diffHeight)
344357
}

internal/ui/model_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/Astro-Han/diffpane/internal"
88
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
910
)
1011

1112
func file(path string, adds int) internal.FileDiff {
@@ -91,6 +92,31 @@ func TestModelPausedFollowTracksNewFiles(t *testing.T) {
9192
}
9293
}
9394

95+
// TestModelPausedFollowIgnoresVanishedChangedPaths verifies transient changes
96+
// that disappear before the diff refresh lands do not inflate +N new.
97+
func TestModelPausedFollowIgnoresVanishedChangedPaths(t *testing.T) {
98+
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{
99+
file("a.txt", 1),
100+
})
101+
model.FollowOn = false
102+
model.CurrentIdx = 0
103+
104+
updated, _ := model.Update(FilesUpdatedMsg{
105+
Files: []internal.FileDiff{
106+
file("a.txt", 1),
107+
},
108+
ChangedPaths: []string{"temp.txt"},
109+
})
110+
111+
got := updated.(Model)
112+
if got.NewCount != 0 {
113+
t.Fatalf("NewCount = %d, want 0", got.NewCount)
114+
}
115+
if len(got.NewFiles) != 0 {
116+
t.Fatalf("NewFiles = %#v, want empty", got.NewFiles)
117+
}
118+
}
119+
94120
// TestModelOverlayQueuesUpdates verifies overlay mode freezes the view and applies pending updates on close.
95121
func TestModelOverlayQueuesUpdates(t *testing.T) {
96122
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{
@@ -281,6 +307,40 @@ func TestModelFollowClampsWhenCurrentFileDisappears(t *testing.T) {
281307
}
282308
}
283309

310+
// TestModelOverlayAppliesFreshUpdateAfterBaselineReset verifies a newer
311+
// baseline refresh still lands after overlay close, even if an old update was
312+
// queued earlier in the same overlay session.
313+
func TestModelOverlayAppliesFreshUpdateAfterBaselineReset(t *testing.T) {
314+
model := NewModel("repo", "/tmp/repo", "old-sha", []internal.FileDiff{
315+
file("old.txt", 1),
316+
})
317+
318+
opened, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
319+
withOverlay := opened.(Model)
320+
321+
queuedOld, _ := withOverlay.Update(FilesUpdatedMsg{
322+
BaselineSHA: "old-sha",
323+
Files: []internal.FileDiff{
324+
file("stale.txt", 1),
325+
},
326+
ChangedPaths: []string{"stale.txt"},
327+
})
328+
reset, _ := queuedOld.(Model).Update(BaselineResetMsg{NewSHA: "new-sha"})
329+
queuedNew, _ := reset.(Model).Update(FilesUpdatedMsg{
330+
BaselineSHA: "new-sha",
331+
Files: []internal.FileDiff{
332+
file("fresh.txt", 1),
333+
},
334+
ChangedPaths: []string{"fresh.txt"},
335+
})
336+
closed, _ := queuedNew.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
337+
got := closed.(Model)
338+
339+
if len(got.Files) != 1 || got.Files[0].Path != "fresh.txt" {
340+
t.Fatalf("fresh overlay update should win after reset, got %#v", got.Files)
341+
}
342+
}
343+
284344
// TestModelViewSmallHeightDoesNotPanic verifies tiny terminal heights do not
285345
// trigger negative content heights in overlay rendering.
286346
func TestModelViewSmallHeightDoesNotPanic(t *testing.T) {
@@ -321,6 +381,54 @@ func TestModelViewEmptyStateFitsViewport(t *testing.T) {
321381
}
322382
}
323383

384+
// TestModelViewNarrowWidthKeepsChromeSingleLine verifies header and footer stay
385+
// within the viewport width instead of relying on terminal soft-wrap.
386+
func TestModelViewNarrowWidthKeepsChromeSingleLine(t *testing.T) {
387+
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{{
388+
Path: "very/long/path/to/current-file-name.ts",
389+
AddCount: 12,
390+
DelCount: 3,
391+
}})
392+
model.Width = 20
393+
model.Height = 4
394+
395+
view := model.View()
396+
lines := strings.Split(view, "\n")
397+
if len(lines) != 4 {
398+
t.Fatalf("line count = %d, want 4; view = %q", len(lines), view)
399+
}
400+
if lipgloss.Width(lines[0]) > model.Width {
401+
t.Fatalf("header width = %d, want <= %d; header = %q", lipgloss.Width(lines[0]), model.Width, lines[0])
402+
}
403+
if lipgloss.Width(lines[3]) > model.Width {
404+
t.Fatalf("footer width = %d, want <= %d; footer = %q", lipgloss.Width(lines[3]), model.Width, lines[3])
405+
}
406+
}
407+
408+
// TestModelOverlayNarrowWidthKeepsEntriesSingleLine verifies overlay rows fit
409+
// the viewport width instead of depending on soft-wrap.
410+
func TestModelOverlayNarrowWidthKeepsEntriesSingleLine(t *testing.T) {
411+
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{
412+
{Path: "very/long/path/to/current-file-name.ts", AddCount: 12, DelCount: 3},
413+
{Path: "another/extremely/long/path/to/second-file.ts", AddCount: 1},
414+
})
415+
model.Width = 20
416+
model.Height = 4
417+
model.OverlayOpen = true
418+
model.OverlaySnapshot = append([]internal.FileDiff(nil), model.Files...)
419+
420+
view := model.View()
421+
lines := strings.Split(view, "\n")
422+
if len(lines) != 4 {
423+
t.Fatalf("line count = %d, want 4; view = %q", len(lines), view)
424+
}
425+
for i := 1; i <= 2; i++ {
426+
if lipgloss.Width(lines[i]) > model.Width {
427+
t.Fatalf("overlay line %d width = %d, want <= %d; line = %q", i, lipgloss.Width(lines[i]), model.Width, lines[i])
428+
}
429+
}
430+
}
431+
324432
// TestModelScrollOffsetStaysWithinContent verifies repeated down-navigation does
325433
// not grow scroll state beyond the visible diff content.
326434
func TestModelScrollOffsetStaysWithinContent(t *testing.T) {

internal/ui/overlay.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import (
88
)
99

1010
// RenderOverlay renders the frozen file list overlay.
11-
func RenderOverlay(files []internal.FileDiff, cursor, height int) string {
11+
func RenderOverlay(files []internal.FileDiff, cursor, height, width int) string {
1212
if len(files) == 0 {
13-
return StyleDim.Render("No changed files")
13+
return clampInlineWidth(StyleDim.Render("No changed files"), width)
1414
}
1515

1616
var lines []string
@@ -30,5 +30,8 @@ func RenderOverlay(files []internal.FileDiff, cursor, height int) string {
3030
if end > len(lines) {
3131
end = len(lines)
3232
}
33+
for i := start; i < end; i++ {
34+
lines[i] = clampInlineWidth(lines[i], width)
35+
}
3336
return strings.Join(lines[start:end], "\n")
3437
}

0 commit comments

Comments
 (0)