diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b758d9..1d90d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/diff/hunks.go b/internal/diff/hunks.go index 2ca41d8..50feb7e 100644 --- a/internal/diff/hunks.go +++ b/internal/diff/hunks.go @@ -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 { @@ -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) @@ -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}) } @@ -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 } diff --git a/internal/diff/hunks_test.go b/internal/diff/hunks_test.go index ca58762..7ea356a 100644 --- a/internal/diff/hunks_test.go +++ b/internal/diff/hunks_test.go @@ -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) { @@ -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) { @@ -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]) } } @@ -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) diff --git a/internal/tui/diffview/update_test.go b/internal/tui/diffview/update_test.go index 3e3c6c7..3238942 100644 --- a/internal/tui/diffview/update_test.go +++ b/internal/tui/diffview/update_test.go @@ -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("]")) @@ -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]) } }