Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
- `c` copies the complete loaded file preview to the terminal clipboard without line numbers or display wrapping; while a preview is open, drift releases the mouse to the terminal so its text can also be selected directly

### Fixed
- unified diff `@@` headers sat after their context lines, so a hunk starting at line 1 showed a block of unchanged lines and then the marker. The header is the first row of the hunk now, with context underneath
- tabbing through the diff file list could punch large black holes in the layout when a file used CRLF line endings. A leftover `\r` sent the terminal cursor back to column 0, so the padded diff background painted over the file list. Line splits now drop CR, and the renderer ignores any that remain
- a directory marked in the remote pane of an FTP or FTPS host ended up in the diff view as a file, with a red "is a directory" where the diff belongs, instead of being expanded into the files below it. `Stat` treated a successful `SIZE` as proof of a file, but vsftpd, ProFTPD and others answer `SIZE` for directories too. It asks `MLST` first now, which reports the entry type. One command also replaces `SIZE` plus `MDTM`, and its timestamps have second precision, so the diff loader skips more downloads on files that match. Servers without `MLST` keep the old behaviour and the old ambiguity

Expand Down
74 changes: 51 additions & 23 deletions internal/diff/hunks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (r DisplayRow) SourceLine() int {
}

// Flatten builds display rows: hunk headers, limited context, and fold markers.
// Each @@ header is the first row of its hunk, followed by context then changes.
// expanded holds GapIDs whose equal runs should be shown in full.
func Flatten(lines []DiffLine, context int, expanded map[int]struct{}) []DisplayRow {
if context < 0 {
Expand All @@ -56,9 +57,13 @@ func Flatten(lines []DiffLine, context int, expanded map[int]struct{}) []Display

foldBudget := 2 * context
out := make([]DisplayRow, 0, len(lines)+len(runs))
var leading []DisplayRow
for i, r := range runs {
if r.equal {
out = append(out, flattenEqualRun(runs, i, context, foldBudget, expanded)...)
prefix, fold, suffix := splitEqualRun(runs, i, context, foldBudget, expanded)
out = append(out, prefix...)
out = append(out, fold...)
leading = suffix
continue
}
hStart, hEnd := hunkRange(runs, i, context, foldBudget, expanded)
Expand All @@ -67,6 +72,8 @@ func Flatten(lines []DiffLine, context int, expanded map[int]struct{}) []Display
LineIndex: r.start,
Header: formatHunkHeader(lines, hStart, hEnd),
})
out = append(out, leading...)
leading = nil
for idx := r.start; idx < r.end; idx++ {
out = append(out, DisplayRow{Kind: DisplayLine, LineIndex: idx})
}
Expand Down Expand Up @@ -139,34 +146,55 @@ func hasChangeRun(runs []run) bool {
return false
}

func flattenEqualRun(runs []run, i, context, foldBudget int, expanded map[int]struct{}) []DisplayRow {
// splitEqualRun splits an equal run into trailing context of the previous hunk
// (prefix), an optional fold, and leading context of the next hunk (suffix).
// Flatten emits the next @@ header between fold and suffix so the header is
// the first row of its hunk.
func splitEqualRun(runs []run, i, context, foldBudget int, expanded map[int]struct{}) (prefix, fold, suffix []DisplayRow) {
r := runs[i]
length := r.end - r.start
_, shown := expanded[r.start]
if length <= foldBudget || shown {
out := make([]DisplayRow, 0, length)
for idx := r.start; idx < r.end; idx++ {
out = append(out, DisplayRow{Kind: DisplayLine, LineIndex: idx})
preN, sufN := equalContext(runs, i, context)

if length > foldBudget && !shown {
hiddenStart := r.start + preN
hiddenEnd := r.end - sufN
prefix = lineRows(r.start, hiddenStart)
if hiddenEnd > hiddenStart {
fold = []DisplayRow{{
Kind: DisplayFold,
GapID: r.start,
Hidden: hiddenEnd - hiddenStart,
FoldStart: hiddenStart,
FoldEnd: hiddenEnd,
}}
}
return out
suffix = lineRows(hiddenEnd, r.end)
return prefix, fold, suffix
}

switch {
case preN == 0 && sufN > 0:
return nil, nil, lineRows(r.start, r.end)
case sufN == 0:
return lineRows(r.start, r.end), nil, nil
default:
if sufN > length {
sufN = length
}
split := r.end - sufN
return lineRows(r.start, split), nil, lineRows(split, r.end)
}
}

prefix, suffix := equalContext(runs, i, context)
out := make([]DisplayRow, 0, prefix+suffix+1)
for idx := r.start; idx < r.start+prefix; idx++ {
out = append(out, DisplayRow{Kind: DisplayLine, LineIndex: idx})
}
hiddenStart := r.start + prefix
hiddenEnd := r.end - suffix
out = append(out, DisplayRow{
Kind: DisplayFold,
GapID: r.start,
Hidden: hiddenEnd - hiddenStart,
FoldStart: hiddenStart,
FoldEnd: hiddenEnd,
})
for idx := hiddenEnd; idx < r.end; idx++ {
out = append(out, DisplayRow{Kind: DisplayLine, LineIndex: idx})
func lineRows(start, end int) []DisplayRow {
n := end - start
if n <= 0 {
return nil
}
out := make([]DisplayRow, n)
for i := 0; i < n; i++ {
out[i] = DisplayRow{Kind: DisplayLine, LineIndex: start + i}
}
return out
}
Expand Down
54 changes: 50 additions & 4 deletions internal/diff/hunks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ func TestFlattenShortGapStaysOpen(t *testing.T) {
t.Fatalf("6-line gap should not fold: %+v", rows)
}
}
headers := headerIndexes(rows)
if len(headers) != 2 {
t.Fatalf("headers at %v, want 2", headers)
}
// Gap is indices 3–8. The second header sits before the last 3 of those.
if rows[headers[1]+1].LineIndex != 6 {
t.Fatalf("second header at %d is followed by line %d, want 6", headers[1], rows[headers[1]+1].LineIndex)
}
}

func TestFlattenSevenLineGapFoldsMiddle(t *testing.T) {
Expand Down Expand Up @@ -97,8 +105,9 @@ func TestFlattenLeadingAndTrailingFolds(t *testing.T) {
kinds[i] = r.Kind
}
want := []DisplayKind{
DisplayFold, DisplayLine, DisplayLine, DisplayLine,
DisplayHunkHeader, DisplayLine,
DisplayFold, DisplayHunkHeader,
DisplayLine, DisplayLine, DisplayLine,
DisplayLine,
DisplayLine, DisplayLine, DisplayLine, DisplayFold,
}
if !reflect.DeepEqual(kinds, want) {
Expand Down Expand Up @@ -151,8 +160,35 @@ func TestFlattenHunkHeaderIncludesContext(t *testing.T) {
{Text: "c", Kind: LineEqual, LocalNum: 3, RemoteNum: 3},
}
rows := Flatten(lines, DefaultContext, nil)
if rows[1].Kind != DisplayHunkHeader || rows[1].Header != "@@ -1,3 +1,3 @@" {
t.Fatalf("header = %+v, want @@ -1,3 +1,3 @@ after first context line", rows[1])
if rows[0].Kind != DisplayHunkHeader || rows[0].Header != "@@ -1,3 +1,3 @@" {
t.Fatalf("header = %+v, want @@ -1,3 +1,3 @@ as first row", rows[0])
}
if rows[1].Kind != DisplayLine || rows[1].LineIndex != 0 {
t.Fatalf("row after header = %+v, want first context line", rows[1])
}
}

func TestFlattenHeaderComesBeforeLeadingContext(t *testing.T) {
lines := numberedEquals(12, 1)
lines = append(lines, DiffLine{Text: "old", Kind: LineRemoved, LocalNum: 13})

collapsed := Flatten(lines, DefaultContext, nil)
if collapsed[0].Kind != DisplayFold {
t.Fatalf("row 0 = %+v, want leading fold", collapsed[0])
}
if collapsed[1].Kind != DisplayHunkHeader || collapsed[1].Header != "@@ -10,4 +10,3 @@" {
t.Fatalf("header = %+v, want @@ -10,4 +10,3 @@ after the fold", collapsed[1])
}
if collapsed[2].LineIndex != 9 {
t.Fatalf("first context line index = %d, want 9", collapsed[2].LineIndex)
}

expanded := Flatten(lines, DefaultContext, map[int]struct{}{0: {}})
if expanded[0].Kind != DisplayHunkHeader || expanded[0].Header != "@@ -1,13 +1,12 @@" {
t.Fatalf("expanded header = %+v, want @@ -1,13 +1,12 @@ first", expanded[0])
}
if expanded[1].Kind != DisplayLine || expanded[1].LineIndex != 0 {
t.Fatalf("expanded row 1 = %+v, want file line 1 under the header", expanded[1])
}
}

Expand Down Expand Up @@ -181,6 +217,16 @@ func TestFoldableGapIDs(t *testing.T) {
}
}

func headerIndexes(rows []DisplayRow) []int {
var out []int
for i, r := range rows {
if r.Kind == DisplayHunkHeader {
out = append(out, i)
}
}
return out
}

func TestSplitRunsGroupsByKindAndClosesTheLastRun(t *testing.T) {
if got := splitRuns(nil); got != nil {
t.Fatalf("splitRuns(nil) = %v, want nil", got)
Expand Down
13 changes: 8 additions & 5 deletions internal/tui/diffview/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,10 +415,8 @@ func TestHunkJumpLandsOnRunStart(t *testing.T) {
if len(headers) < 2 {
t.Fatalf("need two hunk headers, got %v", headers)
}

model, _ = model.handleKey(keyMsg("]"))
if model.scroll != headers[0] {
t.Fatalf("] jumped to %d, want first hunk header at %d", model.scroll, headers[0])
if headers[0] != 0 {
t.Fatalf("first header at %d, want 0 (leading context sits under it)", headers[0])
}

model, _ = model.handleKey(keyMsg("]"))
Expand All @@ -428,7 +426,12 @@ func TestHunkJumpLandsOnRunStart(t *testing.T) {

model, _ = model.handleKey(keyMsg("["))
if model.scroll != headers[0] {
t.Fatalf("[ jumped to %d, want previous hunk header at %d", model.scroll, headers[0])
t.Fatalf("[ jumped to %d, want first hunk header at %d", model.scroll, headers[0])
}

model, _ = model.handleKey(keyMsg("]"))
if model.scroll != headers[1] {
t.Fatalf("] jumped to %d, want second hunk header at %d", model.scroll, headers[1])
}
}

Expand Down
Loading