Skip to content

Commit 57792dc

Browse files
committed
fix: resolve mvp blockers
1 parent a01ade6 commit 57792dc

13 files changed

Lines changed: 298 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
func getUntrackedDiff(repoDir string) ([]internal.FileDiff, error) {
66111
cmd := exec.Command("git", "status", "--porcelain", "-z", "--untracked-files=all")

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
@@ -89,6 +89,7 @@ func (m Model) handleFilesUpdated(msg FilesUpdatedMsg) (tea.Model, tea.Cmd) {
8989
if m.PendingUpdate == nil {
9090
m.PendingUpdate = &msg
9191
} else {
92+
m.PendingUpdate.BaselineSHA = msg.BaselineSHA
9293
m.PendingUpdate.Files = msg.Files
9394
m.PendingUpdate.ChangedPaths = append(m.PendingUpdate.ChangedPaths, msg.ChangedPaths...)
9495
}
@@ -124,12 +125,24 @@ func (m Model) applyFilesUpdate(msg FilesUpdatedMsg) Model {
124125
m.LastChangedPath = ""
125126
m.NewCount = 0
126127
} else if !m.FollowOn {
128+
presentPaths := make(map[string]bool, len(m.Files))
129+
for _, file := range m.Files {
130+
presentPaths[file.Path] = true
131+
}
132+
for path := range m.NewFiles {
133+
if !presentPaths[path] || path == currentPath {
134+
delete(m.NewFiles, path)
135+
}
136+
}
127137
for _, changedPath := range msg.ChangedPaths {
128-
if changedPath != currentPath {
138+
if changedPath != currentPath && presentPaths[changedPath] {
129139
m.NewFiles[changedPath] = true
130140
m.LastChangedPath = changedPath
131141
}
132142
}
143+
if m.LastChangedPath != "" && !m.NewFiles[m.LastChangedPath] {
144+
m.LastChangedPath = ""
145+
}
133146
m.NewCount = len(m.NewFiles)
134147

135148
found := false
@@ -313,12 +326,12 @@ func (m Model) View() string {
313326
}
314327

315328
diffHeight := max(0, m.Height-2)
316-
header := RenderHeader(m.DirName, m.Files, m.CurrentIdx, m.NewCount)
317-
footer := RenderFooter(m.FollowOn, m.Notification)
329+
header := RenderHeader(m.DirName, m.Files, m.CurrentIdx, m.NewCount, m.Width)
330+
footer := RenderFooter(m.FollowOn, m.Notification, m.Width)
318331

319332
var content string
320333
if m.OverlayOpen {
321-
content = RenderOverlay(m.OverlaySnapshot, m.OverlayCursor, diffHeight)
334+
content = RenderOverlay(m.OverlaySnapshot, m.OverlayCursor, diffHeight, m.Width)
322335
} else if len(m.Files) > 0 && m.CurrentIdx < len(m.Files) {
323336
content = RenderDiffView(&m.Files[m.CurrentIdx], m.ScrollOffset, m.Width, diffHeight)
324337
}

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{
@@ -230,6 +256,40 @@ func TestModelDropsQueuedOverlayUpdateAfterBaselineReset(t *testing.T) {
230256
}
231257
}
232258

259+
// TestModelOverlayAppliesFreshUpdateAfterBaselineReset verifies a newer
260+
// baseline refresh still lands after overlay close, even if an old update was
261+
// queued earlier in the same overlay session.
262+
func TestModelOverlayAppliesFreshUpdateAfterBaselineReset(t *testing.T) {
263+
model := NewModel("repo", "/tmp/repo", "old-sha", []internal.FileDiff{
264+
file("old.txt", 1),
265+
})
266+
267+
opened, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
268+
withOverlay := opened.(Model)
269+
270+
queuedOld, _ := withOverlay.Update(FilesUpdatedMsg{
271+
BaselineSHA: "old-sha",
272+
Files: []internal.FileDiff{
273+
file("stale.txt", 1),
274+
},
275+
ChangedPaths: []string{"stale.txt"},
276+
})
277+
reset, _ := queuedOld.(Model).Update(BaselineResetMsg{NewSHA: "new-sha"})
278+
queuedNew, _ := reset.(Model).Update(FilesUpdatedMsg{
279+
BaselineSHA: "new-sha",
280+
Files: []internal.FileDiff{
281+
file("fresh.txt", 1),
282+
},
283+
ChangedPaths: []string{"fresh.txt"},
284+
})
285+
closed, _ := queuedNew.(Model).Update(tea.KeyMsg{Type: tea.KeyEsc})
286+
got := closed.(Model)
287+
288+
if len(got.Files) != 1 || got.Files[0].Path != "fresh.txt" {
289+
t.Fatalf("fresh overlay update should win after reset, got %#v", got.Files)
290+
}
291+
}
292+
233293
// TestModelViewSmallHeightDoesNotPanic verifies tiny terminal heights do not
234294
// trigger negative content heights in overlay rendering.
235295
func TestModelViewSmallHeightDoesNotPanic(t *testing.T) {
@@ -270,6 +330,54 @@ func TestModelViewEmptyStateFitsViewport(t *testing.T) {
270330
}
271331
}
272332

333+
// TestModelViewNarrowWidthKeepsChromeSingleLine verifies header and footer stay
334+
// within the viewport width instead of relying on terminal soft-wrap.
335+
func TestModelViewNarrowWidthKeepsChromeSingleLine(t *testing.T) {
336+
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{{
337+
Path: "very/long/path/to/current-file-name.ts",
338+
AddCount: 12,
339+
DelCount: 3,
340+
}})
341+
model.Width = 20
342+
model.Height = 4
343+
344+
view := model.View()
345+
lines := strings.Split(view, "\n")
346+
if len(lines) != 4 {
347+
t.Fatalf("line count = %d, want 4; view = %q", len(lines), view)
348+
}
349+
if lipgloss.Width(lines[0]) > model.Width {
350+
t.Fatalf("header width = %d, want <= %d; header = %q", lipgloss.Width(lines[0]), model.Width, lines[0])
351+
}
352+
if lipgloss.Width(lines[3]) > model.Width {
353+
t.Fatalf("footer width = %d, want <= %d; footer = %q", lipgloss.Width(lines[3]), model.Width, lines[3])
354+
}
355+
}
356+
357+
// TestModelOverlayNarrowWidthKeepsEntriesSingleLine verifies overlay rows fit
358+
// the viewport width instead of depending on soft-wrap.
359+
func TestModelOverlayNarrowWidthKeepsEntriesSingleLine(t *testing.T) {
360+
model := NewModel("repo", "/tmp/repo", "sha", []internal.FileDiff{
361+
{Path: "very/long/path/to/current-file-name.ts", AddCount: 12, DelCount: 3},
362+
{Path: "another/extremely/long/path/to/second-file.ts", AddCount: 1},
363+
})
364+
model.Width = 20
365+
model.Height = 4
366+
model.OverlayOpen = true
367+
model.OverlaySnapshot = append([]internal.FileDiff(nil), model.Files...)
368+
369+
view := model.View()
370+
lines := strings.Split(view, "\n")
371+
if len(lines) != 4 {
372+
t.Fatalf("line count = %d, want 4; view = %q", len(lines), view)
373+
}
374+
for i := 1; i <= 2; i++ {
375+
if lipgloss.Width(lines[i]) > model.Width {
376+
t.Fatalf("overlay line %d width = %d, want <= %d; line = %q", i, lipgloss.Width(lines[i]), model.Width, lines[i])
377+
}
378+
}
379+
}
380+
273381
// TestModelScrollOffsetStaysWithinContent verifies repeated down-navigation does
274382
// not grow scroll state beyond the visible diff content.
275383
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)