Merge 55fcc8fd17ec2d8214aafd64e606433db222abb7 into fe192203ee99178ad1bcbf9cd0c6e9b8e02cf667
61 files · +2371 -675
M docs/merge-requests.md +8 -0
| @@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c | |||
| 63 | 63 | ||
| 64 | 64 | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | |
| 65 | 65 | ||
| 66 | + | ## Large diffs on the web | |
| 67 | + | ||
| 68 | + | The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll. | |
| 69 | + | ||
| 70 | + | A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment. | |
| 71 | + | ||
| 72 | + | The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set. | |
| 73 | + | ||
| 66 | 74 | ## Notes | |
| 67 | 75 | ||
| 68 | 76 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | |
| @@ -63,6 +63,14 @@ In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `c | |||||
| 63 | 63 | ||||
| 64 | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | 64 | In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box. | ||
| 65 | 65 | ||||
| 66 | + | ## Large diffs on the web | |||
| 67 | + | ||||
| 68 | + | The merge request, commit and compare pages list every changed file. Generated files (lockfiles, `vendor/`, `node_modules/`, minified or generated code) and large files (over 2,000 changed lines) are collapsed: the header and the counts show, the lines do not. "load diff" fetches that one file in place, in full, and you can comment on its lines. Without scripts the link reloads the page with that file loaded. After the first 25 files of a merge request, the other files load as you scroll. | |||
| 69 | + | ||||
| 70 | + | A commit page reads the hunks of its first 300 files with the page; the other files are listed collapsed and load one at a time. A file that cannot be shown in the browser, over 8 MiB of text or over 50,000 changed lines when loaded alone, has no "load diff": the page links to its raw blob instead, and its lines take no comment. | |||
| 71 | + | ||||
| 72 | + | The API and `mr show --diff --json` keep their limits (300 files, 2,000 changed lines per file, 20,000 in total, 8 MiB). A file read without its hunks has `truncated` set and `omitted` giving the reason: `large`, `budget` (the earlier files used the limits) or `unavailable` (over 8 MiB of text, or a line over 64 KiB, even alone). Files past the 300th are not listed and `diff_truncated` is set. | |||
| 73 | + | ||||
| 66 | ## Notes | 74 | ## Notes | ||
| 67 | 75 | ||||
| 68 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | 76 | A note is the only review object. It is published as soon as it is written: there is no draft and no submit step. | ||
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.10.0 | |
| 16 | + | bnema.dev/ermite-api v0.11.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.10.0 | 16 | + | bnema.dev/ermite-api v0.11.0 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.1.0 | 18 | bnema.dev/ermite-kv v0.1.0 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M= | |
| 2 | 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | - | bnema.dev/ermite-api v0.10.0 h1:7OzstzDvBl4KWnvwsrDOvTlUoWAIz6Fnhn0y9iN5YxQ= | |
| 4 | - | bnema.dev/ermite-api v0.10.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 3 | + | bnema.dev/ermite-api v0.11.0 h1:XgD8lBfI/HtnuNVvBkt+QyHu1nTItACZj53c3wY+Pvo= | |
| 4 | + | bnema.dev/ermite-api v0.11.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| 6 | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | |
| 7 | 7 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M= | 1 | bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M= | ||
| 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | ||
| 3 | - | bnema.dev/ermite-api v0.10.0 h1:7OzstzDvBl4KWnvwsrDOvTlUoWAIz6Fnhn0y9iN5YxQ= | 3 | + | bnema.dev/ermite-api v0.11.0 h1:XgD8lBfI/HtnuNVvBkt+QyHu1nTItACZj53c3wY+Pvo= |
| 4 | - | bnema.dev/ermite-api v0.10.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | 4 | + | bnema.dev/ermite-api v0.11.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= |
| 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | ||
| 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | ||
| 7 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | 7 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | ||
M internal/adapters/gitcli/diff.go +38 -30
| @@ -88,8 +88,6 @@ type rawEntry struct { | |||
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | // readDiff parses the output of diff-tree --raw --numstat -p. | |
| 91 | - | // | |
| 92 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process | |
| 93 | 91 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | |
| 94 | 92 | entries, err := readRaw(r) | |
| 95 | 93 | if err != nil { | |
| @@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 115 | 113 | // rename, mode change) have a section without hunks. | |
| 116 | 114 | lastRead := -1 | |
| 117 | 115 | for i := from; i < to; i++ { | |
| 118 | - | if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) { | |
| 116 | + | if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read { | |
| 119 | 117 | lastRead = i | |
| 120 | 118 | } | |
| 121 | 119 | } | |
| @@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 134 | 132 | sections = 2 | |
| 135 | 133 | } | |
| 136 | 134 | inWindow := i >= from && i < to | |
| 135 | + | read, why := false, domain.OmitNone | |
| 136 | + | if inWindow { | |
| 137 | + | read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del) | |
| 138 | + | } | |
| 137 | 139 | switch { | |
| 138 | - | case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del): | |
| 140 | + | case !inWindow: | |
| 139 | 141 | // Nothing to show. | |
| 140 | - | case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del): | |
| 141 | - | e.file.Truncated = true | |
| 142 | + | case !read: | |
| 143 | + | e.file.Omitted = why | |
| 142 | 144 | default: | |
| 143 | 145 | before := pr.budget | |
| 144 | - | hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines()) | |
| 146 | + | pr.budget.StartFile() | |
| 147 | + | hunks, _, why, err := pr.file(sections, pr.budget.FileLines()) | |
| 145 | 148 | if err != nil { | |
| 146 | 149 | return domain.Diff{}, domain.DiffStats{}, err | |
| 147 | 150 | } | |
| 148 | - | e.file.Hunks, e.file.Truncated = hunks, truncated | |
| 149 | - | if truncated { | |
| 151 | + | e.file.Hunks, e.file.Omitted = hunks, why | |
| 152 | + | if why != domain.OmitNone { | |
| 150 | 153 | pr.budget = before // nothing of this file is kept | |
| 151 | 154 | } | |
| 152 | 155 | sections = 0 | |
| @@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 156 | 159 | return domain.Diff{}, domain.DiffStats{}, err | |
| 157 | 160 | } | |
| 158 | 161 | } | |
| 159 | - | diff.Truncated = diff.Truncated || e.file.Truncated | |
| 160 | 162 | diff.Files = append(diff.Files, e.file) | |
| 161 | 163 | } | |
| 162 | 164 | if lastRead < len(entries)-1 { | |
| @@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error { | |||
| 379 | 381 | } | |
| 380 | 382 | ||
| 381 | 383 | // file reads the sections of one file and returns its hunks, the number of | |
| 382 | - | // hunk lines, and whether it exceeded max lines or had a line over | |
| 383 | - | // maxDiffLine. When truncated, the rest of the file is skipped. | |
| 384 | - | func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | |
| 384 | + | // changed lines, and why it was omitted: it exceeded max changed lines or the | |
| 385 | + | // budget, or had a line over maxDiffLine. When omitted, the rest of the file is | |
| 386 | + | // skipped. | |
| 387 | + | func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) { | |
| 385 | 388 | for done := range sections { | |
| 386 | 389 | ok, err := p.enterSection() | |
| 387 | 390 | if err != nil { | |
| 388 | - | return nil, 0, false, err | |
| 391 | + | return nil, 0, domain.OmitNone, err | |
| 389 | 392 | } | |
| 390 | 393 | if !ok { | |
| 391 | 394 | break | |
| 392 | 395 | } | |
| 393 | - | h, n, trunc, err := p.section(max - lines) | |
| 396 | + | h, n, why, err := p.section(max - lines) | |
| 394 | 397 | if err != nil { | |
| 395 | - | return nil, 0, false, err | |
| 398 | + | return nil, 0, domain.OmitNone, err | |
| 396 | 399 | } | |
| 397 | 400 | hunks = append(hunks, h...) | |
| 398 | 401 | lines += n | |
| 399 | - | if trunc { | |
| 402 | + | if why != domain.OmitNone { | |
| 400 | 403 | // Leave the stream at the start of the next file. | |
| 401 | - | return nil, lines, true, p.skip(sections - done - 1) | |
| 404 | + | return nil, lines, why, p.skip(sections - done - 1) | |
| 402 | 405 | } | |
| 403 | 406 | } | |
| 404 | - | return hunks, lines, false, nil | |
| 407 | + | return hunks, lines, domain.OmitNone, nil | |
| 405 | 408 | } | |
| 406 | 409 | ||
| 407 | 410 | // section reads the hunks of the current section up to the next marker, which | |
| 408 | - | // stays pending. It gives up (truncated) after more than max lines. | |
| 409 | - | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | |
| 411 | + | // stays pending. It gives up (with a reason) after more than max changed lines. | |
| 412 | + | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) { | |
| 410 | 413 | var cur *domain.Hunk | |
| 411 | 414 | var oldN, newN int | |
| 412 | 415 | for { | |
| 413 | 416 | line, ok, err := p.line() | |
| 414 | 417 | if err != nil { | |
| 415 | - | return nil, 0, false, err | |
| 418 | + | return nil, 0, domain.OmitNone, err | |
| 416 | 419 | } | |
| 417 | 420 | if !ok { | |
| 418 | - | return hunks, lines, false, nil | |
| 421 | + | return hunks, lines, domain.OmitNone, nil | |
| 419 | 422 | } | |
| 420 | 423 | if bytes.HasPrefix(line, []byte(sectionMarker)) { | |
| 421 | 424 | p.unread(line) | |
| 422 | - | return hunks, lines, false, nil | |
| 425 | + | return hunks, lines, domain.OmitNone, nil | |
| 423 | 426 | } | |
| 424 | 427 | if p.cut { | |
| 425 | - | return nil, lines, true, nil | |
| 428 | + | return nil, lines, domain.OmitUnavailable, nil | |
| 426 | 429 | } | |
| 427 | 430 | if bytes.HasPrefix(line, []byte("@@ ")) { | |
| 428 | 431 | h, err := parseHunkHeader(string(line)) | |
| 429 | 432 | if err != nil { | |
| 430 | - | return nil, 0, false, err | |
| 433 | + | return nil, 0, domain.OmitNone, err | |
| 431 | 434 | } | |
| 432 | 435 | hunks = append(hunks, h) | |
| 433 | 436 | cur = &hunks[len(hunks)-1] | |
| @@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||
| 459 | 462 | default: | |
| 460 | 463 | continue | |
| 461 | 464 | } | |
| 462 | - | lines++ | |
| 463 | - | fits := p.budget.ChargeLine(len(text)) | |
| 464 | - | if lines > max || !fits { | |
| 465 | - | return nil, lines, true, nil | |
| 465 | + | changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion | |
| 466 | + | if changed { | |
| 467 | + | lines++ | |
| 468 | + | } | |
| 469 | + | if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone { | |
| 470 | + | return nil, lines, why, nil | |
| 471 | + | } | |
| 472 | + | if lines > max { | |
| 473 | + | return nil, lines, domain.OmitLarge, nil | |
| 466 | 474 | } | |
| 467 | 475 | cur.Lines = append(cur.Lines, l) | |
| 468 | 476 | } | |
| @@ -88,8 +88,6 @@ type rawEntry struct { | |||||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | // readDiff parses the output of diff-tree --raw --numstat -p. | 90 | // readDiff parses the output of diff-tree --raw --numstat -p. | ||
| 91 | - | // | |||
| 92 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process | |||
| 93 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | 91 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | ||
| 94 | entries, err := readRaw(r) | 92 | entries, err := readRaw(r) | ||
| 95 | if err != nil { | 93 | if err != nil { | ||
| @@ -115,7 +113,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 115 | // rename, mode change) have a section without hunks. | 113 | // rename, mode change) have a section without hunks. | ||
| 116 | lastRead := -1 | 114 | lastRead := -1 | ||
| 117 | for i := from; i < to; i++ { | 115 | for i := from; i < to; i++ { | ||
| 118 | - | if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) { | 116 | + | if read, _ := budget.Admit(entries[i].file.Binary, entries[i].file.CommentPath(), entries[i].added+entries[i].del); read { |
| 119 | lastRead = i | 117 | lastRead = i | ||
| 120 | } | 118 | } | ||
| 121 | } | 119 | } | ||
| @@ -134,19 +132,24 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 134 | sections = 2 | 132 | sections = 2 | ||
| 135 | } | 133 | } | ||
| 136 | inWindow := i >= from && i < to | 134 | inWindow := i >= from && i < to | ||
| 135 | + | read, why := false, domain.OmitNone | |||
| 136 | + | if inWindow { | |||
| 137 | + | read, why = pr.budget.Admit(e.file.Binary, e.file.CommentPath(), e.added+e.del) | |||
| 138 | + | } | |||
| 137 | switch { | 139 | switch { | ||
| 138 | - | case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del): | 140 | + | case !inWindow: |
| 139 | // Nothing to show. | 141 | // Nothing to show. | ||
| 140 | - | case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del): | 142 | + | case !read: |
| 141 | - | e.file.Truncated = true | 143 | + | e.file.Omitted = why |
| 142 | default: | 144 | default: | ||
| 143 | before := pr.budget | 145 | before := pr.budget | ||
| 144 | - | hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines()) | 146 | + | pr.budget.StartFile() |
| 147 | + | hunks, _, why, err := pr.file(sections, pr.budget.FileLines()) | |||
| 145 | if err != nil { | 148 | if err != nil { | ||
| 146 | return domain.Diff{}, domain.DiffStats{}, err | 149 | return domain.Diff{}, domain.DiffStats{}, err | ||
| 147 | } | 150 | } | ||
| 148 | - | e.file.Hunks, e.file.Truncated = hunks, truncated | 151 | + | e.file.Hunks, e.file.Omitted = hunks, why |
| 149 | - | if truncated { | 152 | + | if why != domain.OmitNone { |
| 150 | pr.budget = before // nothing of this file is kept | 153 | pr.budget = before // nothing of this file is kept | ||
| 151 | } | 154 | } | ||
| 152 | sections = 0 | 155 | sections = 0 | ||
| @@ -156,7 +159,6 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 156 | return domain.Diff{}, domain.DiffStats{}, err | 159 | return domain.Diff{}, domain.DiffStats{}, err | ||
| 157 | } | 160 | } | ||
| 158 | } | 161 | } | ||
| 159 | - | diff.Truncated = diff.Truncated || e.file.Truncated | |||
| 160 | diff.Files = append(diff.Files, e.file) | 162 | diff.Files = append(diff.Files, e.file) | ||
| 161 | } | 163 | } | ||
| 162 | if lastRead < len(entries)-1 { | 164 | if lastRead < len(entries)-1 { | ||
| @@ -379,55 +381,56 @@ func (p *patchReader) skip(n int) error { | |||||
| 379 | } | 381 | } | ||
| 380 | 382 | ||||
| 381 | // file reads the sections of one file and returns its hunks, the number of | 383 | // file reads the sections of one file and returns its hunks, the number of | ||
| 382 | - | // hunk lines, and whether it exceeded max lines or had a line over | 384 | + | // changed lines, and why it was omitted: it exceeded max changed lines or the |
| 383 | - | // maxDiffLine. When truncated, the rest of the file is skipped. | 385 | + | // budget, or had a line over maxDiffLine. When omitted, the rest of the file is |
| 384 | - | func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | 386 | + | // skipped. |
| 387 | + | func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) { | |||
| 385 | for done := range sections { | 388 | for done := range sections { | ||
| 386 | ok, err := p.enterSection() | 389 | ok, err := p.enterSection() | ||
| 387 | if err != nil { | 390 | if err != nil { | ||
| 388 | - | return nil, 0, false, err | 391 | + | return nil, 0, domain.OmitNone, err |
| 389 | } | 392 | } | ||
| 390 | if !ok { | 393 | if !ok { | ||
| 391 | break | 394 | break | ||
| 392 | } | 395 | } | ||
| 393 | - | h, n, trunc, err := p.section(max - lines) | 396 | + | h, n, why, err := p.section(max - lines) |
| 394 | if err != nil { | 397 | if err != nil { | ||
| 395 | - | return nil, 0, false, err | 398 | + | return nil, 0, domain.OmitNone, err |
| 396 | } | 399 | } | ||
| 397 | hunks = append(hunks, h...) | 400 | hunks = append(hunks, h...) | ||
| 398 | lines += n | 401 | lines += n | ||
| 399 | - | if trunc { | 402 | + | if why != domain.OmitNone { |
| 400 | // Leave the stream at the start of the next file. | 403 | // Leave the stream at the start of the next file. | ||
| 401 | - | return nil, lines, true, p.skip(sections - done - 1) | 404 | + | return nil, lines, why, p.skip(sections - done - 1) |
| 402 | } | 405 | } | ||
| 403 | } | 406 | } | ||
| 404 | - | return hunks, lines, false, nil | 407 | + | return hunks, lines, domain.OmitNone, nil |
| 405 | } | 408 | } | ||
| 406 | 409 | ||||
| 407 | // section reads the hunks of the current section up to the next marker, which | 410 | // section reads the hunks of the current section up to the next marker, which | ||
| 408 | - | // stays pending. It gives up (truncated) after more than max lines. | 411 | + | // stays pending. It gives up (with a reason) after more than max changed lines. |
| 409 | - | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | 412 | + | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, why domain.FileOmission, err error) { |
| 410 | var cur *domain.Hunk | 413 | var cur *domain.Hunk | ||
| 411 | var oldN, newN int | 414 | var oldN, newN int | ||
| 412 | for { | 415 | for { | ||
| 413 | line, ok, err := p.line() | 416 | line, ok, err := p.line() | ||
| 414 | if err != nil { | 417 | if err != nil { | ||
| 415 | - | return nil, 0, false, err | 418 | + | return nil, 0, domain.OmitNone, err |
| 416 | } | 419 | } | ||
| 417 | if !ok { | 420 | if !ok { | ||
| 418 | - | return hunks, lines, false, nil | 421 | + | return hunks, lines, domain.OmitNone, nil |
| 419 | } | 422 | } | ||
| 420 | if bytes.HasPrefix(line, []byte(sectionMarker)) { | 423 | if bytes.HasPrefix(line, []byte(sectionMarker)) { | ||
| 421 | p.unread(line) | 424 | p.unread(line) | ||
| 422 | - | return hunks, lines, false, nil | 425 | + | return hunks, lines, domain.OmitNone, nil |
| 423 | } | 426 | } | ||
| 424 | if p.cut { | 427 | if p.cut { | ||
| 425 | - | return nil, lines, true, nil | 428 | + | return nil, lines, domain.OmitUnavailable, nil |
| 426 | } | 429 | } | ||
| 427 | if bytes.HasPrefix(line, []byte("@@ ")) { | 430 | if bytes.HasPrefix(line, []byte("@@ ")) { | ||
| 428 | h, err := parseHunkHeader(string(line)) | 431 | h, err := parseHunkHeader(string(line)) | ||
| 429 | if err != nil { | 432 | if err != nil { | ||
| 430 | - | return nil, 0, false, err | 433 | + | return nil, 0, domain.OmitNone, err |
| 431 | } | 434 | } | ||
| 432 | hunks = append(hunks, h) | 435 | hunks = append(hunks, h) | ||
| 433 | cur = &hunks[len(hunks)-1] | 436 | cur = &hunks[len(hunks)-1] | ||
| @@ -459,10 +462,15 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||||
| 459 | default: | 462 | default: | ||
| 460 | continue | 463 | continue | ||
| 461 | } | 464 | } | ||
| 462 | - | lines++ | 465 | + | changed := l.Kind == domain.LineAddition || l.Kind == domain.LineDeletion |
| 463 | - | fits := p.budget.ChargeLine(len(text)) | 466 | + | if changed { |
| 464 | - | if lines > max || !fits { | 467 | + | lines++ |
| 465 | - | return nil, lines, true, nil | 468 | + | } |
| 469 | + | if why := p.budget.ChargeLine(len(text), changed); why != domain.OmitNone { | |||
| 470 | + | return nil, lines, why, nil | |||
| 471 | + | } | |||
| 472 | + | if lines > max { | |||
| 473 | + | return nil, lines, domain.OmitLarge, nil | |||
| 466 | } | 474 | } | ||
| 467 | cur.Lines = append(cur.Lines, l) | 475 | cur.Lines = append(cur.Lines, l) | ||
| 468 | } | 476 | } | ||
M internal/adapters/gitcli/diff_test.go +40 -38
| @@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 177 | 177 | assert.Equal(t, domain.FileModified, bin.Status) | |
| 178 | 178 | assert.True(t, bin.Binary) | |
| 179 | 179 | assert.Empty(t, bin.Hunks) | |
| 180 | - | assert.False(t, bin.Truncated) | |
| 180 | + | assert.Equal(t, domain.OmitNone, bin.Omitted) | |
| 181 | 181 | ||
| 182 | 182 | mode := files["d/f>d/f"] | |
| 183 | 183 | assert.Equal(t, domain.FileModified, mode.Status) | |
| @@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||
| 185 | 185 | assert.Equal(t, uint32(0o100755), mode.NewMode) | |
| 186 | 186 | assert.Empty(t, mode.Hunks) | |
| 187 | 187 | assert.False(t, mode.Binary) | |
| 188 | - | assert.False(t, mode.Truncated) | |
| 188 | + | assert.Equal(t, domain.OmitNone, mode.Omitted) | |
| 189 | 189 | ||
| 190 | 190 | del := files["README.md>"] | |
| 191 | 191 | assert.Equal(t, domain.FileDeleted, del.Status) | |
| @@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) { | |||
| 315 | 315 | require.NoError(t, err) | |
| 316 | 316 | exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small") | |
| 317 | 317 | ||
| 318 | - | assert.False(t, exact.Truncated) | |
| 318 | + | assert.Equal(t, domain.OmitNone, exact.Omitted) | |
| 319 | 319 | require.Len(t, exact.Hunks, 1) | |
| 320 | 320 | assert.Len(t, exact.Hunks[0].Lines, 2000) | |
| 321 | - | assert.True(t, over.Truncated) | |
| 321 | + | assert.NotEqual(t, domain.OmitNone, over.Omitted) | |
| 322 | 322 | assert.Empty(t, over.Hunks) | |
| 323 | 323 | assert.Equal(t, 2001, over.Additions, "counts stay accurate") | |
| 324 | - | assert.False(t, small.Truncated) | |
| 324 | + | assert.Equal(t, domain.OmitNone, small.Omitted) | |
| 325 | 325 | assert.Len(t, small.Hunks, 1) | |
| 326 | - | assert.True(t, d.Diff.Truncated) | |
| 326 | + | assert.False(t, d.Diff.Truncated) | |
| 327 | 327 | assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats) | |
| 328 | 328 | }) | |
| 329 | 329 | ||
| 330 | - | t.Run("context counts towards the file limit", func(t *testing.T) { | |
| 330 | + | t.Run("context does not count towards the file limit", func(t *testing.T) { | |
| 331 | 331 | f := readertest.NewFixture(t, data, "limit-context") | |
| 332 | 332 | f.Lines("big", "l", 3000).Commit("v1") | |
| 333 | - | // Every 7th line changes: ~428 changed lines, but the hunks merge | |
| 333 | + | // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge | |
| 334 | 334 | // into one that spans the file with its context. | |
| 335 | 335 | var b strings.Builder | |
| 336 | 336 | for i := range 3000 { | |
| @@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) { | |||
| 346 | 346 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 347 | 347 | require.NoError(t, err) | |
| 348 | 348 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | |
| 349 | - | assert.True(t, big.Truncated) | |
| 350 | - | assert.Empty(t, big.Hunks) | |
| 349 | + | assert.Equal(t, domain.OmitNone, big.Omitted) | |
| 350 | + | require.Len(t, big.Hunks, 1) | |
| 351 | + | assert.Len(t, big.Hunks[0].Lines, 3000+429) | |
| 352 | + | assert.False(t, d.Diff.Truncated) | |
| 351 | 353 | assert.Equal(t, 429, big.Additions) | |
| 352 | 354 | assert.Equal(t, 429, big.Deletions) | |
| 353 | - | assert.False(t, tiny.Truncated, "the file after a truncated one is still read") | |
| 355 | + | assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget") | |
| 354 | 356 | assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+")) | |
| 355 | 357 | assert.Equal(t, id, d.Commit.ID.String()) | |
| 356 | 358 | }) | |
| @@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) { | |||
| 376 | 378 | } | |
| 377 | 379 | assert.LessOrEqual(t, total, 20000) | |
| 378 | 380 | for i := range 10 { | |
| 379 | - | assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated) | |
| 381 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted) | |
| 380 | 382 | } | |
| 381 | - | assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left") | |
| 382 | - | assert.False(t, find(d.Diff, "g-small").Truncated) | |
| 383 | - | assert.True(t, find(d.Diff, "h-big").Truncated) | |
| 384 | - | assert.False(t, find(d.Diff, "i-small").Truncated) | |
| 385 | - | assert.True(t, d.Diff.Truncated) | |
| 383 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left") | |
| 384 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted) | |
| 385 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted) | |
| 386 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted) | |
| 387 | + | assert.False(t, d.Diff.Truncated) | |
| 386 | 388 | assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions) | |
| 387 | 389 | assert.Equal(t, 14, d.Stats.FilesChanged) | |
| 388 | 390 | }) | |
| @@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) { | |||
| 401 | 403 | assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath) | |
| 402 | 404 | assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath) | |
| 403 | 405 | for _, file := range d.Diff.Files { | |
| 404 | - | assert.False(t, file.Truncated) | |
| 406 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 405 | 407 | assert.Len(t, file.Hunks, 1) | |
| 406 | 408 | } | |
| 407 | 409 | }) | |
| @@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) { | |||
| 424 | 426 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20}) | |
| 425 | 427 | require.NoError(t, err) | |
| 426 | 428 | require.Len(t, d.Diff.Files, 2) | |
| 427 | - | assert.False(t, d.Diff.Files[0].Truncated) | |
| 428 | - | assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left") | |
| 429 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |
| 430 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left") | |
| 429 | 431 | assert.Equal(t, 3, d.Stats.FilesChanged) | |
| 430 | 432 | ||
| 431 | 433 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50}) | |
| 432 | 434 | require.NoError(t, err) | |
| 433 | 435 | require.Len(t, d.Diff.Files, 3) | |
| 434 | - | assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50") | |
| 436 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50") | |
| 435 | 437 | for _, file := range d.Diff.Files[1:] { | |
| 436 | - | assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30") | |
| 438 | + | assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30") | |
| 437 | 439 | assert.Empty(t, file.Hunks) | |
| 438 | 440 | } | |
| 439 | 441 | }) | |
| @@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) { | |||
| 444 | 446 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 445 | 447 | require.NoError(t, err) | |
| 446 | 448 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | |
| 447 | - | assert.True(t, long.Truncated) | |
| 449 | + | assert.NotEqual(t, domain.OmitNone, long.Omitted) | |
| 448 | 450 | assert.Empty(t, long.Hunks) | |
| 449 | - | assert.False(t, short.Truncated) | |
| 451 | + | assert.Equal(t, domain.OmitNone, short.Omitted) | |
| 450 | 452 | assert.Len(t, short.Hunks, 1) | |
| 451 | 453 | }) | |
| 452 | 454 | ||
| @@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) { | |||
| 458 | 460 | id := f.Commit("v2") | |
| 459 | 461 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | |
| 460 | 462 | require.NoError(t, err) | |
| 461 | - | assert.True(t, find(d.Diff, "a-big").Truncated) | |
| 463 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted) | |
| 462 | 464 | assert.True(t, find(d.Diff, "b-bin").Binary) | |
| 463 | - | assert.False(t, find(d.Diff, "b-bin").Truncated) | |
| 464 | - | assert.False(t, find(d.Diff, "c-mode").Truncated) | |
| 465 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted) | |
| 466 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted) | |
| 465 | 467 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | |
| 466 | 468 | // What is kept matches git. | |
| 467 | 469 | sections := showSections(f, id) | |
| @@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) { | |||
| 480 | 482 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) | |
| 481 | 483 | require.NoError(t, err) | |
| 482 | 484 | require.Len(t, d.Diff.Files, 2) | |
| 483 | - | assert.False(t, d.Diff.Files[0].Truncated) | |
| 484 | - | assert.True(t, d.Diff.Files[1].Truncated) | |
| 485 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |
| 486 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted) | |
| 485 | 487 | assert.Equal(t, 100005, d.Stats.Additions) | |
| 486 | 488 | } | |
| 487 | 489 | ||
| @@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) { | |||
| 580 | 582 | for i, file := range d.Diff.Files { | |
| 581 | 583 | switch i { | |
| 582 | 584 | case 3: | |
| 583 | - | assert.False(t, file.Truncated) | |
| 585 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 584 | 586 | assert.Len(t, file.Hunks, 1, "10 lines fit in 15") | |
| 585 | 587 | case 4: | |
| 586 | - | assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left") | |
| 588 | + | assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left") | |
| 587 | 589 | default: | |
| 588 | - | assert.False(t, file.Truncated) | |
| 590 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 589 | 591 | assert.Empty(t, file.Hunks) | |
| 590 | 592 | assert.Equal(t, 10, file.Additions, "counts stay accurate") | |
| 591 | 593 | } | |
| 592 | 594 | } | |
| 593 | - | assert.True(t, d.Diff.Truncated, "a file of the window is truncated") | |
| 595 | + | assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated") | |
| 594 | 596 | ||
| 595 | 597 | limits.From, limits.To = 0, 0 | |
| 596 | 598 | limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file | |
| 597 | 599 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 598 | 600 | require.NoError(t, err) | |
| 599 | - | assert.True(t, d.Diff.Files[0].Truncated) | |
| 601 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |
| 600 | 602 | }) | |
| 601 | 603 | ||
| 602 | 604 | t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) { | |
| @@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) { | |||
| 608 | 610 | require.NoError(t, err) | |
| 609 | 611 | require.Len(t, d.Diff.Files, 2) | |
| 610 | 612 | assert.Len(t, d.Diff.Files[0].Hunks, 1) | |
| 611 | - | assert.False(t, d.Diff.Files[1].Truncated) | |
| 613 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted) | |
| 612 | 614 | assert.Empty(t, d.Diff.Files[1].Hunks) | |
| 613 | 615 | assert.False(t, d.Diff.Truncated) | |
| 614 | 616 | limits.From, limits.To = 1, 2 | |
| 615 | 617 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 616 | 618 | require.NoError(t, err) | |
| 617 | - | assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit") | |
| 618 | - | assert.True(t, d.Diff.Truncated) | |
| 619 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit") | |
| 620 | + | assert.False(t, d.Diff.Truncated) | |
| 619 | 621 | }) | |
| 620 | 622 | ||
| 621 | 623 | t.Run("MaxFiles 0 lists every file", func(t *testing.T) { | |
| @@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) { | |||
| 650 | 652 | } else { | |
| 651 | 653 | assert.Empty(t, file.Hunks) | |
| 652 | 654 | } | |
| 653 | - | assert.False(t, file.Truncated) | |
| 655 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 654 | 656 | } | |
| 655 | 657 | ||
| 656 | 658 | // The default limits still cut at 300. | |
| @@ -177,7 +177,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 177 | assert.Equal(t, domain.FileModified, bin.Status) | 177 | assert.Equal(t, domain.FileModified, bin.Status) | ||
| 178 | assert.True(t, bin.Binary) | 178 | assert.True(t, bin.Binary) | ||
| 179 | assert.Empty(t, bin.Hunks) | 179 | assert.Empty(t, bin.Hunks) | ||
| 180 | - | assert.False(t, bin.Truncated) | 180 | + | assert.Equal(t, domain.OmitNone, bin.Omitted) |
| 181 | 181 | ||||
| 182 | mode := files["d/f>d/f"] | 182 | mode := files["d/f>d/f"] | ||
| 183 | assert.Equal(t, domain.FileModified, mode.Status) | 183 | assert.Equal(t, domain.FileModified, mode.Status) | ||
| @@ -185,7 +185,7 @@ func TestGetCommitFileMetadata(t *testing.T) { | |||||
| 185 | assert.Equal(t, uint32(0o100755), mode.NewMode) | 185 | assert.Equal(t, uint32(0o100755), mode.NewMode) | ||
| 186 | assert.Empty(t, mode.Hunks) | 186 | assert.Empty(t, mode.Hunks) | ||
| 187 | assert.False(t, mode.Binary) | 187 | assert.False(t, mode.Binary) | ||
| 188 | - | assert.False(t, mode.Truncated) | 188 | + | assert.Equal(t, domain.OmitNone, mode.Omitted) |
| 189 | 189 | ||||
| 190 | del := files["README.md>"] | 190 | del := files["README.md>"] | ||
| 191 | assert.Equal(t, domain.FileDeleted, del.Status) | 191 | assert.Equal(t, domain.FileDeleted, del.Status) | ||
| @@ -315,22 +315,22 @@ func TestDiffLimits(t *testing.T) { | |||||
| 315 | require.NoError(t, err) | 315 | require.NoError(t, err) | ||
| 316 | exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small") | 316 | exact, over, small := find(d.Diff, "exact"), find(d.Diff, "over"), find(d.Diff, "small") | ||
| 317 | 317 | ||||
| 318 | - | assert.False(t, exact.Truncated) | 318 | + | assert.Equal(t, domain.OmitNone, exact.Omitted) |
| 319 | require.Len(t, exact.Hunks, 1) | 319 | require.Len(t, exact.Hunks, 1) | ||
| 320 | assert.Len(t, exact.Hunks[0].Lines, 2000) | 320 | assert.Len(t, exact.Hunks[0].Lines, 2000) | ||
| 321 | - | assert.True(t, over.Truncated) | 321 | + | assert.NotEqual(t, domain.OmitNone, over.Omitted) |
| 322 | assert.Empty(t, over.Hunks) | 322 | assert.Empty(t, over.Hunks) | ||
| 323 | assert.Equal(t, 2001, over.Additions, "counts stay accurate") | 323 | assert.Equal(t, 2001, over.Additions, "counts stay accurate") | ||
| 324 | - | assert.False(t, small.Truncated) | 324 | + | assert.Equal(t, domain.OmitNone, small.Omitted) |
| 325 | assert.Len(t, small.Hunks, 1) | 325 | assert.Len(t, small.Hunks, 1) | ||
| 326 | - | assert.True(t, d.Diff.Truncated) | 326 | + | assert.False(t, d.Diff.Truncated) |
| 327 | assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats) | 327 | assert.Equal(t, domain.DiffStats{FilesChanged: 3, Additions: 4004}, d.Stats) | ||
| 328 | }) | 328 | }) | ||
| 329 | 329 | ||||
| 330 | - | t.Run("context counts towards the file limit", func(t *testing.T) { | 330 | + | t.Run("context does not count towards the file limit", func(t *testing.T) { |
| 331 | f := readertest.NewFixture(t, data, "limit-context") | 331 | f := readertest.NewFixture(t, data, "limit-context") | ||
| 332 | f.Lines("big", "l", 3000).Commit("v1") | 332 | f.Lines("big", "l", 3000).Commit("v1") | ||
| 333 | - | // Every 7th line changes: ~428 changed lines, but the hunks merge | 333 | + | // Every 7th line changes: 429 additions plus 429 deletions, but the hunks merge |
| 334 | // into one that spans the file with its context. | 334 | // into one that spans the file with its context. | ||
| 335 | var b strings.Builder | 335 | var b strings.Builder | ||
| 336 | for i := range 3000 { | 336 | for i := range 3000 { | ||
| @@ -346,11 +346,13 @@ func TestDiffLimits(t *testing.T) { | |||||
| 346 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | 346 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | ||
| 347 | require.NoError(t, err) | 347 | require.NoError(t, err) | ||
| 348 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | 348 | big, tiny := find(d.Diff, "big"), find(d.Diff, "tiny") | ||
| 349 | - | assert.True(t, big.Truncated) | 349 | + | assert.Equal(t, domain.OmitNone, big.Omitted) |
| 350 | - | assert.Empty(t, big.Hunks) | 350 | + | require.Len(t, big.Hunks, 1) |
| 351 | + | assert.Len(t, big.Hunks[0].Lines, 3000+429) | |||
| 352 | + | assert.False(t, d.Diff.Truncated) | |||
| 351 | assert.Equal(t, 429, big.Additions) | 353 | assert.Equal(t, 429, big.Additions) | ||
| 352 | assert.Equal(t, 429, big.Deletions) | 354 | assert.Equal(t, 429, big.Deletions) | ||
| 353 | - | assert.False(t, tiny.Truncated, "the file after a truncated one is still read") | 355 | + | assert.Equal(t, domain.OmitNone, tiny.Omitted, "the following file still fits the changed-line budget") |
| 354 | assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+")) | 356 | assert.Equal(t, "x\n", strings.TrimPrefix(unified(tiny.Hunks), "@@ -0,0 +1 @@\n+")) | ||
| 355 | assert.Equal(t, id, d.Commit.ID.String()) | 357 | assert.Equal(t, id, d.Commit.ID.String()) | ||
| 356 | }) | 358 | }) | ||
| @@ -376,13 +378,13 @@ func TestDiffLimits(t *testing.T) { | |||||
| 376 | } | 378 | } | ||
| 377 | assert.LessOrEqual(t, total, 20000) | 379 | assert.LessOrEqual(t, total, 20000) | ||
| 378 | for i := range 10 { | 380 | for i := range 10 { | ||
| 379 | - | assert.False(t, find(d.Diff, fmt.Sprintf("f%02d", i)).Truncated) | 381 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, fmt.Sprintf("f%02d", i)).Omitted) |
| 380 | } | 382 | } | ||
| 381 | - | assert.True(t, find(d.Diff, "f10").Truncated, "1,900 lines do not fit in the 1,000 left") | 383 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "f10").Omitted, "1,900 lines do not fit in the 1,000 left") |
| 382 | - | assert.False(t, find(d.Diff, "g-small").Truncated) | 384 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "g-small").Omitted) |
| 383 | - | assert.True(t, find(d.Diff, "h-big").Truncated) | 385 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "h-big").Omitted) |
| 384 | - | assert.False(t, find(d.Diff, "i-small").Truncated) | 386 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "i-small").Omitted) |
| 385 | - | assert.True(t, d.Diff.Truncated) | 387 | + | assert.False(t, d.Diff.Truncated) |
| 386 | assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions) | 388 | assert.Equal(t, 11*1900+50+1500+10, d.Stats.Additions) | ||
| 387 | assert.Equal(t, 14, d.Stats.FilesChanged) | 389 | assert.Equal(t, 14, d.Stats.FilesChanged) | ||
| 388 | }) | 390 | }) | ||
| @@ -401,7 +403,7 @@ func TestDiffLimits(t *testing.T) { | |||||
| 401 | assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath) | 403 | assert.Equal(t, "many/f000", d.Diff.Files[0].NewPath) | ||
| 402 | assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath) | 404 | assert.Equal(t, "many/f299", d.Diff.Files[299].NewPath) | ||
| 403 | for _, file := range d.Diff.Files { | 405 | for _, file := range d.Diff.Files { | ||
| 404 | - | assert.False(t, file.Truncated) | 406 | + | assert.Equal(t, domain.OmitNone, file.Omitted) |
| 405 | assert.Len(t, file.Hunks, 1) | 407 | assert.Len(t, file.Hunks, 1) | ||
| 406 | } | 408 | } | ||
| 407 | }) | 409 | }) | ||
| @@ -424,16 +426,16 @@ func TestDiffLimits(t *testing.T) { | |||||
| 424 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20}) | 426 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20}) | ||
| 425 | require.NoError(t, err) | 427 | require.NoError(t, err) | ||
| 426 | require.Len(t, d.Diff.Files, 2) | 428 | require.Len(t, d.Diff.Files, 2) | ||
| 427 | - | assert.False(t, d.Diff.Files[0].Truncated) | 429 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) |
| 428 | - | assert.True(t, d.Diff.Files[1].Truncated, "10 lines do not fit in the 5 left") | 430 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "10 lines do not fit in the 5 left") |
| 429 | assert.Equal(t, 3, d.Stats.FilesChanged) | 431 | assert.Equal(t, 3, d.Stats.FilesChanged) | ||
| 430 | 432 | ||||
| 431 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50}) | 433 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 10, MaxFileLines: 100, MaxTotalLines: 100, MaxTotalBytes: 50}) | ||
| 432 | require.NoError(t, err) | 434 | require.NoError(t, err) | ||
| 433 | require.Len(t, d.Diff.Files, 3) | 435 | require.Len(t, d.Diff.Files, 3) | ||
| 434 | - | assert.False(t, d.Diff.Files[0].Truncated, "30 bytes of text fit in 50") | 436 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "30 bytes of text fit in 50") |
| 435 | for _, file := range d.Diff.Files[1:] { | 437 | for _, file := range d.Diff.Files[1:] { | ||
| 436 | - | assert.True(t, file.Truncated, "the byte budget has 20 bytes left for 30") | 438 | + | assert.NotEqual(t, domain.OmitNone, file.Omitted, "the byte budget has 20 bytes left for 30") |
| 437 | assert.Empty(t, file.Hunks) | 439 | assert.Empty(t, file.Hunks) | ||
| 438 | } | 440 | } | ||
| 439 | }) | 441 | }) | ||
| @@ -444,9 +446,9 @@ func TestDiffLimits(t *testing.T) { | |||||
| 444 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | 446 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | ||
| 445 | require.NoError(t, err) | 447 | require.NoError(t, err) | ||
| 446 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | 448 | long, short := find(d.Diff, "long"), find(d.Diff, "short") | ||
| 447 | - | assert.True(t, long.Truncated) | 449 | + | assert.NotEqual(t, domain.OmitNone, long.Omitted) |
| 448 | assert.Empty(t, long.Hunks) | 450 | assert.Empty(t, long.Hunks) | ||
| 449 | - | assert.False(t, short.Truncated) | 451 | + | assert.Equal(t, domain.OmitNone, short.Omitted) |
| 450 | assert.Len(t, short.Hunks, 1) | 452 | assert.Len(t, short.Hunks, 1) | ||
| 451 | }) | 453 | }) | ||
| 452 | 454 | ||||
| @@ -458,10 +460,10 @@ func TestDiffLimits(t *testing.T) { | |||||
| 458 | id := f.Commit("v2") | 460 | id := f.Commit("v2") | ||
| 459 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | 461 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, defaults) | ||
| 460 | require.NoError(t, err) | 462 | require.NoError(t, err) | ||
| 461 | - | assert.True(t, find(d.Diff, "a-big").Truncated) | 463 | + | assert.NotEqual(t, domain.OmitNone, find(d.Diff, "a-big").Omitted) |
| 462 | assert.True(t, find(d.Diff, "b-bin").Binary) | 464 | assert.True(t, find(d.Diff, "b-bin").Binary) | ||
| 463 | - | assert.False(t, find(d.Diff, "b-bin").Truncated) | 465 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "b-bin").Omitted) |
| 464 | - | assert.False(t, find(d.Diff, "c-mode").Truncated) | 466 | + | assert.Equal(t, domain.OmitNone, find(d.Diff, "c-mode").Omitted) |
| 465 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | 467 | assert.Len(t, find(d.Diff, "d-text").Hunks, 1) | ||
| 466 | // What is kept matches git. | 468 | // What is kept matches git. | ||
| 467 | sections := showSections(f, id) | 469 | sections := showSections(f, id) | ||
| @@ -480,8 +482,8 @@ func TestGetCommitStopsGitEarly(t *testing.T) { | |||||
| 480 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) | 482 | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) | ||
| 481 | require.NoError(t, err) | 483 | require.NoError(t, err) | ||
| 482 | require.Len(t, d.Diff.Files, 2) | 484 | require.Len(t, d.Diff.Files, 2) | ||
| 483 | - | assert.False(t, d.Diff.Files[0].Truncated) | 485 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) |
| 484 | - | assert.True(t, d.Diff.Files[1].Truncated) | 486 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted) |
| 485 | assert.Equal(t, 100005, d.Stats.Additions) | 487 | assert.Equal(t, 100005, d.Stats.Additions) | ||
| 486 | } | 488 | } | ||
| 487 | 489 | ||||
| @@ -580,23 +582,23 @@ func TestDiffLimitsWindow(t *testing.T) { | |||||
| 580 | for i, file := range d.Diff.Files { | 582 | for i, file := range d.Diff.Files { | ||
| 581 | switch i { | 583 | switch i { | ||
| 582 | case 3: | 584 | case 3: | ||
| 583 | - | assert.False(t, file.Truncated) | 585 | + | assert.Equal(t, domain.OmitNone, file.Omitted) |
| 584 | assert.Len(t, file.Hunks, 1, "10 lines fit in 15") | 586 | assert.Len(t, file.Hunks, 1, "10 lines fit in 15") | ||
| 585 | case 4: | 587 | case 4: | ||
| 586 | - | assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left") | 588 | + | assert.NotEqual(t, domain.OmitNone, file.Omitted, "the second file of the window does not fit in the 5 lines left") |
| 587 | default: | 589 | default: | ||
| 588 | - | assert.False(t, file.Truncated) | 590 | + | assert.Equal(t, domain.OmitNone, file.Omitted) |
| 589 | assert.Empty(t, file.Hunks) | 591 | assert.Empty(t, file.Hunks) | ||
| 590 | assert.Equal(t, 10, file.Additions, "counts stay accurate") | 592 | assert.Equal(t, 10, file.Additions, "counts stay accurate") | ||
| 591 | } | 593 | } | ||
| 592 | } | 594 | } | ||
| 593 | - | assert.True(t, d.Diff.Truncated, "a file of the window is truncated") | 595 | + | assert.False(t, d.Diff.Truncated, "an omitted file does not set Diff.Truncated") |
| 594 | 596 | ||||
| 595 | limits.From, limits.To = 0, 0 | 597 | limits.From, limits.To = 0, 0 | ||
| 596 | limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file | 598 | limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file | ||
| 597 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | 599 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | ||
| 598 | require.NoError(t, err) | 600 | require.NoError(t, err) | ||
| 599 | - | assert.True(t, d.Diff.Files[0].Truncated) | 601 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[0].Omitted) |
| 600 | }) | 602 | }) | ||
| 601 | 603 | ||||
| 602 | t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) { | 604 | t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) { | ||
| @@ -608,14 +610,14 @@ func TestDiffLimitsWindow(t *testing.T) { | |||||
| 608 | require.NoError(t, err) | 610 | require.NoError(t, err) | ||
| 609 | require.Len(t, d.Diff.Files, 2) | 611 | require.Len(t, d.Diff.Files, 2) | ||
| 610 | assert.Len(t, d.Diff.Files[0].Hunks, 1) | 612 | assert.Len(t, d.Diff.Files[0].Hunks, 1) | ||
| 611 | - | assert.False(t, d.Diff.Files[1].Truncated) | 613 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[1].Omitted) |
| 612 | assert.Empty(t, d.Diff.Files[1].Hunks) | 614 | assert.Empty(t, d.Diff.Files[1].Hunks) | ||
| 613 | assert.False(t, d.Diff.Truncated) | 615 | assert.False(t, d.Diff.Truncated) | ||
| 614 | limits.From, limits.To = 1, 2 | 616 | limits.From, limits.To = 1, 2 | ||
| 615 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | 617 | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | ||
| 616 | require.NoError(t, err) | 618 | require.NoError(t, err) | ||
| 617 | - | assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit") | 619 | + | assert.NotEqual(t, domain.OmitNone, d.Diff.Files[1].Omitted, "in the window, over the per-file limit") |
| 618 | - | assert.True(t, d.Diff.Truncated) | 620 | + | assert.False(t, d.Diff.Truncated) |
| 619 | }) | 621 | }) | ||
| 620 | 622 | ||||
| 621 | t.Run("MaxFiles 0 lists every file", func(t *testing.T) { | 623 | t.Run("MaxFiles 0 lists every file", func(t *testing.T) { | ||
| @@ -650,7 +652,7 @@ func TestDiffLimitsWindow(t *testing.T) { | |||||
| 650 | } else { | 652 | } else { | ||
| 651 | assert.Empty(t, file.Hunks) | 653 | assert.Empty(t, file.Hunks) | ||
| 652 | } | 654 | } | ||
| 653 | - | assert.False(t, file.Truncated) | 655 | + | assert.Equal(t, domain.OmitNone, file.Omitted) |
| 654 | } | 656 | } | ||
| 655 | 657 | ||||
| 656 | // The default limits still cut at 300. | 658 | // The default limits still cut at 300. | ||
M internal/adapters/rpc/convert.go +17 -1
| @@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff { | |||
| 394 | 394 | Additions: int32(f.Additions), | |
| 395 | 395 | Deletions: int32(f.Deletions), | |
| 396 | 396 | Similarity: int32(f.Similarity), | |
| 397 | - | Truncated: f.Truncated, | |
| 397 | + | Truncated: f.Omitted != domain.OmitNone, | |
| 398 | + | Omitted: fileOmissionToPB(f.Omitted), | |
| 398 | 399 | } | |
| 399 | 400 | for _, h := range f.Hunks { | |
| 400 | 401 | out.Hunks = append(out.Hunks, hunkToPB(h)) | |
| @@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff { | |||
| 402 | 403 | return out | |
| 403 | 404 | } | |
| 404 | 405 | ||
| 406 | + | func fileOmissionToPB(o domain.FileOmission) pb.FileOmission { | |
| 407 | + | switch o { | |
| 408 | + | case domain.OmitGenerated: | |
| 409 | + | return pb.FileOmission_FILE_OMISSION_GENERATED | |
| 410 | + | case domain.OmitLarge: | |
| 411 | + | return pb.FileOmission_FILE_OMISSION_LARGE | |
| 412 | + | case domain.OmitBudget: | |
| 413 | + | return pb.FileOmission_FILE_OMISSION_BUDGET | |
| 414 | + | case domain.OmitUnavailable: | |
| 415 | + | return pb.FileOmission_FILE_OMISSION_UNAVAILABLE | |
| 416 | + | } | |
| 417 | + | return pb.FileOmission_FILE_OMISSION_UNSPECIFIED | |
| 418 | + | } | |
| 419 | + | ||
| 405 | 420 | func diffStatsToPB(s domain.DiffStats) *pb.DiffStats { | |
| 406 | 421 | return &pb.DiffStats{ | |
| 407 | 422 | FilesChanged: int32(s.FilesChanged), | |
| @@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats { | |||
| 413 | 428 | func diffToPB(d domain.Diff) *pb.Diff { | |
| 414 | 429 | out := &pb.Diff{Truncated: d.Truncated} | |
| 415 | 430 | for _, f := range d.Files { | |
| 431 | + | out.Truncated = out.Truncated || f.Omitted != domain.OmitNone | |
| 416 | 432 | out.Files = append(out.Files, fileDiffToPB(f)) | |
| 417 | 433 | } | |
| 418 | 434 | return out | |
| @@ -394,7 +394,8 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff { | |||||
| 394 | Additions: int32(f.Additions), | 394 | Additions: int32(f.Additions), | ||
| 395 | Deletions: int32(f.Deletions), | 395 | Deletions: int32(f.Deletions), | ||
| 396 | Similarity: int32(f.Similarity), | 396 | Similarity: int32(f.Similarity), | ||
| 397 | - | Truncated: f.Truncated, | 397 | + | Truncated: f.Omitted != domain.OmitNone, |
| 398 | + | Omitted: fileOmissionToPB(f.Omitted), | |||
| 398 | } | 399 | } | ||
| 399 | for _, h := range f.Hunks { | 400 | for _, h := range f.Hunks { | ||
| 400 | out.Hunks = append(out.Hunks, hunkToPB(h)) | 401 | out.Hunks = append(out.Hunks, hunkToPB(h)) | ||
| @@ -402,6 +403,20 @@ func fileDiffToPB(f domain.FileDiff) *pb.FileDiff { | |||||
| 402 | return out | 403 | return out | ||
| 403 | } | 404 | } | ||
| 404 | 405 | ||||
| 406 | + | func fileOmissionToPB(o domain.FileOmission) pb.FileOmission { | |||
| 407 | + | switch o { | |||
| 408 | + | case domain.OmitGenerated: | |||
| 409 | + | return pb.FileOmission_FILE_OMISSION_GENERATED | |||
| 410 | + | case domain.OmitLarge: | |||
| 411 | + | return pb.FileOmission_FILE_OMISSION_LARGE | |||
| 412 | + | case domain.OmitBudget: | |||
| 413 | + | return pb.FileOmission_FILE_OMISSION_BUDGET | |||
| 414 | + | case domain.OmitUnavailable: | |||
| 415 | + | return pb.FileOmission_FILE_OMISSION_UNAVAILABLE | |||
| 416 | + | } | |||
| 417 | + | return pb.FileOmission_FILE_OMISSION_UNSPECIFIED | |||
| 418 | + | } | |||
| 419 | + | ||||
| 405 | func diffStatsToPB(s domain.DiffStats) *pb.DiffStats { | 420 | func diffStatsToPB(s domain.DiffStats) *pb.DiffStats { | ||
| 406 | return &pb.DiffStats{ | 421 | return &pb.DiffStats{ | ||
| 407 | FilesChanged: int32(s.FilesChanged), | 422 | FilesChanged: int32(s.FilesChanged), | ||
| @@ -413,6 +428,7 @@ func diffStatsToPB(s domain.DiffStats) *pb.DiffStats { | |||||
| 413 | func diffToPB(d domain.Diff) *pb.Diff { | 428 | func diffToPB(d domain.Diff) *pb.Diff { | ||
| 414 | out := &pb.Diff{Truncated: d.Truncated} | 429 | out := &pb.Diff{Truncated: d.Truncated} | ||
| 415 | for _, f := range d.Files { | 430 | for _, f := range d.Files { | ||
| 431 | + | out.Truncated = out.Truncated || f.Omitted != domain.OmitNone | |||
| 416 | out.Files = append(out.Files, fileDiffToPB(f)) | 432 | out.Files = append(out.Files, fileDiffToPB(f)) | ||
| 417 | } | 433 | } | ||
| 418 | return out | 434 | return out | ||
M internal/adapters/rpc/convert_test.go +18 -0
| @@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) { | |||
| 398 | 398 | assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability()) | |
| 399 | 399 | } | |
| 400 | 400 | } | |
| 401 | + | ||
| 402 | + | func TestFileDiffOmissionMapping(t *testing.T) { | |
| 403 | + | for o, want := range map[domain.FileOmission]pb.FileOmission{ | |
| 404 | + | domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED, | |
| 405 | + | domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED, | |
| 406 | + | domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE, | |
| 407 | + | domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET, | |
| 408 | + | domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE, | |
| 409 | + | } { | |
| 410 | + | f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o}) | |
| 411 | + | assert.Equal(t, want, f.GetOmitted()) | |
| 412 | + | assert.Equal(t, o != domain.OmitNone, f.GetTruncated()) | |
| 413 | + | d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}}) | |
| 414 | + | assert.Equal(t, o != domain.OmitNone, d.GetTruncated()) | |
| 415 | + | } | |
| 416 | + | assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated()) | |
| 417 | + | assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated()) | |
| 418 | + | } | |
| @@ -398,3 +398,21 @@ func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) { | |||||
| 398 | assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability()) | 398 | assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability()) | ||
| 399 | } | 399 | } | ||
| 400 | } | 400 | } | ||
| 401 | + | ||||
| 402 | + | func TestFileDiffOmissionMapping(t *testing.T) { | |||
| 403 | + | for o, want := range map[domain.FileOmission]pb.FileOmission{ | |||
| 404 | + | domain.OmitNone: pb.FileOmission_FILE_OMISSION_UNSPECIFIED, | |||
| 405 | + | domain.OmitGenerated: pb.FileOmission_FILE_OMISSION_GENERATED, | |||
| 406 | + | domain.OmitLarge: pb.FileOmission_FILE_OMISSION_LARGE, | |||
| 407 | + | domain.OmitBudget: pb.FileOmission_FILE_OMISSION_BUDGET, | |||
| 408 | + | domain.OmitUnavailable: pb.FileOmission_FILE_OMISSION_UNAVAILABLE, | |||
| 409 | + | } { | |||
| 410 | + | f := fileDiffToPB(domain.FileDiff{NewPath: "a", Omitted: o}) | |||
| 411 | + | assert.Equal(t, want, f.GetOmitted()) | |||
| 412 | + | assert.Equal(t, o != domain.OmitNone, f.GetTruncated()) | |||
| 413 | + | d := diffToPB(domain.Diff{Files: []domain.FileDiff{{Omitted: o}}}) | |||
| 414 | + | assert.Equal(t, o != domain.OmitNone, d.GetTruncated()) | |||
| 415 | + | } | |||
| 416 | + | assert.True(t, diffToPB(domain.Diff{Truncated: true}).GetTruncated()) | |||
| 417 | + | assert.False(t, diffToPB(domain.Diff{Files: []domain.FileDiff{{}}}).GetTruncated()) | |||
| 418 | + | } | |||
M internal/adapters/rpc/git.go +1 -1
| @@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC | |||
| 180 | 180 | if err != nil { | |
| 181 | 181 | return nil, connectError(ctx, err) | |
| 182 | 182 | } | |
| 183 | - | detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev) | |
| 183 | + | detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{}) | |
| 184 | 184 | if err != nil { | |
| 185 | 185 | return nil, connectError(ctx, err) | |
| 186 | 186 | } | |
| @@ -180,7 +180,7 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC | |||||
| 180 | if err != nil { | 180 | if err != nil { | ||
| 181 | return nil, connectError(ctx, err) | 181 | return nil, connectError(ctx, err) | ||
| 182 | } | 182 | } | ||
| 183 | - | detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev) | 183 | + | detail, err := s.browse.GetCommit(ctx, caller(ctx), name, rev, ports.GetCommitOptions{}) |
| 184 | if err != nil { | 184 | if err != nil { | ||
| 185 | return nil, connectError(ctx, err) | 185 | return nil, connectError(ctx, err) | ||
| 186 | } | 186 | } | ||
M internal/adapters/rpc/git_test.go +1 -1
| @@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) { | |||
| 157 | 157 | t.Run("GetCommit not found", func(t *testing.T) { | |
| 158 | 158 | t.Parallel() | |
| 159 | 159 | browse := mocks.NewBrowseService(t) | |
| 160 | - | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() | |
| 160 | + | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() | |
| 161 | 161 | _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"})) | |
| 162 | 162 | requireCode(t, connect.CodeNotFound, err) | |
| 163 | 163 | }) | |
| @@ -157,7 +157,7 @@ func TestGitServiceGetCommitAndResolveRevision(t *testing.T) { | |||||
| 157 | t.Run("GetCommit not found", func(t *testing.T) { | 157 | t.Run("GetCommit not found", func(t *testing.T) { | ||
| 158 | t.Parallel() | 158 | t.Parallel() | ||
| 159 | browse := mocks.NewBrowseService(t) | 159 | browse := mocks.NewBrowseService(t) | ||
| 160 | - | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() | 160 | + | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() |
| 161 | _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"})) | 161 | _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"})) | ||
| 162 | requireCode(t, connect.CodeNotFound, err) | 162 | requireCode(t, connect.CodeNotFound, err) | ||
| 163 | }) | 163 | }) | ||
M internal/adapters/web/assets.go +2 -1
| @@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/" | |||
| 24 | 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 25 | 25 | // the content, so they can be cached for ever. | |
| 26 | 26 | type Assets struct { | |
| 27 | - | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string | |
| 27 | + | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string | |
| 28 | 28 | // Manifest is the web app manifest (install on a phone's home screen), | |
| 29 | 29 | // AppleIcon the home screen icon iOS reads from a link instead. | |
| 30 | 30 | Manifest, AppleIcon string | |
| @@ -64,6 +64,7 @@ var scripts = []struct { | |||
| 64 | 64 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |
| 65 | 65 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 66 | 66 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 67 | + | {"lazy.js", func(a *Assets, u string) { a.Lazy = u }}, | |
| 67 | 68 | {"mr.js", func(a *Assets, u string) { a.MR = u }}, | |
| 68 | 69 | {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }}, | |
| 69 | 70 | {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }}, | |
| @@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/" | |||||
| 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | ||
| 25 | // the content, so they can be cached for ever. | 25 | // the content, so they can be cached for ever. | ||
| 26 | type Assets struct { | 26 | type Assets struct { | ||
| 27 | - | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string | 27 | + | CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, Lazy, MR, IssueForm, PowWorker, Icon string |
| 28 | // Manifest is the web app manifest (install on a phone's home screen), | 28 | // Manifest is the web app manifest (install on a phone's home screen), | ||
| 29 | // AppleIcon the home screen icon iOS reads from a link instead. | 29 | // AppleIcon the home screen icon iOS reads from a link instead. | ||
| 30 | Manifest, AppleIcon string | 30 | Manifest, AppleIcon string | ||
| @@ -64,6 +64,7 @@ var scripts = []struct { | |||||
| 64 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | 64 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | ||
| 65 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | 65 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | ||
| 66 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | 66 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | ||
| 67 | + | {"lazy.js", func(a *Assets, u string) { a.Lazy = u }}, | |||
| 67 | {"mr.js", func(a *Assets, u string) { a.MR = u }}, | 68 | {"mr.js", func(a *Assets, u string) { a.MR = u }}, | ||
| 68 | {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }}, | 69 | {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }}, | ||
| 69 | {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }}, | 70 | {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }}, | ||
M internal/adapters/web/browse.go +2 -2
| @@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st | |||
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | // getCommit reads a commit with its diff. | |
| 48 | - | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |
| 48 | + | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) { | |
| 49 | 49 | r, err := domain.ParseRevision(rev) | |
| 50 | 50 | if err != nil { | |
| 51 | 51 | return domain.CommitDetail{}, err | |
| 52 | 52 | } | |
| 53 | - | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r) | |
| 53 | + | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts) | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | // openBlobAt opens the file path of the commit rev. The caller closes the | |
| @@ -45,12 +45,12 @@ func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path st | |||||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | // getCommit reads a commit with its diff. | 47 | // getCommit reads a commit with its diff. | ||
| 48 | - | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | 48 | + | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string, opts ports.GetCommitOptions) (domain.CommitDetail, error) { |
| 49 | r, err := domain.ParseRevision(rev) | 49 | r, err := domain.ParseRevision(rev) | ||
| 50 | if err != nil { | 50 | if err != nil { | ||
| 51 | return domain.CommitDetail{}, err | 51 | return domain.CommitDetail{}, err | ||
| 52 | } | 52 | } | ||
| 53 | - | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r) | 53 | + | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r, opts) |
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | // openBlobAt opens the file path of the commit rev. The caller closes the | 56 | // openBlobAt opens the file path of the commit rev. The caller closes the | ||
M internal/adapters/web/commit.go +57 -16
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/a-h/templ" | |
| 13 | 13 | ||
| 14 | 14 | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // bothViewsLines is the largest diff, in lines, that is sent in both the | |
| @@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 105 | 106 | if err != nil { | |
| 106 | 107 | return nil, 0, err | |
| 107 | 108 | } | |
| 108 | - | res, err := s.getCommit(ctx, d.repo.Name, sha) | |
| 109 | + | res, err := s.commitDiff(ctx, r, d.repo.Name, sha) | |
| 109 | 110 | if err != nil { | |
| 110 | 111 | return nil, 0, err | |
| 111 | 112 | } | |
| @@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 128 | 129 | Verification: c.Verification, | |
| 129 | 130 | } | |
| 130 | 131 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | |
| 132 | + | collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid)) | |
| 131 | 133 | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { | |
| 132 | 134 | p.Committer = &cm | |
| 133 | 135 | } | |
| @@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 145 | 147 | p.Refs = append(p.Refs, "tag: "+t.ShortName) | |
| 146 | 148 | } | |
| 147 | 149 | } | |
| 148 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 150 | + | p.Note = diffNote(res.Diff) | |
| 149 | 151 | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | |
| 150 | 152 | ||
| 151 | 153 | lines, invisible := diffLines(p.Files) | |
| @@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 166 | 168 | {Label: p.Short, Current: true}, | |
| 167 | 169 | } | |
| 168 | 170 | l.HasRail = true | |
| 171 | + | l.Lazy = true | |
| 169 | 172 | return Commit(l, p), http.StatusOK, nil | |
| 170 | 173 | } | |
| 171 | 174 | ||
| @@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string { | |||
| 186 | 189 | return messageText(strings.TrimSpace(body)) | |
| 187 | 190 | } | |
| 188 | 191 | ||
| 189 | - | // diffNote says what is missing from an incomplete diff, "" for a complete one. | |
| 190 | - | func diffNote(d domain.Diff, shown, total int) string { | |
| 191 | - | if !d.Truncated { | |
| 192 | + | // diffNote says what is missing from an incomplete diff, "" for a complete one: | |
| 193 | + | // files too large for the browser at all. Collapsed files say so on their own | |
| 194 | + | // line and load on their own; every changed file is listed. | |
| 195 | + | func diffNote(d domain.Diff) string { | |
| 196 | + | gone := 0 | |
| 197 | + | for _, f := range d.Files { | |
| 198 | + | if f.Omitted == domain.OmitUnavailable { | |
| 199 | + | gone++ | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if gone == 0 { | |
| 192 | 203 | return "" | |
| 193 | 204 | } | |
| 194 | - | var parts []string | |
| 195 | - | if shown < total { | |
| 196 | - | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | |
| 205 | + | return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest." | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // commitDiff reads a commit for its page (and the compare page): every file | |
| 209 | + | // listed, the hunks of the first files read, generated files and files past | |
| 210 | + | // the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link | |
| 211 | + | // of a page without scripts) file i is read in full and replaces its entry. | |
| 212 | + | func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |
| 213 | + | return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)}) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // commitLinks are the addresses a commit page loads its collapsed files from. | |
| 217 | + | func commitLinks(repo, rev string) fileLinks { | |
| 218 | + | return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev} | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]: | |
| 222 | + | // the one file i of the commit with its hunks, in full, as the one | |
| 223 | + | // details.dfile the page swaps for its placeholder. | |
| 224 | + | func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 225 | + | sha := r.PathValue("sha") | |
| 226 | + | i, err := parseOne(r) | |
| 227 | + | if err != nil || !isCommitID(sha) { | |
| 228 | + | return nil, 0, errNotFound | |
| 229 | + | } | |
| 230 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 231 | + | if err != nil { | |
| 232 | + | return nil, 0, err | |
| 197 | 233 | } | |
| 198 | - | cut := 0 | |
| 199 | - | for _, f := range d.Files { | |
| 200 | - | if f.Truncated { | |
| 201 | - | cut++ | |
| 202 | - | } | |
| 234 | + | res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}}) | |
| 235 | + | if err != nil { | |
| 236 | + | return nil, 0, err | |
| 203 | 237 | } | |
| 204 | - | if cut > 0 { | |
| 205 | - | parts = append(parts, plural(cut, "file is", "files are")+" too large to show") | |
| 238 | + | if i >= len(res.Diff.Files) { | |
| 239 | + | return nil, 0, errNotFound | |
| 206 | 240 | } | |
| 207 | - | return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest." | |
| 241 | + | cid := res.Commit.ID.String() | |
| 242 | + | raw := res.Diff.Files[i : i+1] | |
| 243 | + | files := s.hl.files(ctx, raw) | |
| 244 | + | files[0].ID = "f" + itoa(i+1) | |
| 245 | + | contextGaps(files, raw, repoHref(d.name(), "context", cid)) | |
| 246 | + | files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid)) | |
| 247 | + | lines, _ := diffLines(files) | |
| 248 | + | return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil | |
| 208 | 249 | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/a-h/templ" | 12 | "github.com/a-h/templ" | ||
| 13 | 13 | ||||
| 14 | "bnema.dev/ermite/internal/domain" | 14 | "bnema.dev/ermite/internal/domain" | ||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | // bothViewsLines is the largest diff, in lines, that is sent in both the | 18 | // bothViewsLines is the largest diff, in lines, that is sent in both the | ||
| @@ -105,7 +106,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 105 | if err != nil { | 106 | if err != nil { | ||
| 106 | return nil, 0, err | 107 | return nil, 0, err | ||
| 107 | } | 108 | } | ||
| 108 | - | res, err := s.getCommit(ctx, d.repo.Name, sha) | 109 | + | res, err := s.commitDiff(ctx, r, d.repo.Name, sha) |
| 109 | if err != nil { | 110 | if err != nil { | ||
| 110 | return nil, 0, err | 111 | return nil, 0, err | ||
| 111 | } | 112 | } | ||
| @@ -128,6 +129,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 128 | Verification: c.Verification, | 129 | Verification: c.Verification, | ||
| 129 | } | 130 | } | ||
| 130 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | 131 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | ||
| 132 | + | collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid)) | |||
| 131 | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { | 133 | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { | ||
| 132 | p.Committer = &cm | 134 | p.Committer = &cm | ||
| 133 | } | 135 | } | ||
| @@ -145,7 +147,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 145 | p.Refs = append(p.Refs, "tag: "+t.ShortName) | 147 | p.Refs = append(p.Refs, "tag: "+t.ShortName) | ||
| 146 | } | 148 | } | ||
| 147 | } | 149 | } | ||
| 148 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 150 | + | p.Note = diffNote(res.Diff) |
| 149 | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | 151 | p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid)) | ||
| 150 | 152 | ||||
| 151 | lines, invisible := diffLines(p.Files) | 153 | lines, invisible := diffLines(p.Files) | ||
| @@ -166,6 +168,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 166 | {Label: p.Short, Current: true}, | 168 | {Label: p.Short, Current: true}, | ||
| 167 | } | 169 | } | ||
| 168 | l.HasRail = true | 170 | l.HasRail = true | ||
| 171 | + | l.Lazy = true | |||
| 169 | return Commit(l, p), http.StatusOK, nil | 172 | return Commit(l, p), http.StatusOK, nil | ||
| 170 | } | 173 | } | ||
| 171 | 174 | ||||
| @@ -186,23 +189,61 @@ func commitBody(c domain.Commit) string { | |||||
| 186 | return messageText(strings.TrimSpace(body)) | 189 | return messageText(strings.TrimSpace(body)) | ||
| 187 | } | 190 | } | ||
| 188 | 191 | ||||
| 189 | - | // diffNote says what is missing from an incomplete diff, "" for a complete one. | 192 | + | // diffNote says what is missing from an incomplete diff, "" for a complete one: |
| 190 | - | func diffNote(d domain.Diff, shown, total int) string { | 193 | + | // files too large for the browser at all. Collapsed files say so on their own |
| 191 | - | if !d.Truncated { | 194 | + | // line and load on their own; every changed file is listed. |
| 195 | + | func diffNote(d domain.Diff) string { | |||
| 196 | + | gone := 0 | |||
| 197 | + | for _, f := range d.Files { | |||
| 198 | + | if f.Omitted == domain.OmitUnavailable { | |||
| 199 | + | gone++ | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | if gone == 0 { | |||
| 192 | return "" | 203 | return "" | ||
| 193 | } | 204 | } | ||
| 194 | - | var parts []string | 205 | + | return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest." |
| 195 | - | if shown < total { | 206 | + | } |
| 196 | - | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | 207 | + | |
| 208 | + | // commitDiff reads a commit for its page (and the compare page): every file | |||
| 209 | + | // listed, the hunks of the first files read, generated files and files past | |||
| 210 | + | // the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link | |||
| 211 | + | // of a page without scripts) file i is read in full and replaces its entry. | |||
| 212 | + | func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |||
| 213 | + | return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)}) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // commitLinks are the addresses a commit page loads its collapsed files from. | |||
| 217 | + | func commitLinks(repo, rev string) fileLinks { | |||
| 218 | + | return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev} | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]: | |||
| 222 | + | // the one file i of the commit with its hunks, in full, as the one | |||
| 223 | + | // details.dfile the page swaps for its placeholder. | |||
| 224 | + | func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 225 | + | sha := r.PathValue("sha") | |||
| 226 | + | i, err := parseOne(r) | |||
| 227 | + | if err != nil || !isCommitID(sha) { | |||
| 228 | + | return nil, 0, errNotFound | |||
| 229 | + | } | |||
| 230 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 231 | + | if err != nil { | |||
| 232 | + | return nil, 0, err | |||
| 197 | } | 233 | } | ||
| 198 | - | cut := 0 | 234 | + | res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}}) |
| 199 | - | for _, f := range d.Files { | 235 | + | if err != nil { |
| 200 | - | if f.Truncated { | 236 | + | return nil, 0, err |
| 201 | - | cut++ | |||
| 202 | - | } | |||
| 203 | } | 237 | } | ||
| 204 | - | if cut > 0 { | 238 | + | if i >= len(res.Diff.Files) { |
| 205 | - | parts = append(parts, plural(cut, "file is", "files are")+" too large to show") | 239 | + | return nil, 0, errNotFound |
| 206 | } | 240 | } | ||
| 207 | - | return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest." | 241 | + | cid := res.Commit.ID.String() |
| 242 | + | raw := res.Diff.Files[i : i+1] | |||
| 243 | + | files := s.hl.files(ctx, raw) | |||
| 244 | + | files[0].ID = "f" + itoa(i+1) | |||
| 245 | + | contextGaps(files, raw, repoHref(d.name(), "context", cid)) | |||
| 246 | + | files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid)) | |||
| 247 | + | lines, _ := diffLines(files) | |||
| 248 | + | return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil | |||
| 208 | } | 249 | } | ||
M internal/adapters/web/compare.go +4 -2
| @@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 76 | 76 | ||
| 77 | 77 | // The exact diff: one commit on top of the tip of the target. | |
| 78 | 78 | if exact && !behind && len(ahead) == 1 { | |
| 79 | - | res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String()) | |
| 79 | + | res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String()) | |
| 80 | 80 | if err != nil { | |
| 81 | 81 | return nil, 0, err | |
| 82 | 82 | } | |
| 83 | 83 | p.HasDiff = true | |
| 84 | 84 | p.Files = s.hl.files(ctx, res.Diff.Files) | |
| 85 | 85 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String())) | |
| 86 | + | collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String())) | |
| 86 | 87 | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions | |
| 87 | - | if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" { | |
| 88 | + | if n := diffNote(res.Diff); n != "" { | |
| 88 | 89 | p.Note = n | |
| 89 | 90 | } | |
| 90 | 91 | lines, invisible := diffLines(p.Files) | |
| @@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 131 | 132 | Crumb{Label: p.Source, Current: true}, | |
| 132 | 133 | ) | |
| 133 | 134 | l.HasRail = true | |
| 135 | + | l.Lazy = true | |
| 134 | 136 | return Compare(l, p), http.StatusOK, nil | |
| 135 | 137 | } | |
| 136 | 138 | ||
| @@ -76,15 +76,16 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 76 | 76 | ||||
| 77 | // The exact diff: one commit on top of the tip of the target. | 77 | // The exact diff: one commit on top of the tip of the target. | ||
| 78 | if exact && !behind && len(ahead) == 1 { | 78 | if exact && !behind && len(ahead) == 1 { | ||
| 79 | - | res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String()) | 79 | + | res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String()) |
| 80 | if err != nil { | 80 | if err != nil { | ||
| 81 | return nil, 0, err | 81 | return nil, 0, err | ||
| 82 | } | 82 | } | ||
| 83 | p.HasDiff = true | 83 | p.HasDiff = true | ||
| 84 | p.Files = s.hl.files(ctx, res.Diff.Files) | 84 | p.Files = s.hl.files(ctx, res.Diff.Files) | ||
| 85 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String())) | 85 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String())) | ||
| 86 | + | collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String())) | |||
| 86 | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions | 87 | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions | ||
| 87 | - | if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" { | 88 | + | if n := diffNote(res.Diff); n != "" { |
| 88 | p.Note = n | 89 | p.Note = n | ||
| 89 | } | 90 | } | ||
| 90 | lines, invisible := diffLines(p.Files) | 91 | lines, invisible := diffLines(p.Files) | ||
| @@ -131,6 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 131 | Crumb{Label: p.Source, Current: true}, | 132 | Crumb{Label: p.Source, Current: true}, | ||
| 132 | ) | 133 | ) | ||
| 133 | l.HasRail = true | 134 | l.HasRail = true | ||
| 135 | + | l.Lazy = true | |||
| 134 | return Compare(l, p), http.StatusOK, nil | 136 | return Compare(l, p), http.StatusOK, nil | ||
| 135 | } | 137 | } | ||
| 136 | 138 | ||||
M internal/adapters/web/diff.go +109 -5
| @@ -7,8 +7,10 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "html" | |
| 9 | 9 | "io" | |
| 10 | + | "net/http" | |
| 10 | 11 | "net/url" | |
| 11 | 12 | "slices" | |
| 13 | + | "strconv" | |
| 12 | 14 | "strings" | |
| 13 | 15 | "unicode/utf8" | |
| 14 | 16 | ||
| @@ -123,6 +125,14 @@ type DiffFile struct { | |||
| 123 | 125 | Note string | |
| 124 | 126 | Open bool | |
| 125 | 127 | Hunks []DiffHunk | |
| 128 | + | // Manual is true for a collapsed file (generated, large, over the page | |
| 129 | + | // budget): no hunks, a placeholder with a "load diff" control that reads | |
| 130 | + | // that one file (Src, empty when it cannot be loaded in place; Href is the | |
| 131 | + | // page without scripts). | |
| 132 | + | Manual bool | |
| 133 | + | Href string | |
| 134 | + | // Raw is the address of the raw blob of a file too large to show. | |
| 135 | + | Raw string | |
| 126 | 136 | // Digits is the width of the largest line number of the file, at least 2 | |
| 127 | 137 | // and at most 7: the gutter is as wide as the file needs (class lnw-N). | |
| 128 | 138 | Digits int | |
| @@ -163,7 +173,7 @@ const diffContextLines = 3 | |||
| 163 | 173 | // them: an added or a deleted file shows every line already. | |
| 164 | 174 | func contextGaps(out []DiffFile, files []domain.FileDiff, base string) { | |
| 165 | 175 | for i, f := range files { | |
| 166 | - | if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | |
| 176 | + | if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | |
| 167 | 177 | continue | |
| 168 | 178 | } | |
| 169 | 179 | switch f.Status { | |
| @@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile { | |||
| 370 | 380 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | |
| 371 | 381 | Add: f.Additions, Del: f.Deletions, Bin: f.Binary, | |
| 372 | 382 | // A file that is shown in full opens; a file with nothing to read | |
| 373 | - | // (binary, truncated) stays closed under its note. | |
| 374 | - | Open: len(f.Hunks) > 0 && !f.Truncated, | |
| 383 | + | // (binary, omitted) stays closed under its note. | |
| 384 | + | Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone, | |
| 385 | + | Manual: f.Omitted.Collapsed(), | |
| 375 | 386 | } | |
| 376 | 387 | df.Key = f.NewPath | |
| 377 | 388 | switch f.Status { | |
| @@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string { | |||
| 406 | 417 | switch { | |
| 407 | 418 | case f.Binary: | |
| 408 | 419 | parts = append(parts, "binary file: no text diff") | |
| 409 | - | case f.Truncated: | |
| 410 | - | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions)) | |
| 420 | + | case f.Omitted == domain.OmitGenerated: | |
| 421 | + | parts = append(parts, "generated file: collapsed") | |
| 422 | + | case f.Omitted == domain.OmitLarge: | |
| 423 | + | parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions)) | |
| 424 | + | case f.Omitted == domain.OmitBudget: | |
| 425 | + | parts = append(parts, "collapsed to keep the page light") | |
| 426 | + | case f.Omitted == domain.OmitUnavailable: | |
| 427 | + | parts = append(parts, "too large to show in the browser") | |
| 411 | 428 | } | |
| 412 | 429 | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { | |
| 413 | 430 | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) | |
| @@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component { | |||
| 449 | 466 | return err | |
| 450 | 467 | }) | |
| 451 | 468 | } | |
| 469 | + | ||
| 470 | + | // fileLinks says where the collapsed files of a diff are loaded from. | |
| 471 | + | type fileLinks struct { | |
| 472 | + | // src is the address of the fragment route of the diff ("" when files | |
| 473 | + | // cannot be loaded in place); one file is src plus one=1&from=i. | |
| 474 | + | src string | |
| 475 | + | // from is the window of the page, kept in the links of a page without | |
| 476 | + | // scripts. | |
| 477 | + | from int | |
| 478 | + | // repo and rev name the raw blob of a file that cannot be shown. | |
| 479 | + | repo, rev string | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // oneFileSrc is the address of the fragment holding file i alone. | |
| 483 | + | func oneFileSrc(src string, i int) string { | |
| 484 | + | sep := "?" | |
| 485 | + | if strings.Contains(src, "?") { | |
| 486 | + | sep = "&" | |
| 487 | + | } | |
| 488 | + | return src + sep + "one=1&from=" + itoa(i) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // collapseFiles gives the collapsed files of a diff their load address, and | |
| 492 | + | // the files too large to show a link to their raw blob. Nothing is read. | |
| 493 | + | // | |
| 494 | + | //hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing | |
| 495 | + | func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) { | |
| 496 | + | for i, f := range files { | |
| 497 | + | if i >= len(out) { | |
| 498 | + | break | |
| 499 | + | } | |
| 500 | + | switch { | |
| 501 | + | case f.Omitted.Collapsed(): | |
| 502 | + | if l.src != "" { | |
| 503 | + | out[i].Src = oneFileSrc(l.src, i) | |
| 504 | + | } | |
| 505 | + | q := "?file=" + itoa(i) | |
| 506 | + | if l.from > 0 { | |
| 507 | + | q = "?from=" + itoa(l.from) + "&file=" + itoa(i) | |
| 508 | + | } | |
| 509 | + | out[i].Href = q + "#" + out[i].ID | |
| 510 | + | default: | |
| 511 | + | out[i].Raw = rawLink(f, l) | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | // rawLink is the address of the raw blob of a file too large to show, "" for | |
| 517 | + | // any other file. | |
| 518 | + | func rawLink(f domain.FileDiff, l fileLinks) string { | |
| 519 | + | if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" { | |
| 520 | + | return "" | |
| 521 | + | } | |
| 522 | + | return repoHref(l.repo, "raw", l.rev, f.NewPath) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // fileParam reads ?file= of a page without scripts: the index of a collapsed | |
| 526 | + | // file to read in full; nil when absent or malformed. | |
| 527 | + | func fileParam(r *http.Request) *int { | |
| 528 | + | s := r.URL.Query().Get("file") | |
| 529 | + | i, err := strconv.Atoi(s) | |
| 530 | + | if err != nil || i < 0 || strconv.Itoa(i) != s { | |
| 531 | + | return nil | |
| 532 | + | } | |
| 533 | + | return &i | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // fragmentView lays out a one-file fragment like the page that asks for it: | |
| 537 | + | // ?view= is the layout shown and ?layouts= says whether the page holds both. | |
| 538 | + | func fragmentView(r *http.Request, lines int) DiffView { | |
| 539 | + | v := newDiffView(r, lines, "", "") | |
| 540 | + | v.pageLayouts(r) | |
| 541 | + | return v | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // pageLayouts makes a fragment send the layouts of the page that asks for | |
| 545 | + | // it: ?layouts=both when the page holds both and switches them itself, | |
| 546 | + | // ?layouts=one when it holds only the shown one. | |
| 547 | + | func (v *DiffView) pageLayouts(r *http.Request) { | |
| 548 | + | switch r.URL.Query().Get("layouts") { | |
| 549 | + | case "both": | |
| 550 | + | v.Both = true | |
| 551 | + | case "one": | |
| 552 | + | v.Both = false | |
| 553 | + | } | |
| 554 | + | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |
| 555 | + | } | |
| @@ -7,8 +7,10 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "html" | 8 | "html" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | + | "net/http" | |||
| 10 | "net/url" | 11 | "net/url" | ||
| 11 | "slices" | 12 | "slices" | ||
| 13 | + | "strconv" | |||
| 12 | "strings" | 14 | "strings" | ||
| 13 | "unicode/utf8" | 15 | "unicode/utf8" | ||
| 14 | 16 | ||||
| @@ -123,6 +125,14 @@ type DiffFile struct { | |||||
| 123 | Note string | 125 | Note string | ||
| 124 | Open bool | 126 | Open bool | ||
| 125 | Hunks []DiffHunk | 127 | Hunks []DiffHunk | ||
| 128 | + | // Manual is true for a collapsed file (generated, large, over the page | |||
| 129 | + | // budget): no hunks, a placeholder with a "load diff" control that reads | |||
| 130 | + | // that one file (Src, empty when it cannot be loaded in place; Href is the | |||
| 131 | + | // page without scripts). | |||
| 132 | + | Manual bool | |||
| 133 | + | Href string | |||
| 134 | + | // Raw is the address of the raw blob of a file too large to show. | |||
| 135 | + | Raw string | |||
| 126 | // Digits is the width of the largest line number of the file, at least 2 | 136 | // Digits is the width of the largest line number of the file, at least 2 | ||
| 127 | // and at most 7: the gutter is as wide as the file needs (class lnw-N). | 137 | // and at most 7: the gutter is as wide as the file needs (class lnw-N). | ||
| 128 | Digits int | 138 | Digits int | ||
| @@ -163,7 +173,7 @@ const diffContextLines = 3 | |||||
| 163 | // them: an added or a deleted file shows every line already. | 173 | // them: an added or a deleted file shows every line already. | ||
| 164 | func contextGaps(out []DiffFile, files []domain.FileDiff, base string) { | 174 | func contextGaps(out []DiffFile, files []domain.FileDiff, base string) { | ||
| 165 | for i, f := range files { | 175 | for i, f := range files { | ||
| 166 | - | if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | 176 | + | if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { |
| 167 | continue | 177 | continue | ||
| 168 | } | 178 | } | ||
| 169 | switch f.Status { | 179 | switch f.Status { | ||
| @@ -370,8 +380,9 @@ func diffFiles(files []domain.FileDiff) []DiffFile { | |||||
| 370 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | 380 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | ||
| 371 | Add: f.Additions, Del: f.Deletions, Bin: f.Binary, | 381 | Add: f.Additions, Del: f.Deletions, Bin: f.Binary, | ||
| 372 | // A file that is shown in full opens; a file with nothing to read | 382 | // A file that is shown in full opens; a file with nothing to read | ||
| 373 | - | // (binary, truncated) stays closed under its note. | 383 | + | // (binary, omitted) stays closed under its note. |
| 374 | - | Open: len(f.Hunks) > 0 && !f.Truncated, | 384 | + | Open: len(f.Hunks) > 0 && f.Omitted == domain.OmitNone, |
| 385 | + | Manual: f.Omitted.Collapsed(), | |||
| 375 | } | 386 | } | ||
| 376 | df.Key = f.NewPath | 387 | df.Key = f.NewPath | ||
| 377 | switch f.Status { | 388 | switch f.Status { | ||
| @@ -406,8 +417,14 @@ func fileNote(f domain.FileDiff) string { | |||||
| 406 | switch { | 417 | switch { | ||
| 407 | case f.Binary: | 418 | case f.Binary: | ||
| 408 | parts = append(parts, "binary file: no text diff") | 419 | parts = append(parts, "binary file: no text diff") | ||
| 409 | - | case f.Truncated: | 420 | + | case f.Omitted == domain.OmitGenerated: |
| 410 | - | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions)) | 421 | + | parts = append(parts, "generated file: collapsed") |
| 422 | + | case f.Omitted == domain.OmitLarge: | |||
| 423 | + | parts = append(parts, fmt.Sprintf("large diff (%d lines changed): collapsed", f.Additions+f.Deletions)) | |||
| 424 | + | case f.Omitted == domain.OmitBudget: | |||
| 425 | + | parts = append(parts, "collapsed to keep the page light") | |||
| 426 | + | case f.Omitted == domain.OmitUnavailable: | |||
| 427 | + | parts = append(parts, "too large to show in the browser") | |||
| 411 | } | 428 | } | ||
| 412 | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { | 429 | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { | ||
| 413 | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) | 430 | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) | ||
| @@ -449,3 +466,90 @@ func codeLine(ln DiffLine) templ.Component { | |||||
| 449 | return err | 466 | return err | ||
| 450 | }) | 467 | }) | ||
| 451 | } | 468 | } | ||
| 469 | + | ||||
| 470 | + | // fileLinks says where the collapsed files of a diff are loaded from. | |||
| 471 | + | type fileLinks struct { | |||
| 472 | + | // src is the address of the fragment route of the diff ("" when files | |||
| 473 | + | // cannot be loaded in place); one file is src plus one=1&from=i. | |||
| 474 | + | src string | |||
| 475 | + | // from is the window of the page, kept in the links of a page without | |||
| 476 | + | // scripts. | |||
| 477 | + | from int | |||
| 478 | + | // repo and rev name the raw blob of a file that cannot be shown. | |||
| 479 | + | repo, rev string | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // oneFileSrc is the address of the fragment holding file i alone. | |||
| 483 | + | func oneFileSrc(src string, i int) string { | |||
| 484 | + | sep := "?" | |||
| 485 | + | if strings.Contains(src, "?") { | |||
| 486 | + | sep = "&" | |||
| 487 | + | } | |||
| 488 | + | return src + sep + "one=1&from=" + itoa(i) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // collapseFiles gives the collapsed files of a diff their load address, and | |||
| 492 | + | // the files too large to show a link to their raw blob. Nothing is read. | |||
| 493 | + | // | |||
| 494 | + | //hexcheck:ignore suspicious-business-logic-in-adapter only maps each file's domain omission (Omitted.Collapsed) to a load link, a ?file= anchor or a raw link; it decides nothing and reads nothing | |||
| 495 | + | func collapseFiles(out []DiffFile, files []domain.FileDiff, l fileLinks) { | |||
| 496 | + | for i, f := range files { | |||
| 497 | + | if i >= len(out) { | |||
| 498 | + | break | |||
| 499 | + | } | |||
| 500 | + | switch { | |||
| 501 | + | case f.Omitted.Collapsed(): | |||
| 502 | + | if l.src != "" { | |||
| 503 | + | out[i].Src = oneFileSrc(l.src, i) | |||
| 504 | + | } | |||
| 505 | + | q := "?file=" + itoa(i) | |||
| 506 | + | if l.from > 0 { | |||
| 507 | + | q = "?from=" + itoa(l.from) + "&file=" + itoa(i) | |||
| 508 | + | } | |||
| 509 | + | out[i].Href = q + "#" + out[i].ID | |||
| 510 | + | default: | |||
| 511 | + | out[i].Raw = rawLink(f, l) | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | // rawLink is the address of the raw blob of a file too large to show, "" for | |||
| 517 | + | // any other file. | |||
| 518 | + | func rawLink(f domain.FileDiff, l fileLinks) string { | |||
| 519 | + | if f.Omitted != domain.OmitUnavailable || f.Status == domain.FileDeleted || l.rev == "" { | |||
| 520 | + | return "" | |||
| 521 | + | } | |||
| 522 | + | return repoHref(l.repo, "raw", l.rev, f.NewPath) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // fileParam reads ?file= of a page without scripts: the index of a collapsed | |||
| 526 | + | // file to read in full; nil when absent or malformed. | |||
| 527 | + | func fileParam(r *http.Request) *int { | |||
| 528 | + | s := r.URL.Query().Get("file") | |||
| 529 | + | i, err := strconv.Atoi(s) | |||
| 530 | + | if err != nil || i < 0 || strconv.Itoa(i) != s { | |||
| 531 | + | return nil | |||
| 532 | + | } | |||
| 533 | + | return &i | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // fragmentView lays out a one-file fragment like the page that asks for it: | |||
| 537 | + | // ?view= is the layout shown and ?layouts= says whether the page holds both. | |||
| 538 | + | func fragmentView(r *http.Request, lines int) DiffView { | |||
| 539 | + | v := newDiffView(r, lines, "", "") | |||
| 540 | + | v.pageLayouts(r) | |||
| 541 | + | return v | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | // pageLayouts makes a fragment send the layouts of the page that asks for | |||
| 545 | + | // it: ?layouts=both when the page holds both and switches them itself, | |||
| 546 | + | // ?layouts=one when it holds only the shown one. | |||
| 547 | + | func (v *DiffView) pageLayouts(r *http.Request) { | |||
| 548 | + | switch r.URL.Query().Get("layouts") { | |||
| 549 | + | case "both": | |||
| 550 | + | v.Both = true | |||
| 551 | + | case "one": | |||
| 552 | + | v.Both = false | |||
| 553 | + | } | |||
| 554 | + | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |||
| 555 | + | } | |||
M internal/adapters/web/diff.templ +10 -1
| @@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||
| 237 | 237 | } | |
| 238 | 238 | </summary> | |
| 239 | 239 | if f.Lazy { | |
| 240 | - | // A placeholder: mr.js fetches the window of the file when it comes | |
| 240 | + | // A placeholder: lazy.js fetches the window of the file when it comes | |
| 241 | 241 | // near the screen and swaps the files of the window in. Without | |
| 242 | 242 | // scripts the link opens the page of that window. | |
| 243 | 243 | <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }> | |
| 244 | 244 | <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a> | |
| 245 | 245 | </div> | |
| 246 | + | } else if f.Manual { | |
| 247 | + | // A collapsed file (generated, large, over the page budget): no | |
| 248 | + | // rows. "load diff" reads this one file and swaps it in place | |
| 249 | + | // (lazy.js); without scripts the link reloads the page with the file. | |
| 250 | + | <div class="dlazy" data-src={ f.Src } data-manual> | |
| 251 | + | <a href={ templ.SafeURL(f.Href) }>load diff</a> | |
| 252 | + | </div> | |
| 246 | 253 | } else if len(f.Hunks) > 0 { | |
| 247 | 254 | <div class="dscroll"> | |
| 248 | 255 | if p.ShowUnified { | |
| @@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||
| 306 | 313 | </table> | |
| 307 | 314 | } | |
| 308 | 315 | </div> | |
| 316 | + | } else if f.Raw != "" { | |
| 317 | + | <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p> | |
| 309 | 318 | } else { | |
| 310 | 319 | <p class="note pad"> | |
| 311 | 320 | if f.Note != "" { | |
| @@ -237,12 +237,19 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||||
| 237 | } | 237 | } | ||
| 238 | </summary> | 238 | </summary> | ||
| 239 | if f.Lazy { | 239 | if f.Lazy { | ||
| 240 | - | // A placeholder: mr.js fetches the window of the file when it comes | 240 | + | // A placeholder: lazy.js fetches the window of the file when it comes |
| 241 | // near the screen and swaps the files of the window in. Without | 241 | // near the screen and swaps the files of the window in. Without | ||
| 242 | // scripts the link opens the page of that window. | 242 | // scripts the link opens the page of that window. | ||
| 243 | <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }> | 243 | <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }> | ||
| 244 | <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a> | 244 | <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a> | ||
| 245 | </div> | 245 | </div> | ||
| 246 | + | } else if f.Manual { | |||
| 247 | + | // A collapsed file (generated, large, over the page budget): no | |||
| 248 | + | // rows. "load diff" reads this one file and swaps it in place | |||
| 249 | + | // (lazy.js); without scripts the link reloads the page with the file. | |||
| 250 | + | <div class="dlazy" data-src={ f.Src } data-manual> | |||
| 251 | + | <a href={ templ.SafeURL(f.Href) }>load diff</a> | |||
| 252 | + | </div> | |||
| 246 | } else if len(f.Hunks) > 0 { | 253 | } else if len(f.Hunks) > 0 { | ||
| 247 | <div class="dscroll"> | 254 | <div class="dscroll"> | ||
| 248 | if p.ShowUnified { | 255 | if p.ShowUnified { | ||
| @@ -306,6 +313,8 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||||
| 306 | </table> | 313 | </table> | ||
| 307 | } | 314 | } | ||
| 308 | </div> | 315 | </div> | ||
| 316 | + | } else if f.Raw != "" { | |||
| 317 | + | <p class="note pad">{ f.Note } <a href={ templ.SafeURL(f.Raw) }>view raw</a></p> | |||
| 309 | } else { | 318 | } else { | ||
| 310 | <p class="note pad"> | 319 | <p class="note pad"> | ||
| 311 | if f.Note != "" { | 320 | if f.Note != "" { | ||
M internal/adapters/web/diff_highlight.go +2 -2
| @@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10 | |||
| 48 | 48 | ||
| 49 | 49 | const ( | |
| 50 | 50 | // maxDiffHLFileBytes is the most text of one file of a diff that is | |
| 51 | - | // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a | |
| 51 | + | // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a | |
| 52 | 52 | // file; this keeps a file of very long lines out. | |
| 53 | 53 | maxDiffHLFileBytes = 128 << 10 | |
| 54 | 54 | // maxDiffHLLine is the longest line that is lexed, in bytes: a minified | |
| @@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile | |||
| 446 | 446 | ||
| 447 | 447 | left := maxDiffHLTotalBytes | |
| 448 | 448 | for i, f := range files { | |
| 449 | - | if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | |
| 449 | + | if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | |
| 450 | 450 | continue | |
| 451 | 451 | } | |
| 452 | 452 | if ctx.Err() != nil { | |
| @@ -48,7 +48,7 @@ var maxDiffHLTotalBytes = 256 << 10 | |||||
| 48 | 48 | ||||
| 49 | const ( | 49 | const ( | ||
| 50 | // maxDiffHLFileBytes is the most text of one file of a diff that is | 50 | // maxDiffHLFileBytes is the most text of one file of a diff that is | ||
| 51 | - | // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 lines a | 51 | + | // lexed. The diff limits (domain.DefaultDiffLimits) allow 2,000 changed lines a |
| 52 | // file; this keeps a file of very long lines out. | 52 | // file; this keeps a file of very long lines out. | ||
| 53 | maxDiffHLFileBytes = 128 << 10 | 53 | maxDiffHLFileBytes = 128 << 10 | ||
| 54 | // maxDiffHLLine is the longest line that is lexed, in bytes: a minified | 54 | // maxDiffHLLine is the longest line that is lexed, in bytes: a minified | ||
| @@ -446,7 +446,7 @@ func (c *hlCache) files(ctx context.Context, files []domain.FileDiff) []DiffFile | |||||
| 446 | 446 | ||||
| 447 | left := maxDiffHLTotalBytes | 447 | left := maxDiffHLTotalBytes | ||
| 448 | for i, f := range files { | 448 | for i, f := range files { | ||
| 449 | - | if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { | 449 | + | if i >= len(out) || f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) { |
| 450 | continue | 450 | continue | ||
| 451 | } | 451 | } | ||
| 452 | if ctx.Err() != nil { | 452 | if ctx.Err() != nil { | ||
M internal/adapters/web/diff_highlight_test.go +2 -2
| @@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) { | |||
| 162 | 162 | binary := hunkOf("a.go", "+package a") | |
| 163 | 163 | binary.Binary = true | |
| 164 | 164 | truncated := hunkOf("a.go", "+package a") | |
| 165 | - | truncated.Truncated = true | |
| 165 | + | truncated.Omitted = domain.OmitLarge | |
| 166 | 166 | for name, fd := range map[string]domain.FileDiff{ | |
| 167 | 167 | "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"), | |
| 168 | 168 | "plain text": hunkOf("notes.txt", "+package a"), | |
| @@ -273,7 +273,7 @@ func goSource(n int) []string { | |||
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | 275 | // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place | |
| 276 | - | // and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a | |
| 276 | + | // and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a | |
| 277 | 277 | // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB. | |
| 278 | 278 | func largeDiff(n int) []domain.FileDiff { | |
| 279 | 279 | var files []domain.FileDiff | |
| @@ -162,7 +162,7 @@ func TestHighlightSkips(t *testing.T) { | |||||
| 162 | binary := hunkOf("a.go", "+package a") | 162 | binary := hunkOf("a.go", "+package a") | ||
| 163 | binary.Binary = true | 163 | binary.Binary = true | ||
| 164 | truncated := hunkOf("a.go", "+package a") | 164 | truncated := hunkOf("a.go", "+package a") | ||
| 165 | - | truncated.Truncated = true | 165 | + | truncated.Omitted = domain.OmitLarge |
| 166 | for name, fd := range map[string]domain.FileDiff{ | 166 | for name, fd := range map[string]domain.FileDiff{ | ||
| 167 | "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"), | 167 | "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"), | ||
| 168 | "plain text": hunkOf("notes.txt", "+package a"), | 168 | "plain text": hunkOf("notes.txt", "+package a"), | ||
| @@ -273,7 +273,7 @@ func goSource(n int) []string { | |||||
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place | 275 | // largeDiff is n files of 1,000 lines of Go with 250 lines changed in place | ||
| 276 | - | // and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a | 276 | + | // and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 changed lines a |
| 277 | // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB. | 277 | // file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB. | ||
| 278 | func largeDiff(n int) []domain.FileDiff { | 278 | func largeDiff(n int) []domain.FileDiff { | ||
| 279 | var files []domain.FileDiff | 279 | var files []domain.FileDiff | ||
M internal/adapters/web/diff_templ.go +235 -173 generated file: collapsed
M internal/adapters/web/diff_test.go +51 -2
| @@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) { | |||
| 99 | 99 | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | |
| 100 | 100 | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | |
| 101 | 101 | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | |
| 102 | - | {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false}, | |
| 102 | + | {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true}, | |
| 103 | + | {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true}, | |
| 104 | + | {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true}, | |
| 105 | + | {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true}, | |
| 103 | 106 | } | |
| 104 | 107 | for _, tt := range tests { | |
| 105 | 108 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) { | |||
| 120 | 123 | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, | |
| 121 | 124 | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, | |
| 122 | 125 | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, | |
| 123 | - | {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000}, | |
| 126 | + | {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000}, | |
| 124 | 127 | }) | |
| 125 | 128 | assert.Equal(t, "a.go", fs[0].Path) | |
| 126 | 129 | assert.True(t, fs[0].Open) | |
| @@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) { | |||
| 191 | 194 | contextGaps(o, odd, "/r/context/c") | |
| 192 | 195 | assert.Nil(t, o[0].Hunks[0].Gap) | |
| 193 | 196 | } | |
| 197 | + | ||
| 198 | + | func TestDiffNote(t *testing.T) { | |
| 199 | + | t.Parallel() | |
| 200 | + | files := func(w ...domain.FileOmission) domain.Diff { | |
| 201 | + | var d domain.Diff | |
| 202 | + | for _, o := range w { | |
| 203 | + | d.Files = append(d.Files, domain.FileDiff{Omitted: o}) | |
| 204 | + | } | |
| 205 | + | return d | |
| 206 | + | } | |
| 207 | + | assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note") | |
| 208 | + | assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.", | |
| 209 | + | diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable))) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestCollapsedFileMarkup(t *testing.T) { | |
| 213 | + | t.Parallel() | |
| 214 | + | view := DiffView{ShowUnified: true} | |
| 215 | + | src := "/demo/commit/abc1234/files" | |
| 216 | + | for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} { | |
| 217 | + | raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}} | |
| 218 | + | fs := diffFiles(raw) | |
| 219 | + | collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"}) | |
| 220 | + | out := renderToString(t, DiffFileView(fs[0], view)) | |
| 221 | + | assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why) | |
| 222 | + | assert.Regexp(t, `<details `, out, "the file is a details element") | |
| 223 | + | assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&from=0" data-manual>`) | |
| 224 | + | assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`) | |
| 225 | + | assert.NotContains(t, out, "<table") | |
| 226 | + | } | |
| 227 | + | raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}} | |
| 228 | + | fs := diffFiles(raw) | |
| 229 | + | collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"}) | |
| 230 | + | out := renderToString(t, DiffFileView(fs[0], view)) | |
| 231 | + | assert.Contains(t, out, "too large to show in the browser") | |
| 232 | + | assert.NotContains(t, out, "load diff") | |
| 233 | + | assert.NotContains(t, out, "data-manual") | |
| 234 | + | assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`) | |
| 235 | + | ||
| 236 | + | // Past the first window of a merge request the link keeps the window. | |
| 237 | + | raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}} | |
| 238 | + | fs = diffFiles(raw) | |
| 239 | + | collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25}) | |
| 240 | + | assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src) | |
| 241 | + | assert.Equal(t, "?from=25&file=0#f1", fs[0].Href) | |
| 242 | + | } | |
| @@ -99,7 +99,10 @@ func TestFileNotes(t *testing.T) { | |||||
| 99 | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | 99 | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | ||
| 100 | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | 100 | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | ||
| 101 | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | 101 | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | ||
| 102 | - | {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false}, | 102 | + | {"generated", domain.FileDiff{Omitted: domain.OmitGenerated, Additions: 5}, "generated file: collapsed", true}, |
| 103 | + | {"large", domain.FileDiff{Omitted: domain.OmitLarge, Additions: 2000, Deletions: 500}, "large diff (2500 lines changed): collapsed", true}, | |||
| 104 | + | {"budget", domain.FileDiff{Omitted: domain.OmitBudget, Additions: 40}, "collapsed to keep the page light", true}, | |||
| 105 | + | {"unavailable", domain.FileDiff{Omitted: domain.OmitUnavailable, Additions: 40}, "too large to show in the browser", true}, | |||
| 103 | } | 106 | } | ||
| 104 | for _, tt := range tests { | 107 | for _, tt := range tests { | ||
| 105 | t.Run(tt.name, func(t *testing.T) { | 108 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -120,7 +123,7 @@ func TestDiffFilesPaths(t *testing.T) { | |||||
| 120 | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, | 123 | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, | ||
| 121 | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, | 124 | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, | ||
| 122 | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, | 125 | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, | ||
| 123 | - | {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000}, | 126 | + | {Status: domain.FileModified, NewPath: "big.go", Omitted: domain.OmitLarge, Additions: 3000}, |
| 124 | }) | 127 | }) | ||
| 125 | assert.Equal(t, "a.go", fs[0].Path) | 128 | assert.Equal(t, "a.go", fs[0].Path) | ||
| 126 | assert.True(t, fs[0].Open) | 129 | assert.True(t, fs[0].Open) | ||
| @@ -191,3 +194,49 @@ func TestContextGaps(t *testing.T) { | |||||
| 191 | contextGaps(o, odd, "/r/context/c") | 194 | contextGaps(o, odd, "/r/context/c") | ||
| 192 | assert.Nil(t, o[0].Hunks[0].Gap) | 195 | assert.Nil(t, o[0].Hunks[0].Gap) | ||
| 193 | } | 196 | } | ||
| 197 | + | ||||
| 198 | + | func TestDiffNote(t *testing.T) { | |||
| 199 | + | t.Parallel() | |||
| 200 | + | files := func(w ...domain.FileOmission) domain.Diff { | |||
| 201 | + | var d domain.Diff | |||
| 202 | + | for _, o := range w { | |||
| 203 | + | d.Files = append(d.Files, domain.FileDiff{Omitted: o}) | |||
| 204 | + | } | |||
| 205 | + | return d | |||
| 206 | + | } | |||
| 207 | + | assert.Empty(t, diffNote(files(domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget)), "collapsed files have no global note") | |||
| 208 | + | assert.Equal(t, "this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.", | |||
| 209 | + | diffNote(files(domain.OmitUnavailable, domain.OmitLarge, domain.OmitUnavailable))) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestCollapsedFileMarkup(t *testing.T) { | |||
| 213 | + | t.Parallel() | |||
| 214 | + | view := DiffView{ShowUnified: true} | |||
| 215 | + | src := "/demo/commit/abc1234/files" | |||
| 216 | + | for _, why := range []domain.FileOmission{domain.OmitGenerated, domain.OmitLarge, domain.OmitBudget} { | |||
| 217 | + | raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "go.sum", Additions: 3, Omitted: why}} | |||
| 218 | + | fs := diffFiles(raw) | |||
| 219 | + | collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"}) | |||
| 220 | + | out := renderToString(t, DiffFileView(fs[0], view)) | |||
| 221 | + | assert.NotRegexp(t, `<details[^>]*\sopen[\s>]`, out, "omission %d", why) | |||
| 222 | + | assert.Regexp(t, `<details `, out, "the file is a details element") | |||
| 223 | + | assert.Contains(t, out, `<div class="dlazy" data-src="/demo/commit/abc1234/files?one=1&from=0" data-manual>`) | |||
| 224 | + | assert.Contains(t, out, `<a href="?file=0#f1">load diff</a>`) | |||
| 225 | + | assert.NotContains(t, out, "<table") | |||
| 226 | + | } | |||
| 227 | + | raw := []domain.FileDiff{{Status: domain.FileModified, NewPath: "huge.txt", Additions: 3, Omitted: domain.OmitUnavailable}} | |||
| 228 | + | fs := diffFiles(raw) | |||
| 229 | + | collapseFiles(fs, raw, fileLinks{src: src, repo: "demo", rev: "abc1234"}) | |||
| 230 | + | out := renderToString(t, DiffFileView(fs[0], view)) | |||
| 231 | + | assert.Contains(t, out, "too large to show in the browser") | |||
| 232 | + | assert.NotContains(t, out, "load diff") | |||
| 233 | + | assert.NotContains(t, out, "data-manual") | |||
| 234 | + | assert.Contains(t, out, `<a href="/demo/raw/abc1234/huge.txt">view raw</a>`) | |||
| 235 | + | ||||
| 236 | + | // Past the first window of a merge request the link keeps the window. | |||
| 237 | + | raw = []domain.FileDiff{{NewPath: "a", Additions: 1, Omitted: domain.OmitLarge}} | |||
| 238 | + | fs = diffFiles(raw) | |||
| 239 | + | collapseFiles(fs, raw, fileLinks{src: "/d/merge-requests/1/files?base=b&head=h", from: 25}) | |||
| 240 | + | assert.Equal(t, "/d/merge-requests/1/files?base=b&head=h&one=1&from=0", fs[0].Src) | |||
| 241 | + | assert.Equal(t, "?from=25&file=0#f1", fs[0].Href) | |||
| 242 | + | } | |||
A internal/adapters/web/jstest/lazy.test.mjs +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | // Tests of the loader of the files of a diff (static/js/lazy.js). Run with | |
| 2 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 3 | + | import assert from 'node:assert/strict'; | |
| 4 | + | import { test } from 'node:test'; | |
| 5 | + | import { readFileSync } from 'node:fs'; | |
| 6 | + | ||
| 7 | + | const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8'); | |
| 8 | + | ||
| 9 | + | globalThis.window = globalThis; | |
| 10 | + | const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver }; | |
| 11 | + | ||
| 12 | + | // A placeholder: an element with a dataset; its link finds it back. | |
| 13 | + | function placeholder(src, manual) { | |
| 14 | + | const details = { id: 'f1', querySelector: () => null }; | |
| 15 | + | const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) }; | |
| 16 | + | const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) }; | |
| 17 | + | return { ph, link, details }; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | async function setup(phs, io = true, opts = {}) { | |
| 21 | + | const observed = []; | |
| 22 | + | const urls = []; | |
| 23 | + | if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} }; | |
| 24 | + | else delete globalThis.IntersectionObserver; | |
| 25 | + | const reply = opts.reply || { status: 200, ok: true, body: '' }; | |
| 26 | + | globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; }; | |
| 27 | + | globalThis.document = { | |
| 28 | + | readyState: 'complete', | |
| 29 | + | querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null), | |
| 30 | + | querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs), | |
| 31 | + | getElementById: opts.getElementById || (() => null), | |
| 32 | + | createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }), | |
| 33 | + | addEventListener() {}, | |
| 34 | + | }; | |
| 35 | + | delete globalThis.ermiteLazy; | |
| 36 | + | (0, eval)(SRC); | |
| 37 | + | return { observed, urls }; | |
| 38 | + | } | |
| 39 | + | const settle = () => new Promise((r) => setTimeout(r, 0)); | |
| 40 | + | const restore = () => { | |
| 41 | + | delete globalThis.ermiteLazy; | |
| 42 | + | for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) { | |
| 43 | + | if (v === undefined) delete globalThis[k]; else globalThis[k] = v; | |
| 44 | + | } | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | test('manual placeholders are not observed', async () => { | |
| 48 | + | const a = placeholder('/r/commit/abc/files?one=1&from=0', true); | |
| 49 | + | const b = placeholder('/r/merge-requests/1/files?from=25', false); | |
| 50 | + | try { | |
| 51 | + | const { observed, urls } = await setup([a.ph, b.ph]); | |
| 52 | + | assert.deepEqual(observed, [b.ph]); | |
| 53 | + | assert.deepEqual(urls, []); | |
| 54 | + | } finally { await settle(); restore(); } | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | test('without IntersectionObserver only the automatic placeholders load', async () => { | |
| 58 | + | const a = placeholder('/m?one=1&from=0', true); | |
| 59 | + | const b = placeholder('/w?from=25', false); | |
| 60 | + | try { | |
| 61 | + | const { urls } = await setup([a.ph, b.ph], false); | |
| 62 | + | assert.deepEqual(urls, ['/w?from=25']); | |
| 63 | + | } finally { await settle(); restore(); } | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test('a click on "load diff" loads that one file and does not navigate', async () => { | |
| 67 | + | const a = placeholder('/r/commit/abc/files?one=1&from=3', true); | |
| 68 | + | try { | |
| 69 | + | const { urls } = await setup([a.ph]); | |
| 70 | + | let prevented = false; | |
| 71 | + | const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } }); | |
| 72 | + | assert.equal(used, true); | |
| 73 | + | assert.equal(prevented, true); | |
| 74 | + | assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']); | |
| 75 | + | } finally { await settle(); restore(); } | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test('Enter and Space on the link load it; other keys and modified clicks do not', async () => { | |
| 79 | + | const a = placeholder('/f?one=1&from=1', true); | |
| 80 | + | try { | |
| 81 | + | const { urls } = await setup([a.ph]); | |
| 82 | + | const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra }); | |
| 83 | + | assert.equal(key('a'), false); | |
| 84 | + | assert.equal(key('Enter', { ctrlKey: true }), false); | |
| 85 | + | assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false); | |
| 86 | + | assert.equal(urls.length, 0); | |
| 87 | + | assert.equal(key('Enter'), true); | |
| 88 | + | assert.equal(key(' '), true); | |
| 89 | + | assert.equal(urls.length, 1, 'a file is fetched once'); | |
| 90 | + | } finally { await settle(); restore(); } | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test('a manual placeholder without an address is a plain link', async () => { | |
| 94 | + | const a = placeholder('', true); | |
| 95 | + | a.ph.dataset.src = ''; | |
| 96 | + | try { | |
| 97 | + | await setup([a.ph]); | |
| 98 | + | assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false); | |
| 99 | + | } finally { await settle(); restore(); } | |
| 100 | + | }); | |
| 101 | + | ||
| 102 | + | test('onSwap runs after a window came in', async () => { | |
| 103 | + | const b = placeholder('/w?from=25', false); | |
| 104 | + | try { | |
| 105 | + | await setup([b.ph]); | |
| 106 | + | let n = 0; | |
| 107 | + | window.ermiteLazy.onSwap(() => n++); | |
| 108 | + | await window.ermiteLazy.load(b.ph); | |
| 109 | + | assert.equal(n, 1); | |
| 110 | + | } finally { await settle(); restore(); } | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test('reset drops the response of a load in flight and lets the same window load again', async () => { | |
| 114 | + | const b = placeholder('/w?from=25', false); | |
| 115 | + | try { | |
| 116 | + | await setup([b.ph]); | |
| 117 | + | let release; | |
| 118 | + | const gate = new Promise((r) => { release = r; }); | |
| 119 | + | let fetches = 0; | |
| 120 | + | globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; }; | |
| 121 | + | let n = 0; | |
| 122 | + | window.ermiteLazy.onSwap(() => n++); | |
| 123 | + | const first = window.ermiteLazy.load(b.ph); | |
| 124 | + | window.ermiteLazy.reset(); | |
| 125 | + | release(); | |
| 126 | + | await first; | |
| 127 | + | assert.equal(n, 0, 'the old generation does not swap'); | |
| 128 | + | await window.ermiteLazy.load(b.ph); | |
| 129 | + | assert.equal(fetches, 2); | |
| 130 | + | assert.equal(n, 1); | |
| 131 | + | } finally { await settle(); restore(); } | |
| 132 | + | }); | |
| 133 | + | ||
| 134 | + | test('reset clears the stale state; watch disconnects the previous observer', async () => { | |
| 135 | + | const b = placeholder('/w?from=25', false); | |
| 136 | + | try { | |
| 137 | + | await setup([b.ph]); | |
| 138 | + | const made = []; | |
| 139 | + | globalThis.IntersectionObserver = class { | |
| 140 | + | constructor() { this.off = false; made.push(this); } | |
| 141 | + | observe() {} unobserve() {} disconnect() { this.off = true; } | |
| 142 | + | }; | |
| 143 | + | globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' }); | |
| 144 | + | await window.ermiteLazy.load(b.ph); | |
| 145 | + | const urls = []; | |
| 146 | + | globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; }; | |
| 147 | + | await window.ermiteLazy.load(b.ph); | |
| 148 | + | assert.deepEqual(urls, [], 'stale: nothing loads'); | |
| 149 | + | window.ermiteLazy.watch(); | |
| 150 | + | window.ermiteLazy.watch(); | |
| 151 | + | assert.equal(made.length, 2); | |
| 152 | + | assert.ok(made[0].off && !made[1].off); | |
| 153 | + | window.ermiteLazy.reset(); | |
| 154 | + | assert.ok(made[1].off); | |
| 155 | + | await window.ermiteLazy.load(b.ph); | |
| 156 | + | assert.equal(urls.length, 1); | |
| 157 | + | } finally { await settle(); restore(); } | |
| 158 | + | }); | |
| 159 | + | ||
| 160 | + | ||
| 161 | + | // A file of the page (old) or of a fragment (new), with the queries swap makes. | |
| 162 | + | function fileEl(id, { lazy = false, manual = false, open = false } = {}) { | |
| 163 | + | const f = { id, open, queried: [] }; | |
| 164 | + | f.querySelector = (sel) => { | |
| 165 | + | if (sel === '.dlazy[data-manual]') return manual ? {} : null; | |
| 166 | + | if (sel === '.dlazy') return lazy || manual ? {} : null; | |
| 167 | + | if (sel === '.ln a') return f.line || null; | |
| 168 | + | return null; | |
| 169 | + | }; | |
| 170 | + | f.querySelectorAll = () => []; | |
| 171 | + | return f; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | test('a loaded manual file opens and takes the focus without scrolling', async () => { | |
| 175 | + | const a = placeholder('/f?one=1&from=0', true); | |
| 176 | + | const old = fileEl('f1', { manual: true }); | |
| 177 | + | const fresh = fileEl('f1'); | |
| 178 | + | const focused = []; | |
| 179 | + | fresh.line = { focus: (o) => focused.push(o) }; | |
| 180 | + | let current = old; | |
| 181 | + | old.replaceWith = (n) => { current = n; }; | |
| 182 | + | try { | |
| 183 | + | await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current }); | |
| 184 | + | window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} }); | |
| 185 | + | await settle(); await settle(); | |
| 186 | + | assert.equal(current, fresh); | |
| 187 | + | assert.equal(fresh.open, true); | |
| 188 | + | assert.deepEqual(focused, [{ preventScroll: true }]); | |
| 189 | + | } finally { await settle(); restore(); } | |
| 190 | + | }); | |
| 191 | + | ||
| 192 | + | test('a window that still holds a collapsed file keeps it closed', async () => { | |
| 193 | + | const w = placeholder('/w?from=25', false); | |
| 194 | + | const old = fileEl('f26', { lazy: true, open: true }); | |
| 195 | + | const fresh = fileEl('f26', { manual: true, open: true }); | |
| 196 | + | let replaced = null; | |
| 197 | + | old.replaceWith = (n) => { replaced = n; }; | |
| 198 | + | try { | |
| 199 | + | await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old }); | |
| 200 | + | await window.ermiteLazy.load(w.ph); | |
| 201 | + | assert.equal(replaced, fresh); | |
| 202 | + | assert.equal(fresh.open, false); | |
| 203 | + | } finally { await settle(); restore(); } | |
| 204 | + | }); | |
| 205 | + | ||
| 206 | + | test('a 409 makes the page stale: no later load fetches', async () => { | |
| 207 | + | const a = placeholder('/f?one=1&from=0', true); | |
| 208 | + | const b = placeholder('/f?one=1&from=1', true); | |
| 209 | + | try { | |
| 210 | + | const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } }); | |
| 211 | + | await window.ermiteLazy.load(a.ph); | |
| 212 | + | assert.equal(a.ph.innerHTML, '<p>moved</p>'); | |
| 213 | + | assert.equal(urls.length, 1); | |
| 214 | + | await window.ermiteLazy.load(b.ph); | |
| 215 | + | assert.equal(urls.length, 1, 'stale: nothing is fetched'); | |
| 216 | + | } finally { await settle(); restore(); } | |
| 217 | + | }); | |
| 218 | + | ||
| 219 | + | test('a failed load clears the loading mark and can be retried', async () => { | |
| 220 | + | const a = placeholder('/f?one=1&from=0', true); | |
| 221 | + | const marks = new Set(); | |
| 222 | + | a.ph.setAttribute = (n) => marks.add(n); | |
| 223 | + | a.ph.removeAttribute = (n) => marks.delete(n); | |
| 224 | + | try { | |
| 225 | + | const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } }); | |
| 226 | + | const p = window.ermiteLazy.load(a.ph); | |
| 227 | + | assert.ok(marks.has('data-loading')); | |
| 228 | + | await p; | |
| 229 | + | assert.equal(marks.has('data-loading'), false); | |
| 230 | + | await window.ermiteLazy.load(a.ph); | |
| 231 | + | assert.equal(urls.length, 2, 'a retry fetches again'); | |
| 232 | + | } finally { await settle(); restore(); } | |
| 233 | + | }); | |
| 234 | + | ||
| 235 | + | test('Space is prevented on the link; a held key is ignored', async () => { | |
| 236 | + | const a = placeholder('/f?one=1&from=1', true); | |
| 237 | + | try { | |
| 238 | + | const { urls } = await setup([a.ph]); | |
| 239 | + | let prevented = 0; | |
| 240 | + | const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra }); | |
| 241 | + | assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false); | |
| 242 | + | assert.equal(prevented, 0); | |
| 243 | + | assert.equal(urls.length, 0); | |
| 244 | + | assert.equal(window.ermiteLazy.onKey(ev({})), true); | |
| 245 | + | assert.equal(prevented, 1, 'Space does not scroll the page'); | |
| 246 | + | assert.equal(urls.length, 1); | |
| 247 | + | } finally { await settle(); restore(); } | |
| 248 | + | }); | |
| @@ -0,0 +1,248 @@ | |||||
| 1 | + | // Tests of the loader of the files of a diff (static/js/lazy.js). Run with | |||
| 2 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 3 | + | import assert from 'node:assert/strict'; | |||
| 4 | + | import { test } from 'node:test'; | |||
| 5 | + | import { readFileSync } from 'node:fs'; | |||
| 6 | + | ||||
| 7 | + | const SRC = readFileSync(new URL('../static/js/lazy.js', import.meta.url), 'utf8'); | |||
| 8 | + | ||||
| 9 | + | globalThis.window = globalThis; | |||
| 10 | + | const saved = { document: globalThis.document, fetch: globalThis.fetch, IO: globalThis.IntersectionObserver }; | |||
| 11 | + | ||||
| 12 | + | // A placeholder: an element with a dataset; its link finds it back. | |||
| 13 | + | function placeholder(src, manual) { | |||
| 14 | + | const details = { id: 'f1', querySelector: () => null }; | |||
| 15 | + | const ph = { dataset: manual ? { src, manual: '' } : { src }, setAttribute() {}, removeAttribute() {}, closest: (s) => (s === 'details' ? details : null) }; | |||
| 16 | + | const link = { closest: (s) => (s === '.dlazy' ? ph : s === '.dlazy[data-manual] > a' && manual ? link : null) }; | |||
| 17 | + | return { ph, link, details }; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | async function setup(phs, io = true, opts = {}) { | |||
| 21 | + | const observed = []; | |||
| 22 | + | const urls = []; | |||
| 23 | + | if (io) globalThis.IntersectionObserver = class { observe(p) { observed.push(p); } unobserve() {} disconnect() {} }; | |||
| 24 | + | else delete globalThis.IntersectionObserver; | |||
| 25 | + | const reply = opts.reply || { status: 200, ok: true, body: '' }; | |||
| 26 | + | globalThis.fetch = async (u) => { urls.push(u); return { status: reply.status, ok: reply.ok, text: async () => reply.body }; }; | |||
| 27 | + | globalThis.document = { | |||
| 28 | + | readyState: 'complete', | |||
| 29 | + | querySelector: (s) => (s === '#diffs' ? { dataset: {} } : null), | |||
| 30 | + | querySelectorAll: (s) => (s.includes(':not([data-manual])') ? phs.filter((p) => !('manual' in p.dataset)) : phs), | |||
| 31 | + | getElementById: opts.getElementById || (() => null), | |||
| 32 | + | createElement: () => ({ content: { querySelectorAll: () => opts.files || [] } }), | |||
| 33 | + | addEventListener() {}, | |||
| 34 | + | }; | |||
| 35 | + | delete globalThis.ermiteLazy; | |||
| 36 | + | (0, eval)(SRC); | |||
| 37 | + | return { observed, urls }; | |||
| 38 | + | } | |||
| 39 | + | const settle = () => new Promise((r) => setTimeout(r, 0)); | |||
| 40 | + | const restore = () => { | |||
| 41 | + | delete globalThis.ermiteLazy; | |||
| 42 | + | for (const [k, v] of [['document', saved.document], ['fetch', saved.fetch], ['IntersectionObserver', saved.IO]]) { | |||
| 43 | + | if (v === undefined) delete globalThis[k]; else globalThis[k] = v; | |||
| 44 | + | } | |||
| 45 | + | }; | |||
| 46 | + | ||||
| 47 | + | test('manual placeholders are not observed', async () => { | |||
| 48 | + | const a = placeholder('/r/commit/abc/files?one=1&from=0', true); | |||
| 49 | + | const b = placeholder('/r/merge-requests/1/files?from=25', false); | |||
| 50 | + | try { | |||
| 51 | + | const { observed, urls } = await setup([a.ph, b.ph]); | |||
| 52 | + | assert.deepEqual(observed, [b.ph]); | |||
| 53 | + | assert.deepEqual(urls, []); | |||
| 54 | + | } finally { await settle(); restore(); } | |||
| 55 | + | }); | |||
| 56 | + | ||||
| 57 | + | test('without IntersectionObserver only the automatic placeholders load', async () => { | |||
| 58 | + | const a = placeholder('/m?one=1&from=0', true); | |||
| 59 | + | const b = placeholder('/w?from=25', false); | |||
| 60 | + | try { | |||
| 61 | + | const { urls } = await setup([a.ph, b.ph], false); | |||
| 62 | + | assert.deepEqual(urls, ['/w?from=25']); | |||
| 63 | + | } finally { await settle(); restore(); } | |||
| 64 | + | }); | |||
| 65 | + | ||||
| 66 | + | test('a click on "load diff" loads that one file and does not navigate', async () => { | |||
| 67 | + | const a = placeholder('/r/commit/abc/files?one=1&from=3', true); | |||
| 68 | + | try { | |||
| 69 | + | const { urls } = await setup([a.ph]); | |||
| 70 | + | let prevented = false; | |||
| 71 | + | const used = window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { prevented = true; } }); | |||
| 72 | + | assert.equal(used, true); | |||
| 73 | + | assert.equal(prevented, true); | |||
| 74 | + | assert.deepEqual(urls, ['/r/commit/abc/files?one=1&from=3']); | |||
| 75 | + | } finally { await settle(); restore(); } | |||
| 76 | + | }); | |||
| 77 | + | ||||
| 78 | + | test('Enter and Space on the link load it; other keys and modified clicks do not', async () => { | |||
| 79 | + | const a = placeholder('/f?one=1&from=1', true); | |||
| 80 | + | try { | |||
| 81 | + | const { urls } = await setup([a.ph]); | |||
| 82 | + | const key = (k, extra = {}) => window.ermiteLazy.onKey({ target: a.link, key: k, preventDefault() {}, ...extra }); | |||
| 83 | + | assert.equal(key('a'), false); | |||
| 84 | + | assert.equal(key('Enter', { ctrlKey: true }), false); | |||
| 85 | + | assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, ctrlKey: true, preventDefault() {} }), false); | |||
| 86 | + | assert.equal(urls.length, 0); | |||
| 87 | + | assert.equal(key('Enter'), true); | |||
| 88 | + | assert.equal(key(' '), true); | |||
| 89 | + | assert.equal(urls.length, 1, 'a file is fetched once'); | |||
| 90 | + | } finally { await settle(); restore(); } | |||
| 91 | + | }); | |||
| 92 | + | ||||
| 93 | + | test('a manual placeholder without an address is a plain link', async () => { | |||
| 94 | + | const a = placeholder('', true); | |||
| 95 | + | a.ph.dataset.src = ''; | |||
| 96 | + | try { | |||
| 97 | + | await setup([a.ph]); | |||
| 98 | + | assert.equal(window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() { assert.fail(); } }), false); | |||
| 99 | + | } finally { await settle(); restore(); } | |||
| 100 | + | }); | |||
| 101 | + | ||||
| 102 | + | test('onSwap runs after a window came in', async () => { | |||
| 103 | + | const b = placeholder('/w?from=25', false); | |||
| 104 | + | try { | |||
| 105 | + | await setup([b.ph]); | |||
| 106 | + | let n = 0; | |||
| 107 | + | window.ermiteLazy.onSwap(() => n++); | |||
| 108 | + | await window.ermiteLazy.load(b.ph); | |||
| 109 | + | assert.equal(n, 1); | |||
| 110 | + | } finally { await settle(); restore(); } | |||
| 111 | + | }); | |||
| 112 | + | ||||
| 113 | + | test('reset drops the response of a load in flight and lets the same window load again', async () => { | |||
| 114 | + | const b = placeholder('/w?from=25', false); | |||
| 115 | + | try { | |||
| 116 | + | await setup([b.ph]); | |||
| 117 | + | let release; | |||
| 118 | + | const gate = new Promise((r) => { release = r; }); | |||
| 119 | + | let fetches = 0; | |||
| 120 | + | globalThis.fetch = async () => { fetches++; await gate; return { status: 200, ok: true, text: async () => '' }; }; | |||
| 121 | + | let n = 0; | |||
| 122 | + | window.ermiteLazy.onSwap(() => n++); | |||
| 123 | + | const first = window.ermiteLazy.load(b.ph); | |||
| 124 | + | window.ermiteLazy.reset(); | |||
| 125 | + | release(); | |||
| 126 | + | await first; | |||
| 127 | + | assert.equal(n, 0, 'the old generation does not swap'); | |||
| 128 | + | await window.ermiteLazy.load(b.ph); | |||
| 129 | + | assert.equal(fetches, 2); | |||
| 130 | + | assert.equal(n, 1); | |||
| 131 | + | } finally { await settle(); restore(); } | |||
| 132 | + | }); | |||
| 133 | + | ||||
| 134 | + | test('reset clears the stale state; watch disconnects the previous observer', async () => { | |||
| 135 | + | const b = placeholder('/w?from=25', false); | |||
| 136 | + | try { | |||
| 137 | + | await setup([b.ph]); | |||
| 138 | + | const made = []; | |||
| 139 | + | globalThis.IntersectionObserver = class { | |||
| 140 | + | constructor() { this.off = false; made.push(this); } | |||
| 141 | + | observe() {} unobserve() {} disconnect() { this.off = true; } | |||
| 142 | + | }; | |||
| 143 | + | globalThis.fetch = async () => ({ status: 409, ok: false, text: async () => 'moved' }); | |||
| 144 | + | await window.ermiteLazy.load(b.ph); | |||
| 145 | + | const urls = []; | |||
| 146 | + | globalThis.fetch = async (u) => { urls.push(u); return { status: 200, ok: true, text: async () => '' }; }; | |||
| 147 | + | await window.ermiteLazy.load(b.ph); | |||
| 148 | + | assert.deepEqual(urls, [], 'stale: nothing loads'); | |||
| 149 | + | window.ermiteLazy.watch(); | |||
| 150 | + | window.ermiteLazy.watch(); | |||
| 151 | + | assert.equal(made.length, 2); | |||
| 152 | + | assert.ok(made[0].off && !made[1].off); | |||
| 153 | + | window.ermiteLazy.reset(); | |||
| 154 | + | assert.ok(made[1].off); | |||
| 155 | + | await window.ermiteLazy.load(b.ph); | |||
| 156 | + | assert.equal(urls.length, 1); | |||
| 157 | + | } finally { await settle(); restore(); } | |||
| 158 | + | }); | |||
| 159 | + | ||||
| 160 | + | ||||
| 161 | + | // A file of the page (old) or of a fragment (new), with the queries swap makes. | |||
| 162 | + | function fileEl(id, { lazy = false, manual = false, open = false } = {}) { | |||
| 163 | + | const f = { id, open, queried: [] }; | |||
| 164 | + | f.querySelector = (sel) => { | |||
| 165 | + | if (sel === '.dlazy[data-manual]') return manual ? {} : null; | |||
| 166 | + | if (sel === '.dlazy') return lazy || manual ? {} : null; | |||
| 167 | + | if (sel === '.ln a') return f.line || null; | |||
| 168 | + | return null; | |||
| 169 | + | }; | |||
| 170 | + | f.querySelectorAll = () => []; | |||
| 171 | + | return f; | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | test('a loaded manual file opens and takes the focus without scrolling', async () => { | |||
| 175 | + | const a = placeholder('/f?one=1&from=0', true); | |||
| 176 | + | const old = fileEl('f1', { manual: true }); | |||
| 177 | + | const fresh = fileEl('f1'); | |||
| 178 | + | const focused = []; | |||
| 179 | + | fresh.line = { focus: (o) => focused.push(o) }; | |||
| 180 | + | let current = old; | |||
| 181 | + | old.replaceWith = (n) => { current = n; }; | |||
| 182 | + | try { | |||
| 183 | + | await setup([a.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => current }); | |||
| 184 | + | window.ermiteLazy.onClick({ target: a.link, button: 0, preventDefault() {} }); | |||
| 185 | + | await settle(); await settle(); | |||
| 186 | + | assert.equal(current, fresh); | |||
| 187 | + | assert.equal(fresh.open, true); | |||
| 188 | + | assert.deepEqual(focused, [{ preventScroll: true }]); | |||
| 189 | + | } finally { await settle(); restore(); } | |||
| 190 | + | }); | |||
| 191 | + | ||||
| 192 | + | test('a window that still holds a collapsed file keeps it closed', async () => { | |||
| 193 | + | const w = placeholder('/w?from=25', false); | |||
| 194 | + | const old = fileEl('f26', { lazy: true, open: true }); | |||
| 195 | + | const fresh = fileEl('f26', { manual: true, open: true }); | |||
| 196 | + | let replaced = null; | |||
| 197 | + | old.replaceWith = (n) => { replaced = n; }; | |||
| 198 | + | try { | |||
| 199 | + | await setup([w.ph], true, { reply: { status: 200, ok: true, body: '<x>' }, files: [fresh], getElementById: () => old }); | |||
| 200 | + | await window.ermiteLazy.load(w.ph); | |||
| 201 | + | assert.equal(replaced, fresh); | |||
| 202 | + | assert.equal(fresh.open, false); | |||
| 203 | + | } finally { await settle(); restore(); } | |||
| 204 | + | }); | |||
| 205 | + | ||||
| 206 | + | test('a 409 makes the page stale: no later load fetches', async () => { | |||
| 207 | + | const a = placeholder('/f?one=1&from=0', true); | |||
| 208 | + | const b = placeholder('/f?one=1&from=1', true); | |||
| 209 | + | try { | |||
| 210 | + | const { urls } = await setup([a.ph, b.ph], true, { reply: { status: 409, ok: false, body: '<p>moved</p>' } }); | |||
| 211 | + | await window.ermiteLazy.load(a.ph); | |||
| 212 | + | assert.equal(a.ph.innerHTML, '<p>moved</p>'); | |||
| 213 | + | assert.equal(urls.length, 1); | |||
| 214 | + | await window.ermiteLazy.load(b.ph); | |||
| 215 | + | assert.equal(urls.length, 1, 'stale: nothing is fetched'); | |||
| 216 | + | } finally { await settle(); restore(); } | |||
| 217 | + | }); | |||
| 218 | + | ||||
| 219 | + | test('a failed load clears the loading mark and can be retried', async () => { | |||
| 220 | + | const a = placeholder('/f?one=1&from=0', true); | |||
| 221 | + | const marks = new Set(); | |||
| 222 | + | a.ph.setAttribute = (n) => marks.add(n); | |||
| 223 | + | a.ph.removeAttribute = (n) => marks.delete(n); | |||
| 224 | + | try { | |||
| 225 | + | const { urls } = await setup([a.ph], true, { reply: { status: 500, ok: false, body: 'boom' } }); | |||
| 226 | + | const p = window.ermiteLazy.load(a.ph); | |||
| 227 | + | assert.ok(marks.has('data-loading')); | |||
| 228 | + | await p; | |||
| 229 | + | assert.equal(marks.has('data-loading'), false); | |||
| 230 | + | await window.ermiteLazy.load(a.ph); | |||
| 231 | + | assert.equal(urls.length, 2, 'a retry fetches again'); | |||
| 232 | + | } finally { await settle(); restore(); } | |||
| 233 | + | }); | |||
| 234 | + | ||||
| 235 | + | test('Space is prevented on the link; a held key is ignored', async () => { | |||
| 236 | + | const a = placeholder('/f?one=1&from=1', true); | |||
| 237 | + | try { | |||
| 238 | + | const { urls } = await setup([a.ph]); | |||
| 239 | + | let prevented = 0; | |||
| 240 | + | const ev = (extra) => ({ target: a.link, key: ' ', preventDefault() { prevented++; }, ...extra }); | |||
| 241 | + | assert.equal(window.ermiteLazy.onKey(ev({ repeat: true })), false); | |||
| 242 | + | assert.equal(prevented, 0); | |||
| 243 | + | assert.equal(urls.length, 0); | |||
| 244 | + | assert.equal(window.ermiteLazy.onKey(ev({})), true); | |||
| 245 | + | assert.equal(prevented, 1, 'Space does not scroll the page'); | |||
| 246 | + | assert.equal(urls.length, 1); | |||
| 247 | + | } finally { await settle(); restore(); } | |||
| 248 | + | }); | |||
M internal/adapters/web/jstest/mr.test.mjs +1 -0
| @@ -5,6 +5,7 @@ import { test } from 'node:test'; | |||
| 5 | 5 | ||
| 6 | 6 | globalThis.window = globalThis; | |
| 7 | 7 | globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] }; | |
| 8 | + | await import('../static/js/lazy.js'); | |
| 8 | 9 | await import('../static/js/mr.js'); | |
| 9 | 10 | const C = window.ermiteMR.core; | |
| 10 | 11 | ||
| @@ -5,6 +5,7 @@ import { test } from 'node:test'; | |||||
| 5 | 5 | ||||
| 6 | globalThis.window = globalThis; | 6 | globalThis.window = globalThis; | ||
| 7 | globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] }; | 7 | globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] }; | ||
| 8 | + | await import('../static/js/lazy.js'); | |||
| 8 | await import('../static/js/mr.js'); | 9 | await import('../static/js/mr.js'); | ||
| 9 | const C = window.ermiteMR.core; | 10 | const C = window.ermiteMR.core; | ||
| 10 | 11 | ||||
M internal/adapters/web/mr.go +8 -2
| @@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 562 | 562 | } | |
| 563 | 563 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |
| 564 | 564 | SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |
| 565 | + | Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full | |
| 565 | 566 | }) | |
| 566 | 567 | if err != nil { | |
| 567 | 568 | return nil, 0, err | |
| @@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 590 | 591 | Files: s.hl.files(ctx, res.Diff.Files), | |
| 591 | 592 | } | |
| 592 | 593 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | |
| 594 | + | src := "" | |
| 595 | + | if !res.DiffBaseID.IsZero() { | |
| 596 | + | src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head) | |
| 597 | + | } | |
| 598 | + | collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head}) | |
| 593 | 599 | if err := s.mrFacts(ctx, r, d, &p, res); err != nil { | |
| 594 | 600 | return nil, 0, err | |
| 595 | 601 | } | |
| 596 | 602 | ||
| 597 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 603 | + | p.Note = diffNote(res.Diff) | |
| 598 | 604 | countNotes(p.Files, res.Comments) | |
| 599 | 605 | if !res.DiffBaseID.IsZero() { | |
| 600 | 606 | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |
| @@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 649 | 655 | p.Live = true | |
| 650 | 656 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |
| 651 | 657 | l.HasRail = true | |
| 652 | - | l.MR = true | |
| 658 | + | l.MR, l.Lazy = true, true | |
| 653 | 659 | return MergeRequest(l, p), http.StatusOK, nil | |
| 654 | 660 | } | |
| 655 | 661 | ||
| @@ -562,6 +562,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 562 | } | 562 | } | ||
| 563 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | 563 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | ||
| 564 | SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow}, | 564 | SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow}, | ||
| 565 | + | Load: fileParam(r), // a page without scripts: ?file=i reads that collapsed file in full | |||
| 565 | }) | 566 | }) | ||
| 566 | if err != nil { | 567 | if err != nil { | ||
| 567 | return nil, 0, err | 568 | return nil, 0, err | ||
| @@ -590,11 +591,16 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 590 | Files: s.hl.files(ctx, res.Diff.Files), | 591 | Files: s.hl.files(ctx, res.Diff.Files), | ||
| 591 | } | 592 | } | ||
| 592 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | 593 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | ||
| 594 | + | src := "" | |||
| 595 | + | if !res.DiffBaseID.IsZero() { | |||
| 596 | + | src = filesSrc(d.name(), n, since, res.DiffBaseID.String(), head) | |||
| 597 | + | } | |||
| 598 | + | collapseFiles(p.Files, res.Diff.Files, fileLinks{src: src, from: from, repo: d.name(), rev: head}) | |||
| 593 | if err := s.mrFacts(ctx, r, d, &p, res); err != nil { | 599 | if err := s.mrFacts(ctx, r, d, &p, res); err != nil { | ||
| 594 | return nil, 0, err | 600 | return nil, 0, err | ||
| 595 | } | 601 | } | ||
| 596 | 602 | ||||
| 597 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 603 | + | p.Note = diffNote(res.Diff) |
| 598 | countNotes(p.Files, res.Comments) | 604 | countNotes(p.Files, res.Comments) | ||
| 599 | if !res.DiffBaseID.IsZero() { | 605 | if !res.DiffBaseID.IsZero() { | ||
| 600 | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | 606 | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | ||
| @@ -649,7 +655,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 649 | p.Live = true | 655 | p.Live = true | ||
| 650 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | 656 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | ||
| 651 | l.HasRail = true | 657 | l.HasRail = true | ||
| 652 | - | l.MR = true | 658 | + | l.MR, l.Lazy = true, true |
| 653 | return MergeRequest(l, p), http.StatusOK, nil | 659 | return MergeRequest(l, p), http.StatusOK, nil | ||
| 654 | } | 660 | } | ||
| 655 | 661 | ||||
M internal/adapters/web/mr_files.go +36 -10
| @@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) { | |||
| 73 | 73 | return n, nil | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // parseOne reads ?one=1&from=i of a one-file fragment: the index i of the | |
| 77 | + | // file, any non-negative number. | |
| 78 | + | func parseOne(r *http.Request) (int, error) { | |
| 79 | + | q := r.URL.Query() | |
| 80 | + | s := q.Get("from") | |
| 81 | + | n, err := strconv.Atoi(s) | |
| 82 | + | if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s { | |
| 83 | + | return 0, errNotFound | |
| 84 | + | } | |
| 85 | + | return n, nil | |
| 86 | + | } | |
| 87 | + | ||
| 76 | 88 | // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows | |
| 77 | 89 | // a placeholder that loads their window. src is the URL of /files with the | |
| 78 | 90 | // base and the head of the diff; self the page, for the links of a page | |
| @@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||
| 167 | 179 | if since && !SignedIn(ctx) { | |
| 168 | 180 | return nil, 0, errNotFound | |
| 169 | 181 | } | |
| 170 | - | from, err := parseFrom(r) | |
| 182 | + | one := q.Get("one") == "1" | |
| 183 | + | var from int | |
| 184 | + | if one { | |
| 185 | + | from, err = parseOne(r) | |
| 186 | + | } else { | |
| 187 | + | from, err = parseFrom(r) | |
| 188 | + | } | |
| 171 | 189 | if err != nil { | |
| 172 | 190 | return nil, 0, err | |
| 173 | 191 | } | |
| @@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||
| 180 | 198 | if err != nil { | |
| 181 | 199 | return nil, 0, err | |
| 182 | 200 | } | |
| 201 | + | win := &ports.FileWindow{From: from, To: from + mrWindow} | |
| 202 | + | if one { | |
| 203 | + | win = &ports.FileWindow{From: from, To: from + 1, Full: true} | |
| 204 | + | } | |
| 183 | 205 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |
| 184 | - | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, | |
| 185 | - | Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |
| 206 | + | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win, | |
| 186 | 207 | }) | |
| 187 | 208 | if errors.Is(err, domain.ErrConflict) { | |
| 188 | 209 | return MRFilesMoved(), http.StatusConflict, nil | |
| @@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||
| 198 | 219 | canWrite := signed && m.State == domain.MRStateOpen && !since | |
| 199 | 220 | p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))} | |
| 200 | 221 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String())) | |
| 222 | + | links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()} | |
| 223 | + | if one { | |
| 224 | + | // A one-file read holds file from alone, at index from: the load | |
| 225 | + | // addresses of a window do not apply, only the raw link. | |
| 226 | + | p.Files[from].Raw = rawLink(res.Diff.Files[from], links) | |
| 227 | + | } else { | |
| 228 | + | collapseFiles(p.Files, res.Diff.Files, links) | |
| 229 | + | } | |
| 201 | 230 | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | |
| 202 | 231 | window := p.Files[from:min(from+mrWindow, len(p.Files))] | |
| 232 | + | if one { | |
| 233 | + | window = p.Files[from : from+1] | |
| 234 | + | } | |
| 203 | 235 | // The layouts follow the page: it sends ?view= (the layout shown) and | |
| 204 | 236 | // ?layouts=both when it holds both layouts and switches them itself, so | |
| 205 | 237 | // that every window of the page renders the same way. | |
| @@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||
| 209 | 241 | } | |
| 210 | 242 | lines, _ := diffLines(window) | |
| 211 | 243 | v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 212 | - | switch r.URL.Query().Get("layouts") { | |
| 213 | - | case "both": | |
| 214 | - | v.Both = true | |
| 215 | - | case "one": | |
| 216 | - | v.Both = false | |
| 217 | - | } | |
| 218 | - | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |
| 244 | + | v.pageLayouts(r) | |
| 219 | 245 | return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil | |
| 220 | 246 | } | |
| @@ -73,6 +73,18 @@ func parseFrom(r *http.Request) (int, error) { | |||||
| 73 | return n, nil | 73 | return n, nil | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | // parseOne reads ?one=1&from=i of a one-file fragment: the index i of the | |||
| 77 | + | // file, any non-negative number. | |||
| 78 | + | func parseOne(r *http.Request) (int, error) { | |||
| 79 | + | q := r.URL.Query() | |||
| 80 | + | s := q.Get("from") | |||
| 81 | + | n, err := strconv.Atoi(s) | |||
| 82 | + | if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s { | |||
| 83 | + | return 0, errNotFound | |||
| 84 | + | } | |||
| 85 | + | return n, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 76 | // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows | 88 | // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows | ||
| 77 | // a placeholder that loads their window. src is the URL of /files with the | 89 | // a placeholder that loads their window. src is the URL of /files with the | ||
| 78 | // base and the head of the diff; self the page, for the links of a page | 90 | // base and the head of the diff; self the page, for the links of a page | ||
| @@ -167,7 +179,13 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 167 | if since && !SignedIn(ctx) { | 179 | if since && !SignedIn(ctx) { | ||
| 168 | return nil, 0, errNotFound | 180 | return nil, 0, errNotFound | ||
| 169 | } | 181 | } | ||
| 170 | - | from, err := parseFrom(r) | 182 | + | one := q.Get("one") == "1" |
| 183 | + | var from int | |||
| 184 | + | if one { | |||
| 185 | + | from, err = parseOne(r) | |||
| 186 | + | } else { | |||
| 187 | + | from, err = parseFrom(r) | |||
| 188 | + | } | |||
| 171 | if err != nil { | 189 | if err != nil { | ||
| 172 | return nil, 0, err | 190 | return nil, 0, err | ||
| 173 | } | 191 | } | ||
| @@ -180,9 +198,12 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 180 | if err != nil { | 198 | if err != nil { | ||
| 181 | return nil, 0, err | 199 | return nil, 0, err | ||
| 182 | } | 200 | } | ||
| 201 | + | win := &ports.FileWindow{From: from, To: from + mrWindow} | |||
| 202 | + | if one { | |||
| 203 | + | win = &ports.FileWindow{From: from, To: from + 1, Full: true} | |||
| 204 | + | } | |||
| 183 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | 205 | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | ||
| 184 | - | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, | 206 | + | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win, |
| 185 | - | Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |||
| 186 | }) | 207 | }) | ||
| 187 | if errors.Is(err, domain.ErrConflict) { | 208 | if errors.Is(err, domain.ErrConflict) { | ||
| 188 | return MRFilesMoved(), http.StatusConflict, nil | 209 | return MRFilesMoved(), http.StatusConflict, nil | ||
| @@ -198,8 +219,19 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 198 | canWrite := signed && m.State == domain.MRStateOpen && !since | 219 | canWrite := signed && m.State == domain.MRStateOpen && !since | ||
| 199 | p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))} | 220 | p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))} | ||
| 200 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String())) | 221 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String())) | ||
| 222 | + | links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()} | |||
| 223 | + | if one { | |||
| 224 | + | // A one-file read holds file from alone, at index from: the load | |||
| 225 | + | // addresses of a window do not apply, only the raw link. | |||
| 226 | + | p.Files[from].Raw = rawLink(res.Diff.Files[from], links) | |||
| 227 | + | } else { | |||
| 228 | + | collapseFiles(p.Files, res.Diff.Files, links) | |||
| 229 | + | } | |||
| 201 | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | 230 | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | ||
| 202 | window := p.Files[from:min(from+mrWindow, len(p.Files))] | 231 | window := p.Files[from:min(from+mrWindow, len(p.Files))] | ||
| 232 | + | if one { | |||
| 233 | + | window = p.Files[from : from+1] | |||
| 234 | + | } | |||
| 203 | // The layouts follow the page: it sends ?view= (the layout shown) and | 235 | // The layouts follow the page: it sends ?view= (the layout shown) and | ||
| 204 | // ?layouts=both when it holds both layouts and switches them itself, so | 236 | // ?layouts=both when it holds both layouts and switches them itself, so | ||
| 205 | // that every window of the page renders the same way. | 237 | // that every window of the page renders the same way. | ||
| @@ -209,12 +241,6 @@ func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 209 | } | 241 | } | ||
| 210 | lines, _ := diffLines(window) | 242 | lines, _ := diffLines(window) | ||
| 211 | v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | 243 | v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | ||
| 212 | - | switch r.URL.Query().Get("layouts") { | 244 | + | v.pageLayouts(r) |
| 213 | - | case "both": | |||
| 214 | - | v.Both = true | |||
| 215 | - | case "one": | |||
| 216 | - | v.Both = false | |||
| 217 | - | } | |||
| 218 | - | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |||
| 219 | return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil | 245 | return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil | ||
| 220 | } | 246 | } | ||
M internal/adapters/web/shell.templ +3 -0
| @@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 30 | 30 | <script src={ l.Assets.Files } defer></script> | |
| 31 | 31 | <script src={ l.Assets.Passkey } defer></script> | |
| 32 | 32 | <script src={ l.Assets.Log } defer></script> | |
| 33 | + | if l.Lazy { | |
| 34 | + | <script src={ l.Assets.Lazy } defer></script> | |
| 35 | + | } | |
| 33 | 36 | if l.MR { | |
| 34 | 37 | <script src={ l.Assets.MR } defer></script> | |
| 35 | 38 | } | |
| @@ -30,6 +30,9 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 30 | <script src={ l.Assets.Files } defer></script> | 30 | <script src={ l.Assets.Files } defer></script> | ||
| 31 | <script src={ l.Assets.Passkey } defer></script> | 31 | <script src={ l.Assets.Passkey } defer></script> | ||
| 32 | <script src={ l.Assets.Log } defer></script> | 32 | <script src={ l.Assets.Log } defer></script> | ||
| 33 | + | if l.Lazy { | |||
| 34 | + | <script src={ l.Assets.Lazy } defer></script> | |||
| 35 | + | } | |||
| 33 | if l.MR { | 36 | if l.MR { | ||
| 34 | <script src={ l.Assets.MR } defer></script> | 37 | <script src={ l.Assets.MR } defer></script> | ||
| 35 | } | 38 | } | ||
M internal/adapters/web/shell_templ.go +199 -180 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +3 -1
| @@ -191,7 +191,9 @@ | |||
| 191 | 191 | &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; } | |
| 192 | 192 | /* with scripts the window comes by itself: the link of the page without | |
| 193 | 193 | scripts would only flash */ | |
| 194 | - | html.js & > a { visibility: hidden; } | |
| 194 | + | html.js &:not([data-manual]) > a { visibility: hidden; } | |
| 195 | + | &[data-manual] { height: auto; padding: var(--s3); } | |
| 196 | + | &[data-manual][data-loading] > a { opacity: .5; } | |
| 195 | 197 | & .notice { margin: 0; } | |
| 196 | 198 | } | |
| 197 | 199 | .dloose { | |
| @@ -191,7 +191,9 @@ | |||||
| 191 | &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; } | 191 | &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; } | ||
| 192 | /* with scripts the window comes by itself: the link of the page without | 192 | /* with scripts the window comes by itself: the link of the page without | ||
| 193 | scripts would only flash */ | 193 | scripts would only flash */ | ||
| 194 | - | html.js & > a { visibility: hidden; } | 194 | + | html.js &:not([data-manual]) > a { visibility: hidden; } |
| 195 | + | &[data-manual] { height: auto; padding: var(--s3); } | |||
| 196 | + | &[data-manual][data-loading] > a { opacity: .5; } | |||
| 195 | & .notice { margin: 0; } | 197 | & .notice { margin: 0; } | ||
| 196 | } | 198 | } | ||
| 197 | .dloose { | 199 | .dloose { | ||
A internal/adapters/web/static/js/lazy.js +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | // lazy.js: the files of a diff that are not in the page yet (commit, compare | |
| 2 | + | // and merge request pages). | |
| 3 | + | // | |
| 4 | + | // A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds: | |
| 5 | + | // - a window of a merge request: loaded by itself when it comes within two | |
| 6 | + | // screens of the view (the files of its window replace the placeholders of | |
| 7 | + | // the same ids); | |
| 8 | + | // - a collapsed file (generated, large) [data-manual]: never loaded by | |
| 9 | + | // itself. Its "load diff" link loads that one file (click, or Enter / Space | |
| 10 | + | // on the link); without scripts the link reloads the page with the file. | |
| 11 | + | // A fragment is fetched once. Nothing observes the DOM: the one | |
| 12 | + | // IntersectionObserver watches the automatic placeholders, and a manual one | |
| 13 | + | // costs no more than its markup. | |
| 14 | + | // | |
| 15 | + | // window.ermiteLazy.load is also the loader of mr.js (reveal). | |
| 16 | + | window.ermiteLazy = (() => { | |
| 17 | + | const $ = (sel, root = document) => root.querySelector(sel); | |
| 18 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 19 | + | const AUTO = '.dlazy[data-src]:not([data-manual])'; | |
| 20 | + | const loading = new Map(); // src -> promise of the swap | |
| 21 | + | let moved = false; | |
| 22 | + | let lazyIO = null; // the observer of the automatic placeholders | |
| 23 | + | let generation = 0; // bumped by reset: older responses are ignored | |
| 24 | + | let onSwap = () => {}; | |
| 25 | + | ||
| 26 | + | // load fetches the fragment of a placeholder and swaps its files in. The | |
| 27 | + | // promise resolves once they are in the page (or the page said to reload). | |
| 28 | + | function load(ph) { | |
| 29 | + | const gen = generation; | |
| 30 | + | const src = ph?.dataset.src; | |
| 31 | + | if (!src || moved) return Promise.resolve(); | |
| 32 | + | if (loading.has(src)) return loading.get(src); | |
| 33 | + | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |
| 34 | + | // The fragment renders like the page: the layout shown, and both layouts | |
| 35 | + | // when the page switches them itself (data-diff) or one (data-view). | |
| 36 | + | const d = $('#diffs')?.dataset || {}; | |
| 37 | + | const view = d.diff || d.view; | |
| 38 | + | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |
| 39 | + | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |
| 40 | + | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |
| 41 | + | .then(async (res) => { | |
| 42 | + | const html = await res.text(); | |
| 43 | + | if (gen !== generation) return; | |
| 44 | + | if (res.status === 409) return stale(html); | |
| 45 | + | if (!res.ok) throw new Error('loading files: ' + res.status); | |
| 46 | + | // A fragment of the old diff after the page went stale stays out. | |
| 47 | + | if (!moved) swap(html); | |
| 48 | + | }) | |
| 49 | + | .catch((err) => { | |
| 50 | + | if (gen !== generation) return; | |
| 51 | + | loading.delete(src); | |
| 52 | + | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |
| 53 | + | window.ermite?.flash?.(err.message, 4000); | |
| 54 | + | }); | |
| 55 | + | loading.set(src, p); | |
| 56 | + | return p; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // swap replaces each placeholder file with the file of the same id. A | |
| 60 | + | // file the reader folded stays folded. | |
| 61 | + | function swap(html) { | |
| 62 | + | const t = document.createElement('template'); | |
| 63 | + | t.innerHTML = html; | |
| 64 | + | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |
| 65 | + | const old = document.getElementById(f.id); | |
| 66 | + | if (!old || !old.querySelector('.dlazy')) continue; | |
| 67 | + | // A file still collapsed stays closed; a collapsed file opens once | |
| 68 | + | // loaded; a file of a window keeps the fold. | |
| 69 | + | f.open = f.querySelector('.dlazy[data-manual]') ? false | |
| 70 | + | : old.querySelector('.dlazy[data-manual]') ? true : old.open; | |
| 71 | + | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |
| 72 | + | old.replaceWith(f); | |
| 73 | + | window.htmx?.process(f); | |
| 74 | + | window.ermite?.watchFile?.(f, old); | |
| 75 | + | } | |
| 76 | + | onSwap(); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // stale: the diff changed under the page. The placeholders say so; no file | |
| 80 | + | // of another diff comes in. | |
| 81 | + | function stale(html) { | |
| 82 | + | moved = true; | |
| 83 | + | for (const p of $$('.dlazy')) { | |
| 84 | + | p.removeAttribute('data-loading'); | |
| 85 | + | p.innerHTML = html; | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Automatic placeholders load as they come within two screens of the view; | |
| 90 | + | // files that come in are marked in the list of files (ermite.js marks the | |
| 91 | + | // one in view). Manual ones are left alone. | |
| 92 | + | function watch() { | |
| 93 | + | lazyIO?.disconnect(); | |
| 94 | + | if (!('IntersectionObserver' in window)) { | |
| 95 | + | for (const p of $$(AUTO)) load(p); | |
| 96 | + | return; | |
| 97 | + | } | |
| 98 | + | lazyIO = new IntersectionObserver((entries) => { | |
| 99 | + | for (const e of entries) { | |
| 100 | + | if (!e.isIntersecting) continue; | |
| 101 | + | lazyIO.unobserve(e.target); | |
| 102 | + | load(e.target); | |
| 103 | + | } | |
| 104 | + | }, { rootMargin: '200% 0px 200% 0px' }); | |
| 105 | + | for (const p of $$(AUTO)) lazyIO.observe(p); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // reset forgets the loads in flight and the stale state: the page shows | |
| 109 | + | // another diff (refresh in place). watch starts the observer again. | |
| 110 | + | function reset() { | |
| 111 | + | generation++; | |
| 112 | + | moved = false; | |
| 113 | + | loading.clear(); | |
| 114 | + | lazyIO?.disconnect(); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // manualLink is the "load diff" link of an event target, when it is one that | |
| 118 | + | // loads in place (the placeholder has an address). | |
| 119 | + | const manualLink = (t) => { | |
| 120 | + | const a = t?.closest?.('.dlazy[data-manual] > a'); | |
| 121 | + | return a && a.closest('.dlazy')?.dataset.src ? a : null; | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | // loadManual loads the file of a "load diff" link and puts the focus in it: | |
| 125 | + | // on its first line link, else on its header. The scroll never moves for it. | |
| 126 | + | function loadManual(a) { | |
| 127 | + | const ph = a.closest('.dlazy'); | |
| 128 | + | const id = ph.closest('details')?.id; | |
| 129 | + | return load(ph).then(() => { | |
| 130 | + | const f = id && document.getElementById(id); | |
| 131 | + | if (!f || f.querySelector('.dlazy[data-manual]')) return; | |
| 132 | + | (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true }); | |
| 133 | + | }); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | function onClick(e) { | |
| 137 | + | if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; | |
| 138 | + | const a = manualLink(e.target); | |
| 139 | + | if (!a) return false; | |
| 140 | + | e.preventDefault(); | |
| 141 | + | loadManual(a); | |
| 142 | + | return true; | |
| 143 | + | } | |
| 144 | + | // Enter makes the browser click a link by itself; Space does not. Both are | |
| 145 | + | // handled here, once (a fragment is fetched once). | |
| 146 | + | function onKey(e) { | |
| 147 | + | // A held key repeats: after the load, focus is on a line link. | |
| 148 | + | if (e.repeat) return false; | |
| 149 | + | if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false; | |
| 150 | + | const a = manualLink(e.target); | |
| 151 | + | if (!a) return false; | |
| 152 | + | e.preventDefault(); | |
| 153 | + | loadManual(a); | |
| 154 | + | return true; | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | let started = false; | |
| 158 | + | function start() { | |
| 159 | + | if (started || !$('#diffs')) return; | |
| 160 | + | started = true; | |
| 161 | + | watch(); | |
| 162 | + | document.addEventListener('click', onClick); | |
| 163 | + | document.addEventListener('keydown', onKey); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey }; | |
| 167 | + | })(); | |
| 168 | + | ||
| 169 | + | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start()); | |
| 170 | + | else window.ermiteLazy.start(); | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | // lazy.js: the files of a diff that are not in the page yet (commit, compare | |||
| 2 | + | // and merge request pages). | |||
| 3 | + | // | |||
| 4 | + | // A placeholder is a .dlazy[data-src] in a details.dfile. Two kinds: | |||
| 5 | + | // - a window of a merge request: loaded by itself when it comes within two | |||
| 6 | + | // screens of the view (the files of its window replace the placeholders of | |||
| 7 | + | // the same ids); | |||
| 8 | + | // - a collapsed file (generated, large) [data-manual]: never loaded by | |||
| 9 | + | // itself. Its "load diff" link loads that one file (click, or Enter / Space | |||
| 10 | + | // on the link); without scripts the link reloads the page with the file. | |||
| 11 | + | // A fragment is fetched once. Nothing observes the DOM: the one | |||
| 12 | + | // IntersectionObserver watches the automatic placeholders, and a manual one | |||
| 13 | + | // costs no more than its markup. | |||
| 14 | + | // | |||
| 15 | + | // window.ermiteLazy.load is also the loader of mr.js (reveal). | |||
| 16 | + | window.ermiteLazy = (() => { | |||
| 17 | + | const $ = (sel, root = document) => root.querySelector(sel); | |||
| 18 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |||
| 19 | + | const AUTO = '.dlazy[data-src]:not([data-manual])'; | |||
| 20 | + | const loading = new Map(); // src -> promise of the swap | |||
| 21 | + | let moved = false; | |||
| 22 | + | let lazyIO = null; // the observer of the automatic placeholders | |||
| 23 | + | let generation = 0; // bumped by reset: older responses are ignored | |||
| 24 | + | let onSwap = () => {}; | |||
| 25 | + | ||||
| 26 | + | // load fetches the fragment of a placeholder and swaps its files in. The | |||
| 27 | + | // promise resolves once they are in the page (or the page said to reload). | |||
| 28 | + | function load(ph) { | |||
| 29 | + | const gen = generation; | |||
| 30 | + | const src = ph?.dataset.src; | |||
| 31 | + | if (!src || moved) return Promise.resolve(); | |||
| 32 | + | if (loading.has(src)) return loading.get(src); | |||
| 33 | + | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |||
| 34 | + | // The fragment renders like the page: the layout shown, and both layouts | |||
| 35 | + | // when the page switches them itself (data-diff) or one (data-view). | |||
| 36 | + | const d = $('#diffs')?.dataset || {}; | |||
| 37 | + | const view = d.diff || d.view; | |||
| 38 | + | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |||
| 39 | + | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |||
| 40 | + | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |||
| 41 | + | .then(async (res) => { | |||
| 42 | + | const html = await res.text(); | |||
| 43 | + | if (gen !== generation) return; | |||
| 44 | + | if (res.status === 409) return stale(html); | |||
| 45 | + | if (!res.ok) throw new Error('loading files: ' + res.status); | |||
| 46 | + | // A fragment of the old diff after the page went stale stays out. | |||
| 47 | + | if (!moved) swap(html); | |||
| 48 | + | }) | |||
| 49 | + | .catch((err) => { | |||
| 50 | + | if (gen !== generation) return; | |||
| 51 | + | loading.delete(src); | |||
| 52 | + | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |||
| 53 | + | window.ermite?.flash?.(err.message, 4000); | |||
| 54 | + | }); | |||
| 55 | + | loading.set(src, p); | |||
| 56 | + | return p; | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // swap replaces each placeholder file with the file of the same id. A | |||
| 60 | + | // file the reader folded stays folded. | |||
| 61 | + | function swap(html) { | |||
| 62 | + | const t = document.createElement('template'); | |||
| 63 | + | t.innerHTML = html; | |||
| 64 | + | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |||
| 65 | + | const old = document.getElementById(f.id); | |||
| 66 | + | if (!old || !old.querySelector('.dlazy')) continue; | |||
| 67 | + | // A file still collapsed stays closed; a collapsed file opens once | |||
| 68 | + | // loaded; a file of a window keeps the fold. | |||
| 69 | + | f.open = f.querySelector('.dlazy[data-manual]') ? false | |||
| 70 | + | : old.querySelector('.dlazy[data-manual]') ? true : old.open; | |||
| 71 | + | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |||
| 72 | + | old.replaceWith(f); | |||
| 73 | + | window.htmx?.process(f); | |||
| 74 | + | window.ermite?.watchFile?.(f, old); | |||
| 75 | + | } | |||
| 76 | + | onSwap(); | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // stale: the diff changed under the page. The placeholders say so; no file | |||
| 80 | + | // of another diff comes in. | |||
| 81 | + | function stale(html) { | |||
| 82 | + | moved = true; | |||
| 83 | + | for (const p of $$('.dlazy')) { | |||
| 84 | + | p.removeAttribute('data-loading'); | |||
| 85 | + | p.innerHTML = html; | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Automatic placeholders load as they come within two screens of the view; | |||
| 90 | + | // files that come in are marked in the list of files (ermite.js marks the | |||
| 91 | + | // one in view). Manual ones are left alone. | |||
| 92 | + | function watch() { | |||
| 93 | + | lazyIO?.disconnect(); | |||
| 94 | + | if (!('IntersectionObserver' in window)) { | |||
| 95 | + | for (const p of $$(AUTO)) load(p); | |||
| 96 | + | return; | |||
| 97 | + | } | |||
| 98 | + | lazyIO = new IntersectionObserver((entries) => { | |||
| 99 | + | for (const e of entries) { | |||
| 100 | + | if (!e.isIntersecting) continue; | |||
| 101 | + | lazyIO.unobserve(e.target); | |||
| 102 | + | load(e.target); | |||
| 103 | + | } | |||
| 104 | + | }, { rootMargin: '200% 0px 200% 0px' }); | |||
| 105 | + | for (const p of $$(AUTO)) lazyIO.observe(p); | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // reset forgets the loads in flight and the stale state: the page shows | |||
| 109 | + | // another diff (refresh in place). watch starts the observer again. | |||
| 110 | + | function reset() { | |||
| 111 | + | generation++; | |||
| 112 | + | moved = false; | |||
| 113 | + | loading.clear(); | |||
| 114 | + | lazyIO?.disconnect(); | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // manualLink is the "load diff" link of an event target, when it is one that | |||
| 118 | + | // loads in place (the placeholder has an address). | |||
| 119 | + | const manualLink = (t) => { | |||
| 120 | + | const a = t?.closest?.('.dlazy[data-manual] > a'); | |||
| 121 | + | return a && a.closest('.dlazy')?.dataset.src ? a : null; | |||
| 122 | + | }; | |||
| 123 | + | ||||
| 124 | + | // loadManual loads the file of a "load diff" link and puts the focus in it: | |||
| 125 | + | // on its first line link, else on its header. The scroll never moves for it. | |||
| 126 | + | function loadManual(a) { | |||
| 127 | + | const ph = a.closest('.dlazy'); | |||
| 128 | + | const id = ph.closest('details')?.id; | |||
| 129 | + | return load(ph).then(() => { | |||
| 130 | + | const f = id && document.getElementById(id); | |||
| 131 | + | if (!f || f.querySelector('.dlazy[data-manual]')) return; | |||
| 132 | + | (f.querySelector('.ln a') || f.querySelector('summary'))?.focus({ preventScroll: true }); | |||
| 133 | + | }); | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | function onClick(e) { | |||
| 137 | + | if (e.defaultPrevented || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; | |||
| 138 | + | const a = manualLink(e.target); | |||
| 139 | + | if (!a) return false; | |||
| 140 | + | e.preventDefault(); | |||
| 141 | + | loadManual(a); | |||
| 142 | + | return true; | |||
| 143 | + | } | |||
| 144 | + | // Enter makes the browser click a link by itself; Space does not. Both are | |||
| 145 | + | // handled here, once (a fragment is fetched once). | |||
| 146 | + | function onKey(e) { | |||
| 147 | + | // A held key repeats: after the load, focus is on a line link. | |||
| 148 | + | if (e.repeat) return false; | |||
| 149 | + | if ((e.key !== 'Enter' && e.key !== ' ') || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return false; | |||
| 150 | + | const a = manualLink(e.target); | |||
| 151 | + | if (!a) return false; | |||
| 152 | + | e.preventDefault(); | |||
| 153 | + | loadManual(a); | |||
| 154 | + | return true; | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | let started = false; | |||
| 158 | + | function start() { | |||
| 159 | + | if (started || !$('#diffs')) return; | |||
| 160 | + | started = true; | |||
| 161 | + | watch(); | |||
| 162 | + | document.addEventListener('click', onClick); | |||
| 163 | + | document.addEventListener('keydown', onKey); | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | return { load, start, watch, reset, onSwap: (fn) => { onSwap = fn; }, onClick, onKey }; | |||
| 167 | + | })(); | |||
| 168 | + | ||||
| 169 | + | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteLazy.start()); | |||
| 170 | + | else window.ermiteLazy.start(); | |||
M internal/adapters/web/static/js/mr.js +8 -91
| @@ -1,10 +1,9 @@ | |||
| 1 | 1 | // mr.js: the merge request page beyond what htmx and hx-live give. | |
| 2 | 2 | // | |
| 3 | 3 | // 1. Windows of files. The page holds the hunks of one window of files; the | |
| 4 | - | // other files are placeholders (.dlazy[data-src]). A placeholder that comes | |
| 5 | - | // near the screen fetches its window (/files?from=&base=&head=), whose | |
| 6 | - | // files replace the placeholders of the same ids. A window is fetched once; | |
| 7 | - | // a link to a file or to a comment of a placeholder fetches it first. | |
| 4 | + | // other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which | |
| 5 | + | // the commit and compare pages use too) when they come near the screen. A | |
| 6 | + | // link to a file or to a comment of a placeholder loads it first (reveal). | |
| 8 | 7 | // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu | |
| 9 | 8 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | |
| 10 | 9 | // submits (the browser does it: the focus is on the button), Esc closes. | |
| @@ -80,72 +79,10 @@ window.ermiteMR = (() => { | |||
| 80 | 79 | ||
| 81 | 80 | const $ = (sel, root = document) => root.querySelector(sel); | |
| 82 | 81 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 83 | - | const loading = new Map(); // src -> promise of the swap | |
| 84 | 82 | let refreshTouchComments = () => {}; | |
| 85 | - | let moved = false; | |
| 86 | - | let lazyIO = null; // the observer of the placeholders (watch) | |
| 87 | - | let diffGeneration = 0; | |
| 88 | 83 | let revealGen = 0; // the last reveal wins the scroll | |
| 89 | - | ||
| 90 | - | // load fetches the window of a placeholder and swaps its files in. The | |
| 91 | - | // promise resolves once they are in the page (or the page said to reload). | |
| 92 | - | function load(ph) { | |
| 93 | - | const generation = diffGeneration; | |
| 94 | - | const src = ph?.dataset.src; | |
| 95 | - | if (!src || moved) return Promise.resolve(); | |
| 96 | - | if (loading.has(src)) return loading.get(src); | |
| 97 | - | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |
| 98 | - | // The window renders like the page: the layout shown, and both layouts | |
| 99 | - | // when the page switches them itself (data-diff) or one (data-view). | |
| 100 | - | const d = $('#diffs')?.dataset || {}; | |
| 101 | - | const view = d.diff || d.view; | |
| 102 | - | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |
| 103 | - | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |
| 104 | - | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |
| 105 | - | .then(async (res) => { | |
| 106 | - | const html = await res.text(); | |
| 107 | - | if (generation !== diffGeneration) return; | |
| 108 | - | if (res.status === 409) return stale(html); | |
| 109 | - | if (!res.ok) throw new Error('loading files: ' + res.status); | |
| 110 | - | // A window of the old diff after the page went stale stays out. | |
| 111 | - | if (!moved) swap(html); | |
| 112 | - | }) | |
| 113 | - | .catch((err) => { | |
| 114 | - | if (generation !== diffGeneration) return; | |
| 115 | - | loading.delete(src); | |
| 116 | - | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |
| 117 | - | window.ermite?.flash?.(err.message, 4000); | |
| 118 | - | }); | |
| 119 | - | loading.set(src, p); | |
| 120 | - | return p; | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // swap replaces each placeholder file with the file of the same id. A | |
| 124 | - | // file the reader folded stays folded. | |
| 125 | - | function swap(html) { | |
| 126 | - | const t = document.createElement('template'); | |
| 127 | - | t.innerHTML = html; | |
| 128 | - | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |
| 129 | - | const old = document.getElementById(f.id); | |
| 130 | - | if (!old || !old.querySelector('.dlazy')) continue; | |
| 131 | - | f.open = old.open; | |
| 132 | - | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |
| 133 | - | old.replaceWith(f); | |
| 134 | - | window.htmx?.process(f); | |
| 135 | - | window.ermite?.watchFile?.(f, old); | |
| 136 | - | } | |
| 137 | - | refreshTouchComments(); | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | // stale: the diff changed under the page. The placeholders say so; no file | |
| 141 | - | // of another diff comes in. | |
| 142 | - | function stale(html) { | |
| 143 | - | moved = true; | |
| 144 | - | for (const p of $$('.dlazy')) { | |
| 145 | - | p.removeAttribute('data-loading'); | |
| 146 | - | p.innerHTML = html; | |
| 147 | - | } | |
| 148 | - | } | |
| 84 | + | const load = (ph) => window.ermiteLazy.load(ph); | |
| 85 | + | window.ermiteLazy.onSwap(() => refreshTouchComments()); | |
| 149 | 86 | ||
| 150 | 87 | // reveal loads the file of an id (a file "f12" or a comment "c3") when it is | |
| 151 | 88 | // a placeholder, then scrolls to it. It returns false when there is nothing | |
| @@ -163,7 +100,7 @@ window.ermiteMR = (() => { | |||
| 163 | 100 | } | |
| 164 | 101 | return document.getElementById(id); | |
| 165 | 102 | }; | |
| 166 | - | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy'); | |
| 103 | + | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])'); | |
| 167 | 104 | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | |
| 168 | 105 | const base = id.replace(/-s$/, ''); | |
| 169 | 106 | ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base)); | |
| @@ -195,25 +132,6 @@ window.ermiteMR = (() => { | |||
| 195 | 132 | return true; | |
| 196 | 133 | } | |
| 197 | 134 | ||
| 198 | - | // Placeholders load as they come within two screens of the view; files | |
| 199 | - | // that come in are marked in the list of files (ermite.js marks the one in | |
| 200 | - | // view). | |
| 201 | - | function watch() { | |
| 202 | - | lazyIO?.disconnect(); | |
| 203 | - | if (!('IntersectionObserver' in window)) { | |
| 204 | - | for (const p of $$('.dlazy[data-src]')) load(p); | |
| 205 | - | return; | |
| 206 | - | } | |
| 207 | - | lazyIO = new IntersectionObserver((entries) => { | |
| 208 | - | for (const e of entries) { | |
| 209 | - | if (!e.isIntersecting) continue; | |
| 210 | - | lazyIO.unobserve(e.target); | |
| 211 | - | load(e.target); | |
| 212 | - | } | |
| 213 | - | }, { rootMargin: '200% 0px 200% 0px' }); | |
| 214 | - | for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p); | |
| 215 | - | } | |
| 216 | - | ||
| 217 | 135 | // The merge menu. | |
| 218 | 136 | const menu = () => $('#merge-menu'); | |
| 219 | 137 | function openMerge() { | |
| @@ -598,7 +516,7 @@ window.ermiteMR = (() => { | |||
| 598 | 516 | const active = document.activeElement; | |
| 599 | 517 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | |
| 600 | 518 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | |
| 601 | - | ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect(); | |
| 519 | + | ++revealGen; window.ermiteLazy.reset(); | |
| 602 | 520 | $('#diffs', page).dataset.mrStarted = 'true'; | |
| 603 | 521 | // The fresh footer comes in with the panels as they are, so that an open | |
| 604 | 522 | // panel does not fold and open again while the other regions swap. | |
| @@ -624,7 +542,7 @@ window.ermiteMR = (() => { | |||
| 624 | 542 | touchComments(false); | |
| 625 | 543 | $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches); | |
| 626 | 544 | window.ermite?.refreshFiles?.(); | |
| 627 | - | watch(); | |
| 545 | + | window.ermiteLazy.watch(); | |
| 628 | 546 | if (focus && active !== document.body) { | |
| 629 | 547 | const region = focus.region ? $(focus.region) : document; | |
| 630 | 548 | const menu = focus.menu && document.getElementById(focus.menu); | |
| @@ -700,7 +618,6 @@ window.ermiteMR = (() => { | |||
| 700 | 618 | // the address of the request, so that a reload does not post again. | |
| 701 | 619 | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | |
| 702 | 620 | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | |
| 703 | - | watch(); | |
| 704 | 621 | // A link to a file or a comment that is not loaded yet. | |
| 705 | 622 | const hash = () => { | |
| 706 | 623 | const id = decodeURIComponent(location.hash.slice(1)); | |
| @@ -1,10 +1,9 @@ | |||||
| 1 | // mr.js: the merge request page beyond what htmx and hx-live give. | 1 | // mr.js: the merge request page beyond what htmx and hx-live give. | ||
| 2 | // | 2 | // | ||
| 3 | // 1. Windows of files. The page holds the hunks of one window of files; the | 3 | // 1. Windows of files. The page holds the hunks of one window of files; the | ||
| 4 | - | // other files are placeholders (.dlazy[data-src]). A placeholder that comes | 4 | + | // other files are placeholders (.dlazy[data-src]), loaded by lazy.js (which |
| 5 | - | // near the screen fetches its window (/files?from=&base=&head=), whose | 5 | + | // the commit and compare pages use too) when they come near the screen. A |
| 6 | - | // files replace the placeholders of the same ids. A window is fetched once; | 6 | + | // link to a file or to a comment of a placeholder loads it first (reveal). |
| 7 | - | // a link to a file or to a comment of a placeholder fetches it first. | |||
| 8 | // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu | 7 | // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu | ||
| 9 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | 8 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | ||
| 10 | // submits (the browser does it: the focus is on the button), Esc closes. | 9 | // submits (the browser does it: the focus is on the button), Esc closes. | ||
| @@ -80,72 +79,10 @@ window.ermiteMR = (() => { | |||||
| 80 | 79 | ||||
| 81 | const $ = (sel, root = document) => root.querySelector(sel); | 80 | const $ = (sel, root = document) => root.querySelector(sel); | ||
| 82 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | 81 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | ||
| 83 | - | const loading = new Map(); // src -> promise of the swap | |||
| 84 | let refreshTouchComments = () => {}; | 82 | let refreshTouchComments = () => {}; | ||
| 85 | - | let moved = false; | |||
| 86 | - | let lazyIO = null; // the observer of the placeholders (watch) | |||
| 87 | - | let diffGeneration = 0; | |||
| 88 | let revealGen = 0; // the last reveal wins the scroll | 83 | let revealGen = 0; // the last reveal wins the scroll | ||
| 89 | - | 84 | + | const load = (ph) => window.ermiteLazy.load(ph); | |
| 90 | - | // load fetches the window of a placeholder and swaps its files in. The | 85 | + | window.ermiteLazy.onSwap(() => refreshTouchComments()); |
| 91 | - | // promise resolves once they are in the page (or the page said to reload). | |||
| 92 | - | function load(ph) { | |||
| 93 | - | const generation = diffGeneration; | |||
| 94 | - | const src = ph?.dataset.src; | |||
| 95 | - | if (!src || moved) return Promise.resolve(); | |||
| 96 | - | if (loading.has(src)) return loading.get(src); | |||
| 97 | - | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |||
| 98 | - | // The window renders like the page: the layout shown, and both layouts | |||
| 99 | - | // when the page switches them itself (data-diff) or one (data-view). | |||
| 100 | - | const d = $('#diffs')?.dataset || {}; | |||
| 101 | - | const view = d.diff || d.view; | |||
| 102 | - | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |||
| 103 | - | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |||
| 104 | - | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |||
| 105 | - | .then(async (res) => { | |||
| 106 | - | const html = await res.text(); | |||
| 107 | - | if (generation !== diffGeneration) return; | |||
| 108 | - | if (res.status === 409) return stale(html); | |||
| 109 | - | if (!res.ok) throw new Error('loading files: ' + res.status); | |||
| 110 | - | // A window of the old diff after the page went stale stays out. | |||
| 111 | - | if (!moved) swap(html); | |||
| 112 | - | }) | |||
| 113 | - | .catch((err) => { | |||
| 114 | - | if (generation !== diffGeneration) return; | |||
| 115 | - | loading.delete(src); | |||
| 116 | - | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |||
| 117 | - | window.ermite?.flash?.(err.message, 4000); | |||
| 118 | - | }); | |||
| 119 | - | loading.set(src, p); | |||
| 120 | - | return p; | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // swap replaces each placeholder file with the file of the same id. A | |||
| 124 | - | // file the reader folded stays folded. | |||
| 125 | - | function swap(html) { | |||
| 126 | - | const t = document.createElement('template'); | |||
| 127 | - | t.innerHTML = html; | |||
| 128 | - | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |||
| 129 | - | const old = document.getElementById(f.id); | |||
| 130 | - | if (!old || !old.querySelector('.dlazy')) continue; | |||
| 131 | - | f.open = old.open; | |||
| 132 | - | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |||
| 133 | - | old.replaceWith(f); | |||
| 134 | - | window.htmx?.process(f); | |||
| 135 | - | window.ermite?.watchFile?.(f, old); | |||
| 136 | - | } | |||
| 137 | - | refreshTouchComments(); | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | // stale: the diff changed under the page. The placeholders say so; no file | |||
| 141 | - | // of another diff comes in. | |||
| 142 | - | function stale(html) { | |||
| 143 | - | moved = true; | |||
| 144 | - | for (const p of $$('.dlazy')) { | |||
| 145 | - | p.removeAttribute('data-loading'); | |||
| 146 | - | p.innerHTML = html; | |||
| 147 | - | } | |||
| 148 | - | } | |||
| 149 | 86 | ||||
| 150 | // reveal loads the file of an id (a file "f12" or a comment "c3") when it is | 87 | // reveal loads the file of an id (a file "f12" or a comment "c3") when it is | ||
| 151 | // a placeholder, then scrolls to it. It returns false when there is nothing | 88 | // a placeholder, then scrolls to it. It returns false when there is nothing | ||
| @@ -163,7 +100,7 @@ window.ermiteMR = (() => { | |||||
| 163 | } | 100 | } | ||
| 164 | return document.getElementById(id); | 101 | return document.getElementById(id); | ||
| 165 | }; | 102 | }; | ||
| 166 | - | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy'); | 103 | + | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy:not([data-manual])'); |
| 167 | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | 104 | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | ||
| 168 | const base = id.replace(/-s$/, ''); | 105 | const base = id.replace(/-s$/, ''); | ||
| 169 | ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base)); | 106 | ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base)); | ||
| @@ -195,25 +132,6 @@ window.ermiteMR = (() => { | |||||
| 195 | return true; | 132 | return true; | ||
| 196 | } | 133 | } | ||
| 197 | 134 | ||||
| 198 | - | // Placeholders load as they come within two screens of the view; files | |||
| 199 | - | // that come in are marked in the list of files (ermite.js marks the one in | |||
| 200 | - | // view). | |||
| 201 | - | function watch() { | |||
| 202 | - | lazyIO?.disconnect(); | |||
| 203 | - | if (!('IntersectionObserver' in window)) { | |||
| 204 | - | for (const p of $$('.dlazy[data-src]')) load(p); | |||
| 205 | - | return; | |||
| 206 | - | } | |||
| 207 | - | lazyIO = new IntersectionObserver((entries) => { | |||
| 208 | - | for (const e of entries) { | |||
| 209 | - | if (!e.isIntersecting) continue; | |||
| 210 | - | lazyIO.unobserve(e.target); | |||
| 211 | - | load(e.target); | |||
| 212 | - | } | |||
| 213 | - | }, { rootMargin: '200% 0px 200% 0px' }); | |||
| 214 | - | for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p); | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | // The merge menu. | 135 | // The merge menu. | ||
| 218 | const menu = () => $('#merge-menu'); | 136 | const menu = () => $('#merge-menu'); | ||
| 219 | function openMerge() { | 137 | function openMerge() { | ||
| @@ -598,7 +516,7 @@ window.ermiteMR = (() => { | |||||
| 598 | const active = document.activeElement; | 516 | const active = document.activeElement; | ||
| 599 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | 517 | const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') }; | ||
| 600 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | 518 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | ||
| 601 | - | ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect(); | 519 | + | ++revealGen; window.ermiteLazy.reset(); |
| 602 | $('#diffs', page).dataset.mrStarted = 'true'; | 520 | $('#diffs', page).dataset.mrStarted = 'true'; | ||
| 603 | // The fresh footer comes in with the panels as they are, so that an open | 521 | // The fresh footer comes in with the panels as they are, so that an open | ||
| 604 | // panel does not fold and open again while the other regions swap. | 522 | // panel does not fold and open again while the other regions swap. | ||
| @@ -624,7 +542,7 @@ window.ermiteMR = (() => { | |||||
| 624 | touchComments(false); | 542 | touchComments(false); | ||
| 625 | $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches); | 543 | $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches); | ||
| 626 | window.ermite?.refreshFiles?.(); | 544 | window.ermite?.refreshFiles?.(); | ||
| 627 | - | watch(); | 545 | + | window.ermiteLazy.watch(); |
| 628 | if (focus && active !== document.body) { | 546 | if (focus && active !== document.body) { | ||
| 629 | const region = focus.region ? $(focus.region) : document; | 547 | const region = focus.region ? $(focus.region) : document; | ||
| 630 | const menu = focus.menu && document.getElementById(focus.menu); | 548 | const menu = focus.menu && document.getElementById(focus.menu); | ||
| @@ -700,7 +618,6 @@ window.ermiteMR = (() => { | |||||
| 700 | // the address of the request, so that a reload does not post again. | 618 | // the address of the request, so that a reload does not post again. | ||
| 701 | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | 619 | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | ||
| 702 | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | 620 | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | ||
| 703 | - | watch(); | |||
| 704 | // A link to a file or a comment that is not loaded yet. | 621 | // A link to a file or a comment that is not loaded yet. | ||
| 705 | const hash = () => { | 622 | const hash = () => { | ||
| 706 | const id = decodeURIComponent(location.hash.slice(1)); | 623 | const id = decodeURIComponent(location.hash.slice(1)); | ||
M internal/adapters/web/view.go +3 -0
| @@ -67,6 +67,9 @@ type Layout struct { | |||
| 67 | 67 | // Passkeys tells that the passkey routes exist: the header then links to | |
| 68 | 68 | // them. | |
| 69 | 69 | Passkeys bool | |
| 70 | + | // Lazy loads the loader of the collapsed and lazy files of a diff | |
| 71 | + | // (lazy.js): the commit, compare and merge request pages. | |
| 72 | + | Lazy bool | |
| 70 | 73 | // MR loads the script of the merge request page (mr.js). | |
| 71 | 74 | MR bool | |
| 72 | 75 | // IssueForm loads the script of the public issue form (issue-form.js). | |
| @@ -67,6 +67,9 @@ type Layout struct { | |||||
| 67 | // Passkeys tells that the passkey routes exist: the header then links to | 67 | // Passkeys tells that the passkey routes exist: the header then links to | ||
| 68 | // them. | 68 | // them. | ||
| 69 | Passkeys bool | 69 | Passkeys bool | ||
| 70 | + | // Lazy loads the loader of the collapsed and lazy files of a diff | |||
| 71 | + | // (lazy.js): the commit, compare and merge request pages. | |||
| 72 | + | Lazy bool | |||
| 70 | // MR loads the script of the merge request page (mr.js). | 73 | // MR loads the script of the merge request page (mr.js). | ||
| 71 | MR bool | 74 | MR bool | ||
| 72 | // IssueForm loads the script of the public issue form (issue-form.js). | 75 | // IssueForm loads the script of the public issue form (issue-form.js). | ||
M internal/adapters/web/web.go +1 -0
| @@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 207 | 207 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | |
| 208 | 208 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | |
| 209 | 209 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | |
| 210 | + | mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles)) | |
| 210 | 211 | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | |
| 211 | 212 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |
| 212 | 213 | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | |
| @@ -207,6 +207,7 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 207 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | 207 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | ||
| 208 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | 208 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | ||
| 209 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | 209 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | ||
| 210 | + | mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles)) | |||
| 210 | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | 211 | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | ||
| 211 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | 212 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | ||
| 212 | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | 213 | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | ||
M internal/app/cli_mr.go +21 -10
| @@ -470,14 +470,16 @@ type jsonHunk struct { | |||
| 470 | 470 | } | |
| 471 | 471 | ||
| 472 | 472 | type jsonFile struct { | |
| 473 | - | OldPath string `json:"old_path"` | |
| 474 | - | NewPath string `json:"new_path"` | |
| 475 | - | Status string `json:"status"` | |
| 476 | - | Additions int32 `json:"additions"` | |
| 477 | - | Deletions int32 `json:"deletions"` | |
| 478 | - | Binary bool `json:"binary"` | |
| 479 | - | Truncated bool `json:"truncated"` | |
| 480 | - | Hunks []jsonHunk `json:"hunks,omitempty"` | |
| 473 | + | OldPath string `json:"old_path"` | |
| 474 | + | NewPath string `json:"new_path"` | |
| 475 | + | Status string `json:"status"` | |
| 476 | + | Additions int32 `json:"additions"` | |
| 477 | + | Deletions int32 `json:"deletions"` | |
| 478 | + | Binary bool `json:"binary"` | |
| 479 | + | Truncated bool `json:"truncated"` | |
| 480 | + | // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted. | |
| 481 | + | Omitted string `json:"omitted,omitempty"` | |
| 482 | + | Hunks []jsonHunk `json:"hunks,omitempty"` | |
| 481 | 483 | } | |
| 482 | 484 | ||
| 483 | 485 | type jsonCommit struct { | |
| @@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string { | |||
| 516 | 518 | return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_")) | |
| 517 | 519 | } | |
| 518 | 520 | ||
| 521 | + | // fileOmissionName is the lowercase reason of an omitted file, "" for none. | |
| 522 | + | func fileOmissionName(o ermite.FileOmission) string { | |
| 523 | + | if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED { | |
| 524 | + | return "" | |
| 525 | + | } | |
| 526 | + | return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_")) | |
| 527 | + | } | |
| 528 | + | ||
| 519 | 529 | func lineKindName(k ermite.LineKind) string { | |
| 520 | 530 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | |
| 521 | 531 | } | |
| @@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri | |||
| 540 | 550 | jf := jsonFile{ | |
| 541 | 551 | OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()), | |
| 542 | 552 | Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(), | |
| 553 | + | Omitted: fileOmissionName(f.GetOmitted()), | |
| 543 | 554 | } | |
| 544 | 555 | if withDiff { | |
| 545 | 556 | for _, h := range f.GetHunks() { | |
| @@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 581 | 592 | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | |
| 582 | 593 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | |
| 583 | 594 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 584 | - | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |
| 585 | - | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | |
| 595 | + | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" + | |
| 596 | + | "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | |
| 586 | 597 | "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).", | |
| 587 | 598 | Args: cobra.ExactArgs(2), | |
| 588 | 599 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| @@ -470,14 +470,16 @@ type jsonHunk struct { | |||||
| 470 | } | 470 | } | ||
| 471 | 471 | ||||
| 472 | type jsonFile struct { | 472 | type jsonFile struct { | ||
| 473 | - | OldPath string `json:"old_path"` | 473 | + | OldPath string `json:"old_path"` |
| 474 | - | NewPath string `json:"new_path"` | 474 | + | NewPath string `json:"new_path"` |
| 475 | - | Status string `json:"status"` | 475 | + | Status string `json:"status"` |
| 476 | - | Additions int32 `json:"additions"` | 476 | + | Additions int32 `json:"additions"` |
| 477 | - | Deletions int32 `json:"deletions"` | 477 | + | Deletions int32 `json:"deletions"` |
| 478 | - | Binary bool `json:"binary"` | 478 | + | Binary bool `json:"binary"` |
| 479 | - | Truncated bool `json:"truncated"` | 479 | + | Truncated bool `json:"truncated"` |
| 480 | - | Hunks []jsonHunk `json:"hunks,omitempty"` | 480 | + | // Omitted is generated, large, budget or unavailable; absent when the hunks are not omitted. |
| 481 | + | Omitted string `json:"omitted,omitempty"` | |||
| 482 | + | Hunks []jsonHunk `json:"hunks,omitempty"` | |||
| 481 | } | 483 | } | ||
| 482 | 484 | ||||
| 483 | type jsonCommit struct { | 485 | type jsonCommit struct { | ||
| @@ -516,6 +518,14 @@ func fileStatusName(s ermite.FileStatus) string { | |||||
| 516 | return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_")) | 518 | return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_")) | ||
| 517 | } | 519 | } | ||
| 518 | 520 | ||||
| 521 | + | // fileOmissionName is the lowercase reason of an omitted file, "" for none. | |||
| 522 | + | func fileOmissionName(o ermite.FileOmission) string { | |||
| 523 | + | if o == ermite.FileOmission_FILE_OMISSION_UNSPECIFIED { | |||
| 524 | + | return "" | |||
| 525 | + | } | |||
| 526 | + | return strings.ToLower(strings.TrimPrefix(o.String(), "FILE_OMISSION_")) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 519 | func lineKindName(k ermite.LineKind) string { | 529 | func lineKindName(k ermite.LineKind) string { | ||
| 520 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | 530 | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | ||
| 521 | } | 531 | } | ||
| @@ -540,6 +550,7 @@ func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL stri | |||||
| 540 | jf := jsonFile{ | 550 | jf := jsonFile{ | ||
| 541 | OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()), | 551 | OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()), | ||
| 542 | Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(), | 552 | Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(), | ||
| 553 | + | Omitted: fileOmissionName(f.GetOmitted()), | |||
| 543 | } | 554 | } | ||
| 544 | if withDiff { | 555 | if withDiff { | ||
| 545 | for _, h := range f.GetHunks() { | 556 | for _, h := range f.GetHunks() { | ||
| @@ -581,8 +592,8 @@ func newMRShowCmd(env *cliEnv) *cobra.Command { | |||||
| 581 | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | 592 | "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" + | ||
| 582 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | 593 | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | ||
| 583 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | 594 | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | ||
| 584 | - | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | 595 | + | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated, omitted (generated, large, budget or unavailable; absent when the hunks are\n" + |
| 585 | - | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | 596 | + | "not omitted) and, with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + |
| 586 | "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).", | 597 | "and last_read_lost; verdicts (author, verdict, comment_id, commit_id, created_at, stale).", | ||
| 587 | Args: cobra.ExactArgs(2), | 598 | Args: cobra.ExactArgs(2), | ||
| 588 | ValidArgsFunction: completeMRRepoThenNumber(env), | 599 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
M internal/app/cli_mr_test.go +15 -0
| @@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) { | |||
| 563 | 563 | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | |
| 564 | 564 | } | |
| 565 | 565 | } | |
| 566 | + | ||
| 567 | + | func TestJSONFileOmitted(t *testing.T) { | |
| 568 | + | d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{ | |
| 569 | + | {NewPath: "a"}, | |
| 570 | + | {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true}, | |
| 571 | + | {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true}, | |
| 572 | + | }}} | |
| 573 | + | got := toJSONDetail(d, false, "https://git.example.org") | |
| 574 | + | assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted}) | |
| 575 | + | raw, err := json.Marshal(got.Files) | |
| 576 | + | require.NoError(t, err) | |
| 577 | + | assert.Contains(t, string(raw), `"omitted":"generated"`) | |
| 578 | + | assert.Contains(t, string(raw), `"truncated":true`) | |
| 579 | + | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | |
| 580 | + | } | |
| @@ -563,3 +563,18 @@ func TestMRWebURL(t *testing.T) { | |||||
| 563 | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | 563 | t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) }) | ||
| 564 | } | 564 | } | ||
| 565 | } | 565 | } | ||
| 566 | + | ||||
| 567 | + | func TestJSONFileOmitted(t *testing.T) { | |||
| 568 | + | d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{ | |||
| 569 | + | {NewPath: "a"}, | |||
| 570 | + | {NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true}, | |||
| 571 | + | {NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true}, | |||
| 572 | + | }}} | |||
| 573 | + | got := toJSONDetail(d, false, "https://git.example.org") | |||
| 574 | + | assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted}) | |||
| 575 | + | raw, err := json.Marshal(got.Files) | |||
| 576 | + | require.NoError(t, err) | |||
| 577 | + | assert.Contains(t, string(raw), `"omitted":"generated"`) | |||
| 578 | + | assert.Contains(t, string(raw), `"truncated":true`) | |||
| 579 | + | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | |||
| 580 | + | } | |||
M internal/app/testdata/web/commit-big.html +6 -3
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -141,7 +143,6 @@ | |||
| 141 | 143 | </div> | |
| 142 | 144 | </details> | |
| 143 | 145 | </div> | |
| 144 | - | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | |
| 145 | 146 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 146 | 147 | <details class="dfile lnw-2" id="f1" data-file="big.txt"> | |
| 147 | 148 | <summary> | |
| @@ -149,9 +150,11 @@ | |||
| 149 | 150 | <span class="p">big.txt</span> | |
| 150 | 151 | <span class="plus">+2600</span> | |
| 151 | 152 | <span class="minus">-0</span> | |
| 152 | - | <span class="note">diff too large to show (2600 lines changed): collapsed</span> | |
| 153 | + | <span class="note">large diff (2600 lines changed): collapsed</span> | |
| 153 | 154 | </summary> | |
| 154 | - | <p class="note pad">diff too large to show (2600 lines changed): collapsed</p> | |
| 155 | + | <div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&from=0" data-manual> | |
| 156 | + | <a href="?file=0#f1">load diff</a> | |
| 157 | + | </div> | |
| 155 | 158 | </details> | |
| 156 | 159 | </div> | |
| 157 | 160 | </main> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
| @@ -141,7 +143,6 @@ | |||||
| 141 | </div> | 143 | </div> | ||
| 142 | </details> | 144 | </details> | ||
| 143 | </div> | 145 | </div> | ||
| 144 | - | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | |||
| 145 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 146 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 146 | <details class="dfile lnw-2" id="f1" data-file="big.txt"> | 147 | <details class="dfile lnw-2" id="f1" data-file="big.txt"> | ||
| 147 | <summary> | 148 | <summary> | ||
| @@ -149,9 +150,11 @@ | |||||
| 149 | <span class="p">big.txt</span> | 150 | <span class="p">big.txt</span> | ||
| 150 | <span class="plus">+2600</span> | 151 | <span class="plus">+2600</span> | ||
| 151 | <span class="minus">-0</span> | 152 | <span class="minus">-0</span> | ||
| 152 | - | <span class="note">diff too large to show (2600 lines changed): collapsed</span> | 153 | + | <span class="note">large diff (2600 lines changed): collapsed</span> |
| 153 | </summary> | 154 | </summary> | ||
| 154 | - | <p class="note pad">diff too large to show (2600 lines changed): collapsed</p> | 155 | + | <div class="dlazy" data-src="/diffs/commit/SHA40/files?one=1&from=0" data-manual> |
| 156 | + | <a href="?file=0#f1">load diff</a> | |||
| 157 | + | </div> | |||
| 155 | </details> | 158 | </details> | ||
| 156 | </div> | 159 | </div> | ||
| 157 | </main> | 160 | </main> | ||
M internal/app/testdata/web/commit-binary.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-merge.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-modify-split.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-modify.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-rename.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-root.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-trojan.html +2 -0
| @@ -30,6 +30,8 @@ | |||
| 30 | 30 | </script> | |
| 31 | 31 | <script src="/-/static/js/log.HASH.js" defer> | |
| 32 | 32 | </script> | |
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |
| 34 | + | </script> | |
| 33 | 35 | </head> | |
| 34 | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 35 | 37 | <a class="skip" href="#main">skip to content</a> | |
| @@ -30,6 +30,8 @@ | |||||
| 30 | </script> | 30 | </script> | ||
| 31 | <script src="/-/static/js/log.HASH.js" defer> | 31 | <script src="/-/static/js/log.HASH.js" defer> | ||
| 32 | </script> | 32 | </script> | ||
| 33 | + | <script src="/-/static/js/lazy.HASH.js" defer> | |||
| 34 | + | </script> | |||
| 33 | </head> | 35 | </head> | ||
| 34 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 36 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 35 | <a class="skip" href="#main">skip to content</a> | 37 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/web_commit_test.go +63 -5
| @@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) { | |||
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | // A commit page reads the hunks of its first 300 files; the others are listed | |
| 45 | + | // collapsed and load on their own. A binary file or one without changed lines | |
| 46 | + | // past the window has nothing to load and gets no link. | |
| 47 | + | func TestWebCommitFileWindow(t *testing.T) { | |
| 48 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 49 | + | b := browser{r.harness} | |
| 50 | + | for i := range 305 { | |
| 51 | + | require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755)) | |
| 52 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644)) | |
| 53 | + | } | |
| 54 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644)) | |
| 55 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644)) | |
| 56 | + | r.mustGit(r.wt, "add", ".") | |
| 57 | + | r.mustGit(r.wt, "commit", "-q", "-m", "many files") | |
| 58 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 59 | + | ||
| 60 | + | page := b.get("/proj/commit/"+r.headOf("feat/search"), nil) | |
| 61 | + | require.Equal(t, 200, page.status) | |
| 62 | + | assert.Contains(t, page.body, "line 299</td>", "index 299 is read") | |
| 63 | + | assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed") | |
| 64 | + | assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light")) | |
| 65 | + | assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load") | |
| 66 | + | assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`) | |
| 67 | + | assert.NotContains(t, page.body, `?file=299#`) | |
| 68 | + | assert.NotContains(t, page.body, `?file=305#`) | |
| 69 | + | assert.NotContains(t, page.body, `?file=306#`) | |
| 70 | + | } | |
| 71 | + | ||
| 44 | 72 | // The behaviours of the commit pages, asserted on their own. | |
| 45 | 73 | func TestWebCommit(t *testing.T) { | |
| 46 | 74 | h := newHarness(t, harnessOpts{}) | |
| @@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) { | |||
| 112 | 140 | assert.Contains(t, body, "holds invisible characters") | |
| 113 | 141 | }) | |
| 114 | 142 | ||
| 115 | - | t.Run("truncated", func(t *testing.T) { | |
| 143 | + | t.Run("large file is collapsed and loads on its own", func(t *testing.T) { | |
| 116 | 144 | body := get("/diffs/commit/"+f.big, 200) | |
| 117 | - | assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed") | |
| 118 | - | assert.Contains(t, body, "this diff is incomplete") | |
| 119 | - | assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed") | |
| 120 | - | assert.NotContains(t, body, "line 2000", "no lines of a truncated file") | |
| 145 | + | assert.Contains(t, body, "large diff (2600 lines changed): collapsed") | |
| 146 | + | assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note") | |
| 147 | + | assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed") | |
| 148 | + | assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&from=0" data-manual>`) | |
| 149 | + | assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`) | |
| 150 | + | assert.NotContains(t, body, "line 2000", "no rows for a collapsed file") | |
| 151 | + | ||
| 152 | + | // One file, in full, over the 2,000 lines of the page. | |
| 153 | + | frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200) | |
| 154 | + | assert.NotContains(t, frag, "<html") | |
| 155 | + | assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`)) | |
| 156 | + | assert.Contains(t, frag, "line 2599") | |
| 157 | + | assert.NotContains(t, frag, "data-manual") | |
| 158 | + | assert.Contains(t, frag, `id="f1"`) | |
| 159 | + | both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200) | |
| 160 | + | assert.NotContains(t, both, `class="dt uni"`) | |
| 161 | + | assert.Contains(t, both, `class="dt spl"`) | |
| 162 | + | ||
| 163 | + | // Without scripts: the page splices the file in. | |
| 164 | + | page := get("/diffs/commit/"+f.big+"?file=0", 200) | |
| 165 | + | assert.Contains(t, page, "line 2599") | |
| 166 | + | assert.NotContains(t, page, "data-manual") | |
| 167 | + | assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored") | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | t.Run("one file route", func(t *testing.T) { | |
| 171 | + | for _, p := range []string{ | |
| 172 | + | "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1", | |
| 173 | + | } { | |
| 174 | + | st, _, _ := h.get("/diffs/commit/" + f.big + p) | |
| 175 | + | assert.Equal(t, 404, st, p) | |
| 176 | + | } | |
| 177 | + | st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0") | |
| 178 | + | assert.Equal(t, 404, st) | |
| 121 | 179 | }) | |
| 122 | 180 | ||
| 123 | 181 | t.Run("not found", func(t *testing.T) { | |
| @@ -41,6 +41,34 @@ func TestWebCommitGolden(t *testing.T) { | |||||
| 41 | } | 41 | } | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | + | // A commit page reads the hunks of its first 300 files; the others are listed | |||
| 45 | + | // collapsed and load on their own. A binary file or one without changed lines | |||
| 46 | + | // past the window has nothing to load and gets no link. | |||
| 47 | + | func TestWebCommitFileWindow(t *testing.T) { | |||
| 48 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 49 | + | b := browser{r.harness} | |||
| 50 | + | for i := range 305 { | |||
| 51 | + | require.NoError(t, os.MkdirAll(filepath.Join(r.wt, "many"), 0o755)) | |||
| 52 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, fmt.Sprintf("many/f%03d.txt", i)), []byte(fmt.Sprintf("line %d\n", i)), 0o644)) | |||
| 53 | + | } | |||
| 54 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zbin.bin"), []byte("a\x00b"), 0o644)) | |||
| 55 | + | require.NoError(t, os.WriteFile(filepath.Join(r.wt, "zempty.txt"), nil, 0o644)) | |||
| 56 | + | r.mustGit(r.wt, "add", ".") | |||
| 57 | + | r.mustGit(r.wt, "commit", "-q", "-m", "many files") | |||
| 58 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 59 | + | ||||
| 60 | + | page := b.get("/proj/commit/"+r.headOf("feat/search"), nil) | |||
| 61 | + | require.Equal(t, 200, page.status) | |||
| 62 | + | assert.Contains(t, page.body, "line 299</td>", "index 299 is read") | |||
| 63 | + | assert.NotContains(t, page.body, "line 300</td>", "index 300 is collapsed") | |||
| 64 | + | assert.Equal(t, 5, strings.Count(page.body, "collapsed to keep the page light")) | |||
| 65 | + | assert.Equal(t, 5, strings.Count(page.body, "data-manual"), "binary and empty files past the window have nothing to load") | |||
| 66 | + | assert.Contains(t, page.body, `<a href="?file=300#f301">load diff</a>`) | |||
| 67 | + | assert.NotContains(t, page.body, `?file=299#`) | |||
| 68 | + | assert.NotContains(t, page.body, `?file=305#`) | |||
| 69 | + | assert.NotContains(t, page.body, `?file=306#`) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 44 | // The behaviours of the commit pages, asserted on their own. | 72 | // The behaviours of the commit pages, asserted on their own. | ||
| 45 | func TestWebCommit(t *testing.T) { | 73 | func TestWebCommit(t *testing.T) { | ||
| 46 | h := newHarness(t, harnessOpts{}) | 74 | h := newHarness(t, harnessOpts{}) | ||
| @@ -112,12 +140,42 @@ func TestWebCommit(t *testing.T) { | |||||
| 112 | assert.Contains(t, body, "holds invisible characters") | 140 | assert.Contains(t, body, "holds invisible characters") | ||
| 113 | }) | 141 | }) | ||
| 114 | 142 | ||||
| 115 | - | t.Run("truncated", func(t *testing.T) { | 143 | + | t.Run("large file is collapsed and loads on its own", func(t *testing.T) { |
| 116 | body := get("/diffs/commit/"+f.big, 200) | 144 | body := get("/diffs/commit/"+f.big, 200) | ||
| 117 | - | assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed") | 145 | + | assert.Contains(t, body, "large diff (2600 lines changed): collapsed") |
| 118 | - | assert.Contains(t, body, "this diff is incomplete") | 146 | + | assert.NotContains(t, body, "this diff is incomplete", "a collapsed file is not a global note") |
| 119 | - | assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed") | 147 | + | assert.NotRegexp(t, `<details class="dfile[^"]*" id="f1"[^>]* open`, body, "a collapsed file is closed") |
| 120 | - | assert.NotContains(t, body, "line 2000", "no lines of a truncated file") | 148 | + | assert.Contains(t, body, `<div class="dlazy" data-src="/diffs/commit/`+f.big+`/files?one=1&from=0" data-manual>`) |
| 149 | + | assert.Contains(t, body, `<a href="?file=0#f1">load diff</a>`) | |||
| 150 | + | assert.NotContains(t, body, "line 2000", "no rows for a collapsed file") | |||
| 151 | + | ||||
| 152 | + | // One file, in full, over the 2,000 lines of the page. | |||
| 153 | + | frag := get("/diffs/commit/"+f.big+"/files?one=1&from=0", 200) | |||
| 154 | + | assert.NotContains(t, frag, "<html") | |||
| 155 | + | assert.Equal(t, 1, strings.Count(frag, `<details class="dfile`)) | |||
| 156 | + | assert.Contains(t, frag, "line 2599") | |||
| 157 | + | assert.NotContains(t, frag, "data-manual") | |||
| 158 | + | assert.Contains(t, frag, `id="f1"`) | |||
| 159 | + | both := get("/diffs/commit/"+f.big+"/files?one=1&from=0&view=split&layouts=one", 200) | |||
| 160 | + | assert.NotContains(t, both, `class="dt uni"`) | |||
| 161 | + | assert.Contains(t, both, `class="dt spl"`) | |||
| 162 | + | ||||
| 163 | + | // Without scripts: the page splices the file in. | |||
| 164 | + | page := get("/diffs/commit/"+f.big+"?file=0", 200) | |||
| 165 | + | assert.Contains(t, page, "line 2599") | |||
| 166 | + | assert.NotContains(t, page, "data-manual") | |||
| 167 | + | assert.Contains(t, get("/diffs/commit/"+f.big+"?file=9", 200), "data-manual", "out of range: ignored") | |||
| 168 | + | }) | |||
| 169 | + | ||||
| 170 | + | t.Run("one file route", func(t *testing.T) { | |||
| 171 | + | for _, p := range []string{ | |||
| 172 | + | "/files?one=1&from=1", "/files?one=1&from=-1", "/files?one=1&from=x", "/files?from=0", "/files?one=1", | |||
| 173 | + | } { | |||
| 174 | + | st, _, _ := h.get("/diffs/commit/" + f.big + p) | |||
| 175 | + | assert.Equal(t, 404, st, p) | |||
| 176 | + | } | |||
| 177 | + | st, _, _ := h.get("/diffs/commit/main/files?one=1&from=0") | |||
| 178 | + | assert.Equal(t, 404, st) | |||
| 121 | }) | 179 | }) | ||
| 122 | 180 | ||||
| 123 | t.Run("not found", func(t *testing.T) { | 181 | t.Run("not found", func(t *testing.T) { | ||
M internal/app/web_mr_test.go +77 -0
| @@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) { | |||
| 921 | 921 | break | |
| 922 | 922 | } | |
| 923 | 923 | } | |
| 924 | + | ||
| 925 | + | // Collapsed files of a merge request: a generated file and a file over the | |
| 926 | + | // line cap are listed closed, with no rows and a manual placeholder; /files | |
| 927 | + | // with one=1 reads exactly one file in full, whatever its index; the page | |
| 928 | + | // without scripts (?file=i) splices the file in. | |
| 929 | + | func TestWebMergeRequestCollapsedFiles(t *testing.T) { | |
| 930 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 931 | + | b := browser{r.harness} | |
| 932 | + | var big strings.Builder | |
| 933 | + | for i := range 2600 { | |
| 934 | + | fmt.Fprintf(&big, "row %d\n", i) | |
| 935 | + | } | |
| 936 | + | r.writeFile(r.wt, "zz/big.txt", []byte(big.String())) | |
| 937 | + | r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n")) | |
| 938 | + | r.writeFile(r.wt, "zz/small.txt", []byte("small line\n")) | |
| 939 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 940 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t) | |
| 941 | + | ||
| 942 | + | page := b.get("/proj/merge-requests/1", nil) | |
| 943 | + | require.Equal(t, 200, page.status) | |
| 944 | + | assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file") | |
| 945 | + | assert.Contains(t, page.body, "generated file: collapsed") | |
| 946 | + | assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed") | |
| 947 | + | assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file") | |
| 948 | + | assert.NotContains(t, page.body, "generated line</td>") | |
| 949 | + | assert.Contains(t, page.body, "small line") | |
| 950 | + | assert.NotContains(t, page.body, "this diff is incomplete") | |
| 951 | + | srcs := regexp.MustCompile(`data-src="([^"]*one=1&from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1) | |
| 952 | + | require.Len(t, srcs, 2) | |
| 953 | + | ||
| 954 | + | for _, m := range srcs { | |
| 955 | + | one := b.get(html.UnescapeString(m[1]), nil) | |
| 956 | + | require.Equal(t, 200, one.status, one.body) | |
| 957 | + | assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file") | |
| 958 | + | assert.NotContains(t, one.body, "data-manual") | |
| 959 | + | } | |
| 960 | + | bigOne := b.get(html.UnescapeString(srcs[1][1]), nil) | |
| 961 | + | genOne := b.get(html.UnescapeString(srcs[0][1]), nil) | |
| 962 | + | assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599")) | |
| 963 | + | assert.Contains(t, bigOne.body+genOne.body, "generated line") | |
| 964 | + | ||
| 965 | + | assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status) | |
| 966 | + | assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status) | |
| 967 | + | ||
| 968 | + | for _, m := range srcs { | |
| 969 | + | own := b.get("/proj/merge-requests/1?file="+m[2], nil) | |
| 970 | + | require.Equal(t, 200, own.status) | |
| 971 | + | assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in") | |
| 972 | + | } | |
| 973 | + | } | |
| 974 | + | ||
| 975 | + | // The page of a window without scripts: ?file=i splices that collapsed file in | |
| 976 | + | // and the load link of a window keeps the window. | |
| 977 | + | func TestWebMergeRequestWindowFileSplice(t *testing.T) { | |
| 978 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 979 | + | b := browser{r.harness} | |
| 980 | + | for i := range 30 { | |
| 981 | + | r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i))) | |
| 982 | + | } | |
| 983 | + | r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n")) | |
| 984 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 985 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t) | |
| 986 | + | ||
| 987 | + | // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated | |
| 988 | + | // file is index 31, in the window that starts at 25. | |
| 989 | + | win := b.get("/proj/merge-requests/1?from=25", nil) | |
| 990 | + | require.Equal(t, 200, win.status) | |
| 991 | + | m := regexp.MustCompile(`href="(\?from=25&file=(\d+)#f\d+)"`).FindStringSubmatch(win.body) | |
| 992 | + | require.Len(t, m, 3, "the load link keeps the window") | |
| 993 | + | assert.Equal(t, "31", m[2]) | |
| 994 | + | assert.NotContains(t, win.body, "generated line</td>") | |
| 995 | + | ||
| 996 | + | own := b.get("/proj/merge-requests/1?from=25&file=31", nil) | |
| 997 | + | require.Equal(t, 200, own.status) | |
| 998 | + | assert.Contains(t, own.body, "generated line</td>", "the file is spliced in") | |
| 999 | + | assert.NotContains(t, own.body, "data-manual") | |
| 1000 | + | } | |
| @@ -921,3 +921,80 @@ func TestWebMergeRequestMergeabilityLive(t *testing.T) { | |||||
| 921 | break | 921 | break | ||
| 922 | } | 922 | } | ||
| 923 | } | 923 | } | ||
| 924 | + | ||||
| 925 | + | // Collapsed files of a merge request: a generated file and a file over the | |||
| 926 | + | // line cap are listed closed, with no rows and a manual placeholder; /files | |||
| 927 | + | // with one=1 reads exactly one file in full, whatever its index; the page | |||
| 928 | + | // without scripts (?file=i) splices the file in. | |||
| 929 | + | func TestWebMergeRequestCollapsedFiles(t *testing.T) { | |||
| 930 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 931 | + | b := browser{r.harness} | |||
| 932 | + | var big strings.Builder | |||
| 933 | + | for i := range 2600 { | |||
| 934 | + | fmt.Fprintf(&big, "row %d\n", i) | |||
| 935 | + | } | |||
| 936 | + | r.writeFile(r.wt, "zz/big.txt", []byte(big.String())) | |||
| 937 | + | r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n")) | |||
| 938 | + | r.writeFile(r.wt, "zz/small.txt", []byte("small line\n")) | |||
| 939 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 940 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t) | |||
| 941 | + | ||||
| 942 | + | page := b.get("/proj/merge-requests/1", nil) | |||
| 943 | + | require.Equal(t, 200, page.status) | |||
| 944 | + | assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file") | |||
| 945 | + | assert.Contains(t, page.body, "generated file: collapsed") | |||
| 946 | + | assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed") | |||
| 947 | + | assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file") | |||
| 948 | + | assert.NotContains(t, page.body, "generated line</td>") | |||
| 949 | + | assert.Contains(t, page.body, "small line") | |||
| 950 | + | assert.NotContains(t, page.body, "this diff is incomplete") | |||
| 951 | + | srcs := regexp.MustCompile(`data-src="([^"]*one=1&from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1) | |||
| 952 | + | require.Len(t, srcs, 2) | |||
| 953 | + | ||||
| 954 | + | for _, m := range srcs { | |||
| 955 | + | one := b.get(html.UnescapeString(m[1]), nil) | |||
| 956 | + | require.Equal(t, 200, one.status, one.body) | |||
| 957 | + | assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file") | |||
| 958 | + | assert.NotContains(t, one.body, "data-manual") | |||
| 959 | + | } | |||
| 960 | + | bigOne := b.get(html.UnescapeString(srcs[1][1]), nil) | |||
| 961 | + | genOne := b.get(html.UnescapeString(srcs[0][1]), nil) | |||
| 962 | + | assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599")) | |||
| 963 | + | assert.Contains(t, bigOne.body+genOne.body, "generated line") | |||
| 964 | + | ||||
| 965 | + | assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status) | |||
| 966 | + | assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status) | |||
| 967 | + | ||||
| 968 | + | for _, m := range srcs { | |||
| 969 | + | own := b.get("/proj/merge-requests/1?file="+m[2], nil) | |||
| 970 | + | require.Equal(t, 200, own.status) | |||
| 971 | + | assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in") | |||
| 972 | + | } | |||
| 973 | + | } | |||
| 974 | + | ||||
| 975 | + | // The page of a window without scripts: ?file=i splices that collapsed file in | |||
| 976 | + | // and the load link of a window keeps the window. | |||
| 977 | + | func TestWebMergeRequestWindowFileSplice(t *testing.T) { | |||
| 978 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 979 | + | b := browser{r.harness} | |||
| 980 | + | for i := range 30 { | |||
| 981 | + | r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i))) | |||
| 982 | + | } | |||
| 983 | + | r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n")) | |||
| 984 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 985 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t) | |||
| 986 | + | ||||
| 987 | + | // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated | |||
| 988 | + | // file is index 31, in the window that starts at 25. | |||
| 989 | + | win := b.get("/proj/merge-requests/1?from=25", nil) | |||
| 990 | + | require.Equal(t, 200, win.status) | |||
| 991 | + | m := regexp.MustCompile(`href="(\?from=25&file=(\d+)#f\d+)"`).FindStringSubmatch(win.body) | |||
| 992 | + | require.Len(t, m, 3, "the load link keeps the window") | |||
| 993 | + | assert.Equal(t, "31", m[2]) | |||
| 994 | + | assert.NotContains(t, win.body, "generated line</td>") | |||
| 995 | + | ||||
| 996 | + | own := b.get("/proj/merge-requests/1?from=25&file=31", nil) | |||
| 997 | + | require.Equal(t, 200, own.status) | |||
| 998 | + | assert.Contains(t, own.body, "generated line</td>", "the file is spliced in") | |||
| 999 | + | assert.NotContains(t, own.body, "data-manual") | |||
| 1000 | + | } | |||
M internal/domain/budget.go +60 -11
| @@ -2,7 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package domain | |
| 4 | 4 | ||
| 5 | - | import "fmt" | |
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "math" | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | // ValidateCommitObjects checks a request before any repository I/O. max is the | |
| 8 | 11 | // caller's commit-count limit. | |
| @@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) { | |||
| 45 | 48 | b.total += size | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 48 | - | // DiffBudget owns file selection and retained hunk accounting. Truncated files | |
| 51 | + | // DiffBudget owns file selection and retained hunk accounting. Omitted files | |
| 49 | 52 | // consume no budget; streaming readers may use a copy while attempting a file. | |
| 50 | 53 | type DiffBudget struct { | |
| 51 | 54 | limits DiffLimits | |
| 52 | 55 | linesLeft, bytesLeft int | |
| 56 | + | // fileBytes is bytesLeft when the current file started (StartFile). | |
| 57 | + | fileBytes int | |
| 53 | 58 | } | |
| 54 | 59 | ||
| 55 | 60 | func NewDiffBudget(limits DiffLimits) DiffBudget { | |
| 56 | - | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes} | |
| 61 | + | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes} | |
| 57 | 62 | } | |
| 58 | 63 | ||
| 59 | 64 | func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | |
| @@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | |||
| 69 | 74 | return | |
| 70 | 75 | } | |
| 71 | 76 | ||
| 72 | - | func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 } | |
| 77 | + | // Admit decides whether the hunks of a file are read. read is false with | |
| 78 | + | // OmitNone for a binary file or one without changed lines (nothing to show). | |
| 79 | + | // Otherwise read is false with the reason the file is omitted. path is the path | |
| 80 | + | // a comment designates (see FileDiff.CommentPath). | |
| 81 | + | func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) { | |
| 82 | + | switch { | |
| 83 | + | case binary || changed <= 0: | |
| 84 | + | return false, OmitNone | |
| 85 | + | case b.limits.SkipGenerated && IsGeneratedPath(path): | |
| 86 | + | return false, OmitGenerated | |
| 87 | + | case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines: | |
| 88 | + | return false, OmitUnavailable | |
| 89 | + | case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines: | |
| 90 | + | return false, OmitLarge | |
| 91 | + | case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0: | |
| 92 | + | return false, OmitBudget | |
| 93 | + | } | |
| 94 | + | return true, OmitNone | |
| 95 | + | } | |
| 73 | 96 | ||
| 74 | - | func (b DiffBudget) WantsHunks(binary bool, n int) bool { | |
| 75 | - | return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0 | |
| 97 | + | // FileLines is the most changed lines the next file may have; math.MaxInt when | |
| 98 | + | // neither MaxFileLines nor MaxTotalLines limits it. | |
| 99 | + | func (b DiffBudget) FileLines() int { | |
| 100 | + | n := math.MaxInt | |
| 101 | + | if b.limits.MaxFileLines > 0 { | |
| 102 | + | n = b.limits.MaxFileLines | |
| 103 | + | } | |
| 104 | + | if b.limits.MaxTotalLines > 0 { | |
| 105 | + | n = min(n, b.linesLeft) | |
| 106 | + | } | |
| 107 | + | return n | |
| 76 | 108 | } | |
| 77 | 109 | ||
| 78 | - | func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) } | |
| 110 | + | // StartFile marks the start of a file's lines, for ChargeLine to tell a file | |
| 111 | + | // over the byte limit on its own from one that met the bytes of earlier files. | |
| 112 | + | func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft } | |
| 79 | 113 | ||
| 80 | - | // ChargeLine accounts for one decoded hunk line and reports whether it fits. | |
| 81 | - | func (b *DiffBudget) ChargeLine(bytes int) bool { | |
| 82 | - | b.linesLeft-- | |
| 114 | + | // ChargeLine accounts for decoded text bytes and, for additions or deletions, | |
| 115 | + | // one changed line. Context consumes bytes only. It returns OmitNone when the | |
| 116 | + | // line fits and OmitBudget when the changed lines ran out. When the bytes run | |
| 117 | + | // out it returns OmitBudget if files read before this one (since StartFile) | |
| 118 | + | // spent some of them, as this file may still fit on its own, and | |
| 119 | + | // OmitUnavailable if this file alone is over MaxTotalBytes. | |
| 120 | + | func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission { | |
| 121 | + | if changed && b.limits.MaxTotalLines > 0 { | |
| 122 | + | b.linesLeft-- | |
| 123 | + | } | |
| 83 | 124 | b.bytesLeft -= bytes | |
| 84 | - | return b.linesLeft >= 0 && b.bytesLeft >= 0 | |
| 125 | + | switch { | |
| 126 | + | case b.limits.MaxTotalLines > 0 && b.linesLeft < 0: | |
| 127 | + | return OmitBudget | |
| 128 | + | case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes: | |
| 129 | + | return OmitBudget | |
| 130 | + | case b.bytesLeft < 0: | |
| 131 | + | return OmitUnavailable | |
| 132 | + | } | |
| 133 | + | return OmitNone | |
| 85 | 134 | } | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | package domain | 3 | package domain | ||
| 4 | 4 | ||||
| 5 | - | import "fmt" | 5 | + | import ( |
| 6 | + | "fmt" | |||
| 7 | + | "math" | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | // ValidateCommitObjects checks a request before any repository I/O. max is the | 10 | // ValidateCommitObjects checks a request before any repository I/O. max is the | ||
| 8 | // caller's commit-count limit. | 11 | // caller's commit-count limit. | ||
| @@ -45,15 +48,17 @@ func (b *CommitObjectBudget) Charge(id ObjectID, size int64) { | |||||
| 45 | b.total += size | 48 | b.total += size | ||
| 46 | } | 49 | } | ||
| 47 | 50 | ||||
| 48 | - | // DiffBudget owns file selection and retained hunk accounting. Truncated files | 51 | + | // DiffBudget owns file selection and retained hunk accounting. Omitted files |
| 49 | // consume no budget; streaming readers may use a copy while attempting a file. | 52 | // consume no budget; streaming readers may use a copy while attempting a file. | ||
| 50 | type DiffBudget struct { | 53 | type DiffBudget struct { | ||
| 51 | limits DiffLimits | 54 | limits DiffLimits | ||
| 52 | linesLeft, bytesLeft int | 55 | linesLeft, bytesLeft int | ||
| 56 | + | // fileBytes is bytesLeft when the current file started (StartFile). | |||
| 57 | + | fileBytes int | |||
| 53 | } | 58 | } | ||
| 54 | 59 | ||||
| 55 | func NewDiffBudget(limits DiffLimits) DiffBudget { | 60 | func NewDiffBudget(limits DiffLimits) DiffBudget { | ||
| 56 | - | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes} | 61 | + | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes, fileBytes: limits.MaxTotalBytes} |
| 57 | } | 62 | } | ||
| 58 | 63 | ||||
| 59 | func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | 64 | func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | ||
| @@ -69,17 +74,61 @@ func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | |||||
| 69 | return | 74 | return | ||
| 70 | } | 75 | } | ||
| 71 | 76 | ||||
| 72 | - | func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 } | 77 | + | // Admit decides whether the hunks of a file are read. read is false with |
| 78 | + | // OmitNone for a binary file or one without changed lines (nothing to show). | |||
| 79 | + | // Otherwise read is false with the reason the file is omitted. path is the path | |||
| 80 | + | // a comment designates (see FileDiff.CommentPath). | |||
| 81 | + | func (b DiffBudget) Admit(binary bool, path string, changed int) (read bool, why FileOmission) { | |||
| 82 | + | switch { | |||
| 83 | + | case binary || changed <= 0: | |||
| 84 | + | return false, OmitNone | |||
| 85 | + | case b.limits.SkipGenerated && IsGeneratedPath(path): | |||
| 86 | + | return false, OmitGenerated | |||
| 87 | + | case b.limits.MaxAloneLines > 0 && changed > b.limits.MaxAloneLines: | |||
| 88 | + | return false, OmitUnavailable | |||
| 89 | + | case b.limits.MaxFileLines > 0 && changed > b.limits.MaxFileLines: | |||
| 90 | + | return false, OmitLarge | |||
| 91 | + | case b.limits.MaxTotalLines > 0 && changed > b.linesLeft, b.bytesLeft <= 0: | |||
| 92 | + | return false, OmitBudget | |||
| 93 | + | } | |||
| 94 | + | return true, OmitNone | |||
| 95 | + | } | |||
| 73 | 96 | ||||
| 74 | - | func (b DiffBudget) WantsHunks(binary bool, n int) bool { | 97 | + | // FileLines is the most changed lines the next file may have; math.MaxInt when |
| 75 | - | return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0 | 98 | + | // neither MaxFileLines nor MaxTotalLines limits it. |
| 99 | + | func (b DiffBudget) FileLines() int { | |||
| 100 | + | n := math.MaxInt | |||
| 101 | + | if b.limits.MaxFileLines > 0 { | |||
| 102 | + | n = b.limits.MaxFileLines | |||
| 103 | + | } | |||
| 104 | + | if b.limits.MaxTotalLines > 0 { | |||
| 105 | + | n = min(n, b.linesLeft) | |||
| 106 | + | } | |||
| 107 | + | return n | |||
| 76 | } | 108 | } | ||
| 77 | 109 | ||||
| 78 | - | func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) } | 110 | + | // StartFile marks the start of a file's lines, for ChargeLine to tell a file |
| 111 | + | // over the byte limit on its own from one that met the bytes of earlier files. | |||
| 112 | + | func (b *DiffBudget) StartFile() { b.fileBytes = b.bytesLeft } | |||
| 79 | 113 | ||||
| 80 | - | // ChargeLine accounts for one decoded hunk line and reports whether it fits. | 114 | + | // ChargeLine accounts for decoded text bytes and, for additions or deletions, |
| 81 | - | func (b *DiffBudget) ChargeLine(bytes int) bool { | 115 | + | // one changed line. Context consumes bytes only. It returns OmitNone when the |
| 82 | - | b.linesLeft-- | 116 | + | // line fits and OmitBudget when the changed lines ran out. When the bytes run |
| 117 | + | // out it returns OmitBudget if files read before this one (since StartFile) | |||
| 118 | + | // spent some of them, as this file may still fit on its own, and | |||
| 119 | + | // OmitUnavailable if this file alone is over MaxTotalBytes. | |||
| 120 | + | func (b *DiffBudget) ChargeLine(bytes int, changed bool) FileOmission { | |||
| 121 | + | if changed && b.limits.MaxTotalLines > 0 { | |||
| 122 | + | b.linesLeft-- | |||
| 123 | + | } | |||
| 83 | b.bytesLeft -= bytes | 124 | b.bytesLeft -= bytes | ||
| 84 | - | return b.linesLeft >= 0 && b.bytesLeft >= 0 | 125 | + | switch { |
| 126 | + | case b.limits.MaxTotalLines > 0 && b.linesLeft < 0: | |||
| 127 | + | return OmitBudget | |||
| 128 | + | case b.bytesLeft < 0 && b.fileBytes < b.limits.MaxTotalBytes: | |||
| 129 | + | return OmitBudget | |||
| 130 | + | case b.bytesLeft < 0: | |||
| 131 | + | return OmitUnavailable | |||
| 132 | + | } | |||
| 133 | + | return OmitNone | |||
| 85 | } | 134 | } | ||
M internal/domain/budget_test.go +136 -17
| @@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) { | |||
| 94 | 94 | } | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | - | func TestDiffBudgetHunks(t *testing.T) { | |
| 97 | + | func TestDiffBudgetAdmit(t *testing.T) { | |
| 98 | 98 | for _, tt := range []struct { | |
| 99 | - | name string | |
| 100 | - | binary bool | |
| 101 | - | n, lines, bytes int | |
| 102 | - | want bool | |
| 99 | + | name string | |
| 100 | + | limits DiffLimits | |
| 101 | + | binary bool | |
| 102 | + | path string | |
| 103 | + | n int | |
| 104 | + | wantRead bool | |
| 105 | + | wantWhy FileOmission | |
| 103 | 106 | }{ | |
| 104 | - | {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false}, | |
| 105 | - | {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false}, | |
| 106 | - | {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false}, | |
| 107 | + | {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone}, | |
| 108 | + | {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone}, | |
| 109 | + | {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone}, | |
| 110 | + | {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge}, | |
| 111 | + | {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget}, | |
| 112 | + | {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget}, | |
| 113 | + | {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated}, | |
| 114 | + | {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone}, | |
| 115 | + | {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated}, | |
| 116 | + | {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone}, | |
| 107 | 117 | } { | |
| 108 | 118 | t.Run(tt.name, func(t *testing.T) { | |
| 109 | - | b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes}) | |
| 110 | - | if got := b.WantsHunks(tt.binary, tt.n); got != tt.want { | |
| 111 | - | t.Fatalf("got %v", got) | |
| 119 | + | b := NewDiffBudget(tt.limits) | |
| 120 | + | read, why := b.Admit(tt.binary, tt.path, tt.n) | |
| 121 | + | if read != tt.wantRead || why != tt.wantWhy { | |
| 122 | + | t.Fatalf("got %v,%v", read, why) | |
| 112 | 123 | } | |
| 113 | 124 | }) | |
| 114 | 125 | } | |
| @@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) { | |||
| 118 | 129 | for _, tt := range []struct { | |
| 119 | 130 | name string | |
| 120 | 131 | bytes int | |
| 121 | - | fits bool | |
| 122 | - | }{{"exact", 3, true}, {"over", 4, false}} { | |
| 132 | + | want FileOmission | |
| 133 | + | }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} { | |
| 123 | 134 | t.Run(tt.name, func(t *testing.T) { | |
| 124 | 135 | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3}) | |
| 125 | 136 | before := b | |
| 126 | 137 | if b.FileLines() != 1 { | |
| 127 | 138 | t.Fatal("wrong file limit") | |
| 128 | 139 | } | |
| 129 | - | if b.ChargeLine(tt.bytes) != tt.fits { | |
| 130 | - | t.Fatal("wrong charge result") | |
| 140 | + | if got := b.ChargeLine(tt.bytes, true); got != tt.want { | |
| 141 | + | t.Fatalf("wrong charge result %v", got) | |
| 131 | 142 | } | |
| 132 | - | if b.ChargeLine(0) { | |
| 143 | + | if b.ChargeLine(0, true) == OmitNone { | |
| 133 | 144 | t.Fatal("line budget exceeded") | |
| 134 | 145 | } | |
| 135 | 146 | b = before | |
| 136 | - | if !b.WantsHunks(false, 1) { | |
| 147 | + | if read, _ := b.Admit(false, "a", 1); !read { | |
| 137 | 148 | t.Fatal("discarded file consumed budget") | |
| 138 | 149 | } | |
| 139 | 150 | }) | |
| 140 | 151 | } | |
| 141 | 152 | } | |
| 153 | + | ||
| 154 | + | func TestDiffBudgetChargeReasons(t *testing.T) { | |
| 155 | + | b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3}) | |
| 156 | + | if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 { | |
| 157 | + | t.Fatal("context consumes bytes only") | |
| 158 | + | } | |
| 159 | + | if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 { | |
| 160 | + | t.Fatal("first changed line fits") | |
| 161 | + | } | |
| 162 | + | if got := b.ChargeLine(0, true); got != OmitBudget { | |
| 163 | + | t.Fatalf("lines ran out: %v", got) | |
| 164 | + | } | |
| 165 | + | b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1}) | |
| 166 | + | if got := b.ChargeLine(2, false); got != OmitUnavailable { | |
| 167 | + | t.Fatalf("bytes ran out: %v", got) | |
| 168 | + | } | |
| 169 | + | free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5}) | |
| 170 | + | for range 100 { | |
| 171 | + | if free.ChargeLine(0, true) != OmitNone { | |
| 172 | + | t.Fatal("0 means no line limit") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if free.FileLines() < 1<<30 { | |
| 176 | + | t.Fatal("unlimited FileLines") | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestFileOmissionCollapsed(t *testing.T) { | |
| 181 | + | for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} { | |
| 182 | + | if o.Collapsed() != want { | |
| 183 | + | t.Errorf("%d: got %v", o, !want) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestOneFileDiffLimits(t *testing.T) { | |
| 189 | + | l := OneFileDiffLimits(3) | |
| 190 | + | if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes { | |
| 191 | + | t.Fatalf("got %+v", l) | |
| 192 | + | } | |
| 193 | + | // A negative index (an unchecked IndexFunc) selects no file, not the whole diff. | |
| 194 | + | if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to { | |
| 195 | + | t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) { | |
| 200 | + | b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10}) | |
| 201 | + | b.StartFile() | |
| 202 | + | if b.ChargeLine(4, true) != OmitNone { | |
| 203 | + | t.Fatal("the first file fits") | |
| 204 | + | } | |
| 205 | + | b.StartFile() | |
| 206 | + | if got := b.ChargeLine(7, true); got != OmitBudget { | |
| 207 | + | t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got) | |
| 208 | + | } | |
| 209 | + | alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10}) | |
| 210 | + | alone.StartFile() | |
| 211 | + | if got := alone.ChargeLine(11, true); got != OmitUnavailable { | |
| 212 | + | t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestDiffBudgetContext(t *testing.T) { | |
| 217 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6}) | |
| 218 | + | if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 { | |
| 219 | + | t.Fatal("context consumed changed-line budget") | |
| 220 | + | } | |
| 221 | + | first := b.ChargeLine(1, true) | |
| 222 | + | second := b.ChargeLine(1, true) | |
| 223 | + | if first != OmitNone || second != OmitNone || b.FileLines() != 0 { | |
| 224 | + | t.Fatal("additions and deletions must consume changed-line budget") | |
| 225 | + | } | |
| 226 | + | if b.ChargeLine(2, false) != OmitNone { | |
| 227 | + | t.Fatal("context at the byte boundary must fit even without changed lines left") | |
| 228 | + | } | |
| 229 | + | if b.ChargeLine(1, false) != OmitUnavailable { | |
| 230 | + | t.Fatal("context must not exceed the byte budget") | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestDiffBudgetAdmitAloneLines(t *testing.T) { | |
| 235 | + | b := NewDiffBudget(OneFileDiffLimits(0)) | |
| 236 | + | if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone { | |
| 237 | + | t.Fatalf("at the cap: read=%v why=%v", read, why) | |
| 238 | + | } | |
| 239 | + | if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable { | |
| 240 | + | t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func TestDiffCollapseFrom(t *testing.T) { | |
| 245 | + | d := Diff{Files: []FileDiff{ | |
| 246 | + | {Additions: 1}, | |
| 247 | + | {Additions: 1}, | |
| 248 | + | {Binary: true, Additions: 1}, | |
| 249 | + | {Omitted: OmitLarge, Additions: 1}, | |
| 250 | + | {}, | |
| 251 | + | {Deletions: 2}, | |
| 252 | + | }} | |
| 253 | + | d.CollapseFrom(1) | |
| 254 | + | want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget} | |
| 255 | + | for i, f := range d.Files { | |
| 256 | + | if f.Omitted != want[i] { | |
| 257 | + | t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i]) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | } | |
| @@ -94,21 +94,32 @@ func TestDiffBudgetWindow(t *testing.T) { | |||||
| 94 | } | 94 | } | ||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | - | func TestDiffBudgetHunks(t *testing.T) { | 97 | + | func TestDiffBudgetAdmit(t *testing.T) { |
| 98 | for _, tt := range []struct { | 98 | for _, tt := range []struct { | ||
| 99 | - | name string | 99 | + | name string |
| 100 | - | binary bool | 100 | + | limits DiffLimits |
| 101 | - | n, lines, bytes int | 101 | + | binary bool |
| 102 | - | want bool | 102 | + | path string |
| 103 | + | n int | |||
| 104 | + | wantRead bool | |||
| 105 | + | wantWhy FileOmission | |||
| 103 | }{ | 106 | }{ | ||
| 104 | - | {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false}, | 107 | + | {"fits", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3, MaxTotalBytes: 1}, false, "a.go", 3, true, OmitNone}, |
| 105 | - | {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false}, | 108 | + | {"binary", DiffLimits{MaxTotalBytes: 1}, true, "a.bin", 3, false, OmitNone}, |
| 106 | - | {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false}, | 109 | + | {"no changes", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 0, false, OmitNone}, |
| 110 | + | {"file limit", DiffLimits{MaxFileLines: 3, MaxTotalLines: 5, MaxTotalBytes: 1}, false, "a.go", 4, false, OmitLarge}, | |||
| 111 | + | {"total lines", DiffLimits{MaxFileLines: 3, MaxTotalLines: 2, MaxTotalBytes: 1}, false, "a.go", 3, false, OmitBudget}, | |||
| 112 | + | {"no bytes", DiffLimits{MaxFileLines: 3, MaxTotalLines: 3}, false, "a.go", 1, false, OmitBudget}, | |||
| 113 | + | {"generated", DiffLimits{SkipGenerated: true, MaxTotalBytes: 1}, false, "go.sum", 1, false, OmitGenerated}, | |||
| 114 | + | {"generated not skipped", DiffLimits{MaxTotalBytes: 1}, false, "go.sum", 1, true, OmitNone}, | |||
| 115 | + | {"generated before large", DiffLimits{SkipGenerated: true, MaxFileLines: 1, MaxTotalBytes: 1}, false, "go.sum", 9, false, OmitGenerated}, | |||
| 116 | + | {"unlimited lines", DiffLimits{MaxTotalBytes: 1}, false, "a.go", 1 << 20, true, OmitNone}, | |||
| 107 | } { | 117 | } { | ||
| 108 | t.Run(tt.name, func(t *testing.T) { | 118 | t.Run(tt.name, func(t *testing.T) { | ||
| 109 | - | b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes}) | 119 | + | b := NewDiffBudget(tt.limits) |
| 110 | - | if got := b.WantsHunks(tt.binary, tt.n); got != tt.want { | 120 | + | read, why := b.Admit(tt.binary, tt.path, tt.n) |
| 111 | - | t.Fatalf("got %v", got) | 121 | + | if read != tt.wantRead || why != tt.wantWhy { |
| 122 | + | t.Fatalf("got %v,%v", read, why) | |||
| 112 | } | 123 | } | ||
| 113 | }) | 124 | }) | ||
| 114 | } | 125 | } | ||
| @@ -118,24 +129,132 @@ func TestDiffBudgetCharge(t *testing.T) { | |||||
| 118 | for _, tt := range []struct { | 129 | for _, tt := range []struct { | ||
| 119 | name string | 130 | name string | ||
| 120 | bytes int | 131 | bytes int | ||
| 121 | - | fits bool | 132 | + | want FileOmission |
| 122 | - | }{{"exact", 3, true}, {"over", 4, false}} { | 133 | + | }{{"exact", 3, OmitNone}, {"over", 4, OmitUnavailable}} { |
| 123 | t.Run(tt.name, func(t *testing.T) { | 134 | t.Run(tt.name, func(t *testing.T) { | ||
| 124 | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3}) | 135 | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3}) | ||
| 125 | before := b | 136 | before := b | ||
| 126 | if b.FileLines() != 1 { | 137 | if b.FileLines() != 1 { | ||
| 127 | t.Fatal("wrong file limit") | 138 | t.Fatal("wrong file limit") | ||
| 128 | } | 139 | } | ||
| 129 | - | if b.ChargeLine(tt.bytes) != tt.fits { | 140 | + | if got := b.ChargeLine(tt.bytes, true); got != tt.want { |
| 130 | - | t.Fatal("wrong charge result") | 141 | + | t.Fatalf("wrong charge result %v", got) |
| 131 | } | 142 | } | ||
| 132 | - | if b.ChargeLine(0) { | 143 | + | if b.ChargeLine(0, true) == OmitNone { |
| 133 | t.Fatal("line budget exceeded") | 144 | t.Fatal("line budget exceeded") | ||
| 134 | } | 145 | } | ||
| 135 | b = before | 146 | b = before | ||
| 136 | - | if !b.WantsHunks(false, 1) { | 147 | + | if read, _ := b.Admit(false, "a", 1); !read { |
| 137 | t.Fatal("discarded file consumed budget") | 148 | t.Fatal("discarded file consumed budget") | ||
| 138 | } | 149 | } | ||
| 139 | }) | 150 | }) | ||
| 140 | } | 151 | } | ||
| 141 | } | 152 | } | ||
| 153 | + | ||||
| 154 | + | func TestDiffBudgetChargeReasons(t *testing.T) { | |||
| 155 | + | b := NewDiffBudget(DiffLimits{MaxTotalLines: 1, MaxTotalBytes: 3}) | |||
| 156 | + | if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 1 { | |||
| 157 | + | t.Fatal("context consumes bytes only") | |||
| 158 | + | } | |||
| 159 | + | if b.ChargeLine(1, true) != OmitNone || b.FileLines() != 0 { | |||
| 160 | + | t.Fatal("first changed line fits") | |||
| 161 | + | } | |||
| 162 | + | if got := b.ChargeLine(0, true); got != OmitBudget { | |||
| 163 | + | t.Fatalf("lines ran out: %v", got) | |||
| 164 | + | } | |||
| 165 | + | b = NewDiffBudget(DiffLimits{MaxTotalLines: 9, MaxTotalBytes: 1}) | |||
| 166 | + | if got := b.ChargeLine(2, false); got != OmitUnavailable { | |||
| 167 | + | t.Fatalf("bytes ran out: %v", got) | |||
| 168 | + | } | |||
| 169 | + | free := NewDiffBudget(DiffLimits{MaxTotalBytes: 5}) | |||
| 170 | + | for range 100 { | |||
| 171 | + | if free.ChargeLine(0, true) != OmitNone { | |||
| 172 | + | t.Fatal("0 means no line limit") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if free.FileLines() < 1<<30 { | |||
| 176 | + | t.Fatal("unlimited FileLines") | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestFileOmissionCollapsed(t *testing.T) { | |||
| 181 | + | for o, want := range map[FileOmission]bool{OmitNone: false, OmitGenerated: true, OmitLarge: true, OmitBudget: true, OmitUnavailable: false} { | |||
| 182 | + | if o.Collapsed() != want { | |||
| 183 | + | t.Errorf("%d: got %v", o, !want) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestOneFileDiffLimits(t *testing.T) { | |||
| 189 | + | l := OneFileDiffLimits(3) | |||
| 190 | + | if l.From != 3 || l.To != 4 || l.MaxFileLines != 0 || l.MaxAloneLines != MaxOneFileLines || l.MaxTotalLines != 0 || l.MaxFiles != 0 || l.MaxTotalBytes != DefaultDiffLimits().MaxTotalBytes { | |||
| 191 | + | t.Fatalf("got %+v", l) | |||
| 192 | + | } | |||
| 193 | + | // A negative index (an unchecked IndexFunc) selects no file, not the whole diff. | |||
| 194 | + | if _, from, to, _ := NewDiffBudget(OneFileDiffLimits(-1)).Window(10); from < to { | |||
| 195 | + | t.Fatalf("OneFileDiffLimits(-1) selects files [%d, %d)", from, to) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestDiffBudgetBytesOfEarlierFiles(t *testing.T) { | |||
| 200 | + | b := NewDiffBudget(DiffLimits{MaxTotalBytes: 10}) | |||
| 201 | + | b.StartFile() | |||
| 202 | + | if b.ChargeLine(4, true) != OmitNone { | |||
| 203 | + | t.Fatal("the first file fits") | |||
| 204 | + | } | |||
| 205 | + | b.StartFile() | |||
| 206 | + | if got := b.ChargeLine(7, true); got != OmitBudget { | |||
| 207 | + | t.Fatalf("a file meeting the bytes of earlier files: got %v, want OmitBudget", got) | |||
| 208 | + | } | |||
| 209 | + | alone := NewDiffBudget(DiffLimits{MaxTotalBytes: 10}) | |||
| 210 | + | alone.StartFile() | |||
| 211 | + | if got := alone.ChargeLine(11, true); got != OmitUnavailable { | |||
| 212 | + | t.Fatalf("a file over the bytes on its own: got %v, want OmitUnavailable", got) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestDiffBudgetContext(t *testing.T) { | |||
| 217 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 2, MaxTotalBytes: 6}) | |||
| 218 | + | if b.ChargeLine(2, false) != OmitNone || b.FileLines() != 2 { | |||
| 219 | + | t.Fatal("context consumed changed-line budget") | |||
| 220 | + | } | |||
| 221 | + | first := b.ChargeLine(1, true) | |||
| 222 | + | second := b.ChargeLine(1, true) | |||
| 223 | + | if first != OmitNone || second != OmitNone || b.FileLines() != 0 { | |||
| 224 | + | t.Fatal("additions and deletions must consume changed-line budget") | |||
| 225 | + | } | |||
| 226 | + | if b.ChargeLine(2, false) != OmitNone { | |||
| 227 | + | t.Fatal("context at the byte boundary must fit even without changed lines left") | |||
| 228 | + | } | |||
| 229 | + | if b.ChargeLine(1, false) != OmitUnavailable { | |||
| 230 | + | t.Fatal("context must not exceed the byte budget") | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func TestDiffBudgetAdmitAloneLines(t *testing.T) { | |||
| 235 | + | b := NewDiffBudget(OneFileDiffLimits(0)) | |||
| 236 | + | if read, why := b.Admit(false, "a.go", MaxOneFileLines); !read || why != OmitNone { | |||
| 237 | + | t.Fatalf("at the cap: read=%v why=%v", read, why) | |||
| 238 | + | } | |||
| 239 | + | if read, why := b.Admit(false, "a.go", MaxOneFileLines+1); read || why != OmitUnavailable { | |||
| 240 | + | t.Fatalf("over the cap: read=%v why=%v, want OmitUnavailable", read, why) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestDiffCollapseFrom(t *testing.T) { | |||
| 245 | + | d := Diff{Files: []FileDiff{ | |||
| 246 | + | {Additions: 1}, | |||
| 247 | + | {Additions: 1}, | |||
| 248 | + | {Binary: true, Additions: 1}, | |||
| 249 | + | {Omitted: OmitLarge, Additions: 1}, | |||
| 250 | + | {}, | |||
| 251 | + | {Deletions: 2}, | |||
| 252 | + | }} | |||
| 253 | + | d.CollapseFrom(1) | |||
| 254 | + | want := []FileOmission{OmitNone, OmitBudget, OmitNone, OmitLarge, OmitNone, OmitBudget} | |||
| 255 | + | for i, f := range d.Files { | |||
| 256 | + | if f.Omitted != want[i] { | |||
| 257 | + | t.Errorf("file %d: omitted %v, want %v", i, f.Omitted, want[i]) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | } | |||
A internal/domain/generated.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "path" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var generatedSuffixes = []string{ | |
| 11 | + | "_templ.go", ".pb.go", "_gen.go", ".gen.go", | |
| 12 | + | ".min.js", ".min.css", ".js.map", ".css.map", | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var generatedNames = map[string]struct{}{ | |
| 16 | + | "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {}, | |
| 17 | + | "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {}, | |
| 18 | + | "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {}, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // IsGeneratedPath reports whether a changed file is generated or vendored by | |
| 22 | + | // its path alone: its diff is collapsed by default in the web view. | |
| 23 | + | func IsGeneratedPath(p string) bool { | |
| 24 | + | if p == "" { | |
| 25 | + | return false | |
| 26 | + | } | |
| 27 | + | base := path.Base(p) | |
| 28 | + | if _, ok := generatedNames[base]; ok { | |
| 29 | + | return true | |
| 30 | + | } | |
| 31 | + | for _, s := range generatedSuffixes { | |
| 32 | + | if strings.HasSuffix(base, s) { | |
| 33 | + | return true | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | // Directory segments only: the last segment is the file name. | |
| 37 | + | dir := p[:len(p)-len(base)] | |
| 38 | + | for dir != "" { | |
| 39 | + | seg, rest, _ := strings.Cut(dir, "/") | |
| 40 | + | if seg == "vendor" || seg == "node_modules" { | |
| 41 | + | return true | |
| 42 | + | } | |
| 43 | + | dir = rest | |
| 44 | + | } | |
| 45 | + | return false | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "path" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var generatedSuffixes = []string{ | |||
| 11 | + | "_templ.go", ".pb.go", "_gen.go", ".gen.go", | |||
| 12 | + | ".min.js", ".min.css", ".js.map", ".css.map", | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | var generatedNames = map[string]struct{}{ | |||
| 16 | + | "go.sum": {}, "package-lock.json": {}, "pnpm-lock.yaml": {}, "yarn.lock": {}, | |||
| 17 | + | "Cargo.lock": {}, "bun.lockb": {}, "bun.lock": {}, "composer.lock": {}, | |||
| 18 | + | "Gemfile.lock": {}, "poetry.lock": {}, "uv.lock": {}, "flake.lock": {}, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // IsGeneratedPath reports whether a changed file is generated or vendored by | |||
| 22 | + | // its path alone: its diff is collapsed by default in the web view. | |||
| 23 | + | func IsGeneratedPath(p string) bool { | |||
| 24 | + | if p == "" { | |||
| 25 | + | return false | |||
| 26 | + | } | |||
| 27 | + | base := path.Base(p) | |||
| 28 | + | if _, ok := generatedNames[base]; ok { | |||
| 29 | + | return true | |||
| 30 | + | } | |||
| 31 | + | for _, s := range generatedSuffixes { | |||
| 32 | + | if strings.HasSuffix(base, s) { | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | // Directory segments only: the last segment is the file name. | |||
| 37 | + | dir := p[:len(p)-len(base)] | |||
| 38 | + | for dir != "" { | |||
| 39 | + | seg, rest, _ := strings.Cut(dir, "/") | |||
| 40 | + | if seg == "vendor" || seg == "node_modules" { | |||
| 41 | + | return true | |||
| 42 | + | } | |||
| 43 | + | dir = rest | |||
| 44 | + | } | |||
| 45 | + | return false | |||
| 46 | + | } | |||
A internal/domain/generated_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestIsGeneratedPath(t *testing.T) { | |
| 8 | + | t.Parallel() | |
| 9 | + | tests := []struct { | |
| 10 | + | path string | |
| 11 | + | want bool | |
| 12 | + | }{ | |
| 13 | + | {"views/a_templ.go", true}, | |
| 14 | + | {"api/x.pb.go", true}, | |
| 15 | + | {"x_gen.go", true}, | |
| 16 | + | {"x.gen.go", true}, | |
| 17 | + | {"static/app.min.js", true}, | |
| 18 | + | {"static/app.min.css", true}, | |
| 19 | + | {"static/app.js.map", true}, | |
| 20 | + | {"static/app.css.map", true}, | |
| 21 | + | {"go.sum", true}, | |
| 22 | + | {"web/package-lock.json", true}, | |
| 23 | + | {"pnpm-lock.yaml", true}, | |
| 24 | + | {"yarn.lock", true}, | |
| 25 | + | {"Cargo.lock", true}, | |
| 26 | + | {"bun.lockb", true}, | |
| 27 | + | {"bun.lock", true}, | |
| 28 | + | {"composer.lock", true}, | |
| 29 | + | {"Gemfile.lock", true}, | |
| 30 | + | {"poetry.lock", true}, | |
| 31 | + | {"uv.lock", true}, | |
| 32 | + | {"flake.lock", true}, | |
| 33 | + | {"vendor/x.go", true}, | |
| 34 | + | {"a/vendor/b/x.go", true}, | |
| 35 | + | {"node_modules/a.js", true}, | |
| 36 | + | {"web/node_modules/a.js", true}, | |
| 37 | + | {"vendor.go", false}, | |
| 38 | + | {"templ.go", false}, | |
| 39 | + | {"main.go", false}, | |
| 40 | + | {"go.mod", false}, | |
| 41 | + | {"minify.js", false}, | |
| 42 | + | {"myvendor/x.go", false}, | |
| 43 | + | {"x.min.jsx", false}, | |
| 44 | + | {"", false}, | |
| 45 | + | } | |
| 46 | + | for _, tt := range tests { | |
| 47 | + | if got := IsGeneratedPath(tt.path); got != tt.want { | |
| 48 | + | t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | func TestIsGeneratedPath(t *testing.T) { | |||
| 8 | + | t.Parallel() | |||
| 9 | + | tests := []struct { | |||
| 10 | + | path string | |||
| 11 | + | want bool | |||
| 12 | + | }{ | |||
| 13 | + | {"views/a_templ.go", true}, | |||
| 14 | + | {"api/x.pb.go", true}, | |||
| 15 | + | {"x_gen.go", true}, | |||
| 16 | + | {"x.gen.go", true}, | |||
| 17 | + | {"static/app.min.js", true}, | |||
| 18 | + | {"static/app.min.css", true}, | |||
| 19 | + | {"static/app.js.map", true}, | |||
| 20 | + | {"static/app.css.map", true}, | |||
| 21 | + | {"go.sum", true}, | |||
| 22 | + | {"web/package-lock.json", true}, | |||
| 23 | + | {"pnpm-lock.yaml", true}, | |||
| 24 | + | {"yarn.lock", true}, | |||
| 25 | + | {"Cargo.lock", true}, | |||
| 26 | + | {"bun.lockb", true}, | |||
| 27 | + | {"bun.lock", true}, | |||
| 28 | + | {"composer.lock", true}, | |||
| 29 | + | {"Gemfile.lock", true}, | |||
| 30 | + | {"poetry.lock", true}, | |||
| 31 | + | {"uv.lock", true}, | |||
| 32 | + | {"flake.lock", true}, | |||
| 33 | + | {"vendor/x.go", true}, | |||
| 34 | + | {"a/vendor/b/x.go", true}, | |||
| 35 | + | {"node_modules/a.js", true}, | |||
| 36 | + | {"web/node_modules/a.js", true}, | |||
| 37 | + | {"vendor.go", false}, | |||
| 38 | + | {"templ.go", false}, | |||
| 39 | + | {"main.go", false}, | |||
| 40 | + | {"go.mod", false}, | |||
| 41 | + | {"minify.js", false}, | |||
| 42 | + | {"myvendor/x.go", false}, | |||
| 43 | + | {"x.min.jsx", false}, | |||
| 44 | + | {"", false}, | |||
| 45 | + | } | |||
| 46 | + | for _, tt := range tests { | |||
| 47 | + | if got := IsGeneratedPath(tt.path); got != tt.want { | |||
| 48 | + | t.Errorf("IsGeneratedPath(%q) = %v, want %v", tt.path, got, tt.want) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
M internal/domain/git.go +84 -19
| @@ -505,11 +505,37 @@ type FileDiff struct { | |||
| 505 | 505 | Additions, Deletions int | |
| 506 | 506 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | |
| 507 | 507 | Similarity int | |
| 508 | - | // Truncated means the file exceeded a diff limit: Hunks is empty although | |
| 509 | - | // the file changed. Additions and Deletions stay accurate. A file outside | |
| 510 | - | // the DiffLimits window has no Hunks and is not Truncated. | |
| 511 | - | Truncated bool | |
| 512 | - | Hunks []Hunk | |
| 508 | + | // Omitted tells why the hunks were not kept: Hunks is empty although the | |
| 509 | + | // file changed; Additions and Deletions stay accurate. A file outside the | |
| 510 | + | // window is OmitNone with no Hunks. | |
| 511 | + | Omitted FileOmission | |
| 512 | + | Hunks []Hunk | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // CommentPath is the path a comment designates for f, as Diff.HasLine reads it: | |
| 516 | + | // the old path of a deleted file, the new path otherwise. | |
| 517 | + | func (f FileDiff) CommentPath() string { | |
| 518 | + | if f.Status == FileDeleted { | |
| 519 | + | return f.OldPath | |
| 520 | + | } | |
| 521 | + | return f.NewPath | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // FileOmission tells why a changed file has no hunks. | |
| 525 | + | type FileOmission uint8 | |
| 526 | + | ||
| 527 | + | // File omissions. | |
| 528 | + | const ( | |
| 529 | + | OmitNone FileOmission = iota | |
| 530 | + | OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read | |
| 531 | + | OmitLarge // changed lines > MaxFileLines | |
| 532 | + | OmitBudget // did not fit in the remaining MaxTotalLines | |
| 533 | + | OmitUnavailable // retained text would exceed MaxTotalBytes | |
| 534 | + | ) | |
| 535 | + | ||
| 536 | + | // Collapsed reports a file the web view folds and can load on its own. | |
| 537 | + | func (o FileOmission) Collapsed() bool { | |
| 538 | + | return o == OmitGenerated || o == OmitLarge || o == OmitBudget | |
| 513 | 539 | } | |
| 514 | 540 | ||
| 515 | 541 | // Diff is the structured difference a commit introduces against its first | |
| @@ -518,9 +544,8 @@ type Diff struct { | |||
| 518 | 544 | // Files are ordered as Git reports them (by path). Files past | |
| 519 | 545 | // DiffLimits.MaxFiles (when it is not 0) are left out. | |
| 520 | 546 | Files []FileDiff | |
| 521 | - | // Truncated is true when files were left out or a listed file is truncated. | |
| 522 | - | // Files listed without hunks because they are outside the DiffLimits window | |
| 523 | - | // do not make it true. | |
| 547 | + | // Truncated is true when files past DiffLimits.MaxFiles were left out. It | |
| 548 | + | // says nothing of the listed files: see FileDiff.Omitted. | |
| 524 | 549 | Truncated bool | |
| 525 | 550 | } | |
| 526 | 551 | ||
| @@ -537,33 +562,58 @@ type CommitDetail struct { | |||
| 537 | 562 | Diff Diff | |
| 538 | 563 | } | |
| 539 | 564 | ||
| 540 | - | // DiffLimits bound the size of a Diff. Lines are hunk lines, context included. | |
| 565 | + | // DiffLimits bound the size of a Diff. Line limits count additions plus | |
| 566 | + | // deletions, not context. A file that does not fit is listed without hunks and | |
| 567 | + | // with an Omitted reason. | |
| 541 | 568 | type DiffLimits struct { | |
| 542 | 569 | // MaxFiles is the number of files kept; 0 means no limit on the number of | |
| 543 | 570 | // files (every changed file is listed). | |
| 544 | 571 | MaxFiles int | |
| 545 | - | // MaxFileLines is the most lines one file may have; a file over it is | |
| 546 | - | // truncated. | |
| 572 | + | // MaxFileLines is the most changed lines one file may have; a file over it is | |
| 573 | + | // OmitLarge. 0 means no limit. | |
| 547 | 574 | MaxFileLines int | |
| 548 | - | // MaxTotalLines is the most lines of all the files. A file is truncated | |
| 549 | - | // when it does not fit in what remains; a smaller one after it may still | |
| 550 | - | // fit. | |
| 575 | + | // MaxTotalLines is the most changed lines of all the files. A file is | |
| 576 | + | // OmitBudget when it does not fit in what remains; a smaller one after it | |
| 577 | + | // may still fit. 0 means no limit. | |
| 551 | 578 | MaxTotalLines int | |
| 552 | - | // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds | |
| 553 | - | // the memory of a diff whose lines are very long; files are truncated | |
| 554 | - | // like for MaxTotalLines. | |
| 579 | + | // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds | |
| 580 | + | // the memory of a diff whose lines are very long; a file whose | |
| 581 | + | // text does not fit is OmitUnavailable. | |
| 555 | 582 | MaxTotalBytes int | |
| 556 | 583 | // From and To select the files whose hunks are read: those with an index in | |
| 557 | 584 | // [From, To), counted from 0 in the order of Diff.Files. The other files | |
| 558 | 585 | // are listed (paths, status, modes, ids, Binary, Additions, Deletions, | |
| 559 | - | // Similarity) without hunks; they are not Truncated and use none of | |
| 586 | + | // Similarity) without hunks; they are OmitNone and use none of | |
| 560 | 587 | // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values | |
| 561 | 588 | // select every file. MaxFileLines applies to each file of the window. | |
| 562 | 589 | From, To int | |
| 590 | + | // MaxAloneLines is the most changed lines a file may have to be read at all; | |
| 591 | + | // a file over it is OmitUnavailable (it cannot be shown even alone), unlike | |
| 592 | + | // OmitLarge, which a read of that file alone can load. 0 means no limit. | |
| 593 | + | MaxAloneLines int | |
| 594 | + | // SkipGenerated omits the files of the window that IsGeneratedPath | |
| 595 | + | // recognizes (OmitGenerated), without reading their hunks. | |
| 596 | + | SkipGenerated bool | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // MaxOneFileLines is the most changed lines of a file read alone on the web | |
| 600 | + | // (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines | |
| 601 | + | // file within the byte guard cannot produce millions of rows. | |
| 602 | + | const MaxOneFileLines = 50000 | |
| 603 | + | ||
| 604 | + | // OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap; | |
| 605 | + | // MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0 | |
| 606 | + | // would mean the whole diff, so a negative i reads no file at all. | |
| 607 | + | func OneFileDiffLimits(i int) DiffLimits { | |
| 608 | + | if i < 0 { | |
| 609 | + | return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes} | |
| 610 | + | } | |
| 611 | + | return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes} | |
| 563 | 612 | } | |
| 564 | 613 | ||
| 565 | 614 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | |
| 566 | - | // lines per file, 20,000 lines in total, plus 8 MiB of text. | |
| 615 | + | // changed lines per file, 20,000 changed lines in total, plus 8 MiB of text | |
| 616 | + | // including context. Generated files are not skipped. | |
| 567 | 617 | func DefaultDiffLimits() DiffLimits { | |
| 568 | 618 | return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20} | |
| 569 | 619 | } | |
| @@ -643,3 +693,18 @@ func (t *Tree) BoundTree() { | |||
| 643 | 693 | t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true | |
| 644 | 694 | } | |
| 645 | 695 | } | |
| 696 | + | ||
| 697 | + | // CommitPageFiles is how many files of a commit get their hunks read with its | |
| 698 | + | // page; the files past it are collapsed (see Diff.CollapseFrom) and load on | |
| 699 | + | // their own. | |
| 700 | + | const CommitPageFiles = 300 | |
| 701 | + | ||
| 702 | + | // CollapseFrom marks the files from index n on that have lines to show (not | |
| 703 | + | // binary, not already omitted, with changes) as OmitBudget: listed, not read. | |
| 704 | + | func (d *Diff) CollapseFrom(n int) { | |
| 705 | + | for i := max(n, 0); i < len(d.Files); i++ { | |
| 706 | + | if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 { | |
| 707 | + | f.Omitted = OmitBudget | |
| 708 | + | } | |
| 709 | + | } | |
| 710 | + | } | |
| @@ -505,11 +505,37 @@ type FileDiff struct { | |||||
| 505 | Additions, Deletions int | 505 | Additions, Deletions int | ||
| 506 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | 506 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | ||
| 507 | Similarity int | 507 | Similarity int | ||
| 508 | - | // Truncated means the file exceeded a diff limit: Hunks is empty although | 508 | + | // Omitted tells why the hunks were not kept: Hunks is empty although the |
| 509 | - | // the file changed. Additions and Deletions stay accurate. A file outside | 509 | + | // file changed; Additions and Deletions stay accurate. A file outside the |
| 510 | - | // the DiffLimits window has no Hunks and is not Truncated. | 510 | + | // window is OmitNone with no Hunks. |
| 511 | - | Truncated bool | 511 | + | Omitted FileOmission |
| 512 | - | Hunks []Hunk | 512 | + | Hunks []Hunk |
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | // CommentPath is the path a comment designates for f, as Diff.HasLine reads it: | |||
| 516 | + | // the old path of a deleted file, the new path otherwise. | |||
| 517 | + | func (f FileDiff) CommentPath() string { | |||
| 518 | + | if f.Status == FileDeleted { | |||
| 519 | + | return f.OldPath | |||
| 520 | + | } | |||
| 521 | + | return f.NewPath | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | // FileOmission tells why a changed file has no hunks. | |||
| 525 | + | type FileOmission uint8 | |||
| 526 | + | ||||
| 527 | + | // File omissions. | |||
| 528 | + | const ( | |||
| 529 | + | OmitNone FileOmission = iota | |||
| 530 | + | OmitGenerated // SkipGenerated and IsGeneratedPath: hunks not read | |||
| 531 | + | OmitLarge // changed lines > MaxFileLines | |||
| 532 | + | OmitBudget // did not fit in the remaining MaxTotalLines | |||
| 533 | + | OmitUnavailable // retained text would exceed MaxTotalBytes | |||
| 534 | + | ) | |||
| 535 | + | ||||
| 536 | + | // Collapsed reports a file the web view folds and can load on its own. | |||
| 537 | + | func (o FileOmission) Collapsed() bool { | |||
| 538 | + | return o == OmitGenerated || o == OmitLarge || o == OmitBudget | |||
| 513 | } | 539 | } | ||
| 514 | 540 | ||||
| 515 | // Diff is the structured difference a commit introduces against its first | 541 | // Diff is the structured difference a commit introduces against its first | ||
| @@ -518,9 +544,8 @@ type Diff struct { | |||||
| 518 | // Files are ordered as Git reports them (by path). Files past | 544 | // Files are ordered as Git reports them (by path). Files past | ||
| 519 | // DiffLimits.MaxFiles (when it is not 0) are left out. | 545 | // DiffLimits.MaxFiles (when it is not 0) are left out. | ||
| 520 | Files []FileDiff | 546 | Files []FileDiff | ||
| 521 | - | // Truncated is true when files were left out or a listed file is truncated. | 547 | + | // Truncated is true when files past DiffLimits.MaxFiles were left out. It |
| 522 | - | // Files listed without hunks because they are outside the DiffLimits window | 548 | + | // says nothing of the listed files: see FileDiff.Omitted. |
| 523 | - | // do not make it true. | |||
| 524 | Truncated bool | 549 | Truncated bool | ||
| 525 | } | 550 | } | ||
| 526 | 551 | ||||
| @@ -537,33 +562,58 @@ type CommitDetail struct { | |||||
| 537 | Diff Diff | 562 | Diff Diff | ||
| 538 | } | 563 | } | ||
| 539 | 564 | ||||
| 540 | - | // DiffLimits bound the size of a Diff. Lines are hunk lines, context included. | 565 | + | // DiffLimits bound the size of a Diff. Line limits count additions plus |
| 566 | + | // deletions, not context. A file that does not fit is listed without hunks and | |||
| 567 | + | // with an Omitted reason. | |||
| 541 | type DiffLimits struct { | 568 | type DiffLimits struct { | ||
| 542 | // MaxFiles is the number of files kept; 0 means no limit on the number of | 569 | // MaxFiles is the number of files kept; 0 means no limit on the number of | ||
| 543 | // files (every changed file is listed). | 570 | // files (every changed file is listed). | ||
| 544 | MaxFiles int | 571 | MaxFiles int | ||
| 545 | - | // MaxFileLines is the most lines one file may have; a file over it is | 572 | + | // MaxFileLines is the most changed lines one file may have; a file over it is |
| 546 | - | // truncated. | 573 | + | // OmitLarge. 0 means no limit. |
| 547 | MaxFileLines int | 574 | MaxFileLines int | ||
| 548 | - | // MaxTotalLines is the most lines of all the files. A file is truncated | 575 | + | // MaxTotalLines is the most changed lines of all the files. A file is |
| 549 | - | // when it does not fit in what remains; a smaller one after it may still | 576 | + | // OmitBudget when it does not fit in what remains; a smaller one after it |
| 550 | - | // fit. | 577 | + | // may still fit. 0 means no limit. |
| 551 | MaxTotalLines int | 578 | MaxTotalLines int | ||
| 552 | - | // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds | 579 | + | // MaxTotalBytes is the most text, in bytes, of all the lines, including context. It bounds |
| 553 | - | // the memory of a diff whose lines are very long; files are truncated | 580 | + | // the memory of a diff whose lines are very long; a file whose |
| 554 | - | // like for MaxTotalLines. | 581 | + | // text does not fit is OmitUnavailable. |
| 555 | MaxTotalBytes int | 582 | MaxTotalBytes int | ||
| 556 | // From and To select the files whose hunks are read: those with an index in | 583 | // From and To select the files whose hunks are read: those with an index in | ||
| 557 | // [From, To), counted from 0 in the order of Diff.Files. The other files | 584 | // [From, To), counted from 0 in the order of Diff.Files. The other files | ||
| 558 | // are listed (paths, status, modes, ids, Binary, Additions, Deletions, | 585 | // are listed (paths, status, modes, ids, Binary, Additions, Deletions, | ||
| 559 | - | // Similarity) without hunks; they are not Truncated and use none of | 586 | + | // Similarity) without hunks; they are OmitNone and use none of |
| 560 | // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values | 587 | // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values | ||
| 561 | // select every file. MaxFileLines applies to each file of the window. | 588 | // select every file. MaxFileLines applies to each file of the window. | ||
| 562 | From, To int | 589 | From, To int | ||
| 590 | + | // MaxAloneLines is the most changed lines a file may have to be read at all; | |||
| 591 | + | // a file over it is OmitUnavailable (it cannot be shown even alone), unlike | |||
| 592 | + | // OmitLarge, which a read of that file alone can load. 0 means no limit. | |||
| 593 | + | MaxAloneLines int | |||
| 594 | + | // SkipGenerated omits the files of the window that IsGeneratedPath | |||
| 595 | + | // recognizes (OmitGenerated), without reading their hunks. | |||
| 596 | + | SkipGenerated bool | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // MaxOneFileLines is the most changed lines of a file read alone on the web | |||
| 600 | + | // (OneFileDiffLimits). A file over it is OmitUnavailable, so a short-lines | |||
| 601 | + | // file within the byte guard cannot produce millions of rows. | |||
| 602 | + | const MaxOneFileLines = 50000 | |||
| 603 | + | ||||
| 604 | + | // OneFileDiffLimits reads the hunks of file i only, with no MaxFileLines cap; | |||
| 605 | + | // MaxAloneLines (MaxOneFileLines) and MaxTotalBytes are the guards. i must be at least 0: From -1 with To 0 | |||
| 606 | + | // would mean the whole diff, so a negative i reads no file at all. | |||
| 607 | + | func OneFileDiffLimits(i int) DiffLimits { | |||
| 608 | + | if i < 0 { | |||
| 609 | + | return DiffLimits{From: 1, To: 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes} | |||
| 610 | + | } | |||
| 611 | + | return DiffLimits{From: i, To: i + 1, MaxAloneLines: MaxOneFileLines, MaxTotalBytes: DefaultDiffLimits().MaxTotalBytes} | |||
| 563 | } | 612 | } | ||
| 564 | 613 | ||||
| 565 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | 614 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | ||
| 566 | - | // lines per file, 20,000 lines in total, plus 8 MiB of text. | 615 | + | // changed lines per file, 20,000 changed lines in total, plus 8 MiB of text |
| 616 | + | // including context. Generated files are not skipped. | |||
| 567 | func DefaultDiffLimits() DiffLimits { | 617 | func DefaultDiffLimits() DiffLimits { | ||
| 568 | return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20} | 618 | return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20} | ||
| 569 | } | 619 | } | ||
| @@ -643,3 +693,18 @@ func (t *Tree) BoundTree() { | |||||
| 643 | t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true | 693 | t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true | ||
| 644 | } | 694 | } | ||
| 645 | } | 695 | } | ||
| 696 | + | ||||
| 697 | + | // CommitPageFiles is how many files of a commit get their hunks read with its | |||
| 698 | + | // page; the files past it are collapsed (see Diff.CollapseFrom) and load on | |||
| 699 | + | // their own. | |||
| 700 | + | const CommitPageFiles = 300 | |||
| 701 | + | ||||
| 702 | + | // CollapseFrom marks the files from index n on that have lines to show (not | |||
| 703 | + | // binary, not already omitted, with changes) as OmitBudget: listed, not read. | |||
| 704 | + | func (d *Diff) CollapseFrom(n int) { | |||
| 705 | + | for i := max(n, 0); i < len(d.Files); i++ { | |||
| 706 | + | if f := &d.Files[i]; !f.Binary && f.Omitted == OmitNone && f.Additions+f.Deletions > 0 { | |||
| 707 | + | f.Omitted = OmitBudget | |||
| 708 | + | } | |||
| 709 | + | } | |||
| 710 | + | } | |||
M internal/domain/mergerequest_test.go +1 -1
| @@ -77,7 +77,7 @@ func hunkDiff() domain.Diff { | |||
| 77 | 77 | {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{ | |
| 78 | 78 | Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}}, | |
| 79 | 79 | }}}, | |
| 80 | - | {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true}, | |
| 80 | + | {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge}, | |
| 81 | 81 | {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{ | |
| 82 | 82 | Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}}, | |
| 83 | 83 | }}}, | |
| @@ -77,7 +77,7 @@ func hunkDiff() domain.Diff { | |||||
| 77 | {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{ | 77 | {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{ | ||
| 78 | Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}}, | 78 | Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}}, | ||
| 79 | }}}, | 79 | }}}, | ||
| 80 | - | {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true}, | 80 | + | {NewPath: "big.txt", Status: domain.FileAdded, Omitted: domain.OmitLarge}, |
| 81 | {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{ | 81 | {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{ | ||
| 82 | Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}}, | 82 | Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}}, | ||
| 83 | }}}, | 83 | }}}, | ||
M internal/ports/dto.go +33 -3
| @@ -298,8 +298,10 @@ type GetMergeRequestOptions struct { | |||
| 298 | 298 | SinceLastRead bool | |
| 299 | 299 | // Files, when set, lists every changed file, without the file-count | |
| 300 | 300 | // limit, and reads the hunks of the files in the window only, each within | |
| 301 | - | // the per-file and the total limits of domain.DefaultDiffLimits. Unset, | |
| 302 | - | // the diff is bounded by domain.DefaultDiffLimits (the API contract). | |
| 301 | + | // the per-file and the total limits of domain.DefaultDiffLimits; generated | |
| 302 | + | // files are listed collapsed (domain.OmitGenerated). With Full, the single | |
| 303 | + | // file is read without the line limits. Unset, the diff is bounded by | |
| 304 | + | // domain.DefaultDiffLimits and generated files are read (the API contract). | |
| 303 | 305 | Files *FileWindow | |
| 304 | 306 | // Base and Head, when not zero, are the diff the caller shows: Get | |
| 305 | 307 | // returns domain.ErrConflict when the request's diff base or head differ | |
| @@ -307,10 +309,38 @@ type GetMergeRequestOptions struct { | |||
| 307 | 309 | Base, Head domain.ObjectID | |
| 308 | 310 | // DiffOnly skips the commits of the request (Commits stays empty). | |
| 309 | 311 | DiffOnly bool | |
| 312 | + | // Load, when set with Files, is the index of a collapsed file Get reads in | |
| 313 | + | // full and splices into the diff in place of its collapsed entry (the page | |
| 314 | + | // of a client without scripts). Ignored when the index is out of range, | |
| 315 | + | // the file is not collapsed, or there is no diff. | |
| 316 | + | Load *int | |
| 310 | 317 | } | |
| 311 | 318 | ||
| 312 | 319 | // FileWindow is the range [From, To) of files whose hunks are read. | |
| 313 | - | type FileWindow struct{ From, To int } | |
| 320 | + | // | |
| 321 | + | // Full asks for the one file From read in full: no line limit applies, only | |
| 322 | + | // the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a | |
| 323 | + | // window with Full and any other To is domain.ErrInvalid. | |
| 324 | + | type FileWindow struct { | |
| 325 | + | From, To int | |
| 326 | + | Full bool | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // GetCommitOptions say which diff BrowseService.GetCommit reads. | |
| 330 | + | type GetCommitOptions struct { | |
| 331 | + | // Files, when set, reads the hunks of a window of the files, as | |
| 332 | + | // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is | |
| 333 | + | // bounded by domain.DefaultDiffLimits. | |
| 334 | + | Files *FileWindow | |
| 335 | + | // Page, without Files, reads the diff for a page: every file listed, the | |
| 336 | + | // hunks of the first domain.CommitPageFiles files read, generated files | |
| 337 | + | // collapsed, and the files past the window collapsed too | |
| 338 | + | // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid. | |
| 339 | + | Page bool | |
| 340 | + | // Load is the index of a collapsed file read in full and spliced into the | |
| 341 | + | // diff, as GetMergeRequestOptions.Load. It requires Page. | |
| 342 | + | Load *int | |
| 343 | + | } | |
| 314 | 344 | ||
| 315 | 345 | // AddCommentParams describes the comment to add. | |
| 316 | 346 | type AddCommentParams struct { | |
| @@ -298,8 +298,10 @@ type GetMergeRequestOptions struct { | |||||
| 298 | SinceLastRead bool | 298 | SinceLastRead bool | ||
| 299 | // Files, when set, lists every changed file, without the file-count | 299 | // Files, when set, lists every changed file, without the file-count | ||
| 300 | // limit, and reads the hunks of the files in the window only, each within | 300 | // limit, and reads the hunks of the files in the window only, each within | ||
| 301 | - | // the per-file and the total limits of domain.DefaultDiffLimits. Unset, | 301 | + | // the per-file and the total limits of domain.DefaultDiffLimits; generated |
| 302 | - | // the diff is bounded by domain.DefaultDiffLimits (the API contract). | 302 | + | // files are listed collapsed (domain.OmitGenerated). With Full, the single |
| 303 | + | // file is read without the line limits. Unset, the diff is bounded by | |||
| 304 | + | // domain.DefaultDiffLimits and generated files are read (the API contract). | |||
| 303 | Files *FileWindow | 305 | Files *FileWindow | ||
| 304 | // Base and Head, when not zero, are the diff the caller shows: Get | 306 | // Base and Head, when not zero, are the diff the caller shows: Get | ||
| 305 | // returns domain.ErrConflict when the request's diff base or head differ | 307 | // returns domain.ErrConflict when the request's diff base or head differ | ||
| @@ -307,10 +309,38 @@ type GetMergeRequestOptions struct { | |||||
| 307 | Base, Head domain.ObjectID | 309 | Base, Head domain.ObjectID | ||
| 308 | // DiffOnly skips the commits of the request (Commits stays empty). | 310 | // DiffOnly skips the commits of the request (Commits stays empty). | ||
| 309 | DiffOnly bool | 311 | DiffOnly bool | ||
| 312 | + | // Load, when set with Files, is the index of a collapsed file Get reads in | |||
| 313 | + | // full and splices into the diff in place of its collapsed entry (the page | |||
| 314 | + | // of a client without scripts). Ignored when the index is out of range, | |||
| 315 | + | // the file is not collapsed, or there is no diff. | |||
| 316 | + | Load *int | |||
| 310 | } | 317 | } | ||
| 311 | 318 | ||||
| 312 | // FileWindow is the range [From, To) of files whose hunks are read. | 319 | // FileWindow is the range [From, To) of files whose hunks are read. | ||
| 313 | - | type FileWindow struct{ From, To int } | 320 | + | // |
| 321 | + | // Full asks for the one file From read in full: no line limit applies, only | |||
| 322 | + | // the byte guard (domain.OneFileDiffLimits). It requires To == From+1; a | |||
| 323 | + | // window with Full and any other To is domain.ErrInvalid. | |||
| 324 | + | type FileWindow struct { | |||
| 325 | + | From, To int | |||
| 326 | + | Full bool | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // GetCommitOptions say which diff BrowseService.GetCommit reads. | |||
| 330 | + | type GetCommitOptions struct { | |||
| 331 | + | // Files, when set, reads the hunks of a window of the files, as | |||
| 332 | + | // GetMergeRequestOptions.Files does. Unset (the zero value), the diff is | |||
| 333 | + | // bounded by domain.DefaultDiffLimits. | |||
| 334 | + | Files *FileWindow | |||
| 335 | + | // Page, without Files, reads the diff for a page: every file listed, the | |||
| 336 | + | // hunks of the first domain.CommitPageFiles files read, generated files | |||
| 337 | + | // collapsed, and the files past the window collapsed too | |||
| 338 | + | // (domain.OmitBudget). Combined with Files it is domain.ErrInvalid. | |||
| 339 | + | Page bool | |||
| 340 | + | // Load is the index of a collapsed file read in full and spliced into the | |||
| 341 | + | // diff, as GetMergeRequestOptions.Load. It requires Page. | |||
| 342 | + | Load *int | |||
| 343 | + | } | |||
| 314 | 344 | ||||
| 315 | // AddCommentParams describes the comment to add. | 345 | // AddCommentParams describes the comment to add. | ||
| 316 | type AddCommentParams struct { | 346 | type AddCommentParams struct { | ||
M internal/ports/mocks/BrowseService.go +18 -12
| @@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter { | |||
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | // GetCommit provides a mock function for the type BrowseService | |
| 54 | - | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | |
| 55 | - | ret := _mock.Called(ctx, p, repo, rev) | |
| 54 | + | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) { | |
| 55 | + | ret := _mock.Called(ctx, p, repo, rev, opts) | |
| 56 | 56 | ||
| 57 | 57 | if len(ret) == 0 { | |
| 58 | 58 | panic("no return value specified for GetCommit") | |
| @@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r | |||
| 60 | 60 | ||
| 61 | 61 | var r0 domain.CommitDetail | |
| 62 | 62 | var r1 error | |
| 63 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok { | |
| 64 | - | return returnFunc(ctx, p, repo, rev) | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok { | |
| 64 | + | return returnFunc(ctx, p, repo, rev, opts) | |
| 65 | 65 | } | |
| 66 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok { | |
| 67 | - | r0 = returnFunc(ctx, p, repo, rev) | |
| 66 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok { | |
| 67 | + | r0 = returnFunc(ctx, p, repo, rev, opts) | |
| 68 | 68 | } else { | |
| 69 | 69 | r0 = ret.Get(0).(domain.CommitDetail) | |
| 70 | 70 | } | |
| 71 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |
| 72 | - | r1 = returnFunc(ctx, p, repo, rev) | |
| 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok { | |
| 72 | + | r1 = returnFunc(ctx, p, repo, rev, opts) | |
| 73 | 73 | } else { | |
| 74 | 74 | r1 = ret.Error(1) | |
| 75 | 75 | } | |
| @@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct { | |||
| 86 | 86 | // - p domain.Principal | |
| 87 | 87 | // - repo domain.RepoName | |
| 88 | 88 | // - rev domain.Revision | |
| 89 | - | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call { | |
| 90 | - | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)} | |
| 89 | + | // - opts ports.GetCommitOptions | |
| 90 | + | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call { | |
| 91 | + | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)} | |
| 91 | 92 | } | |
| 92 | 93 | ||
| 93 | - | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call { | |
| 94 | + | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call { | |
| 94 | 95 | _c.Call.Run(func(args mock.Arguments) { | |
| 95 | 96 | var arg0 context.Context | |
| 96 | 97 | if args[0] != nil { | |
| @@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma | |||
| 108 | 109 | if args[3] != nil { | |
| 109 | 110 | arg3 = args[3].(domain.Revision) | |
| 110 | 111 | } | |
| 112 | + | var arg4 ports.GetCommitOptions | |
| 113 | + | if args[4] != nil { | |
| 114 | + | arg4 = args[4].(ports.GetCommitOptions) | |
| 115 | + | } | |
| 111 | 116 | run( | |
| 112 | 117 | arg0, | |
| 113 | 118 | arg1, | |
| 114 | 119 | arg2, | |
| 115 | 120 | arg3, | |
| 121 | + | arg4, | |
| 116 | 122 | ) | |
| 117 | 123 | }) | |
| 118 | 124 | return _c | |
| @@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail, | |||
| 123 | 129 | return _c | |
| 124 | 130 | } | |
| 125 | 131 | ||
| 126 | - | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { | |
| 132 | + | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { | |
| 127 | 133 | _c.Call.Return(run) | |
| 128 | 134 | return _c | |
| 129 | 135 | } | |
| @@ -51,8 +51,8 @@ func (_m *BrowseService) EXPECT() *BrowseService_Expecter { | |||||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | // GetCommit provides a mock function for the type BrowseService | 53 | // GetCommit provides a mock function for the type BrowseService | ||
| 54 | - | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | 54 | + | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) { |
| 55 | - | ret := _mock.Called(ctx, p, repo, rev) | 55 | + | ret := _mock.Called(ctx, p, repo, rev, opts) |
| 56 | 56 | ||||
| 57 | if len(ret) == 0 { | 57 | if len(ret) == 0 { | ||
| 58 | panic("no return value specified for GetCommit") | 58 | panic("no return value specified for GetCommit") | ||
| @@ -60,16 +60,16 @@ func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, r | |||||
| 60 | 60 | ||||
| 61 | var r0 domain.CommitDetail | 61 | var r0 domain.CommitDetail | ||
| 62 | var r1 error | 62 | var r1 error | ||
| 63 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok { | 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) (domain.CommitDetail, error)); ok { |
| 64 | - | return returnFunc(ctx, p, repo, rev) | 64 | + | return returnFunc(ctx, p, repo, rev, opts) |
| 65 | } | 65 | } | ||
| 66 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok { | 66 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) domain.CommitDetail); ok { |
| 67 | - | r0 = returnFunc(ctx, p, repo, rev) | 67 | + | r0 = returnFunc(ctx, p, repo, rev, opts) |
| 68 | } else { | 68 | } else { | ||
| 69 | r0 = ret.Get(0).(domain.CommitDetail) | 69 | r0 = ret.Get(0).(domain.CommitDetail) | ||
| 70 | } | 70 | } | ||
| 71 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | 71 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, ports.GetCommitOptions) error); ok { |
| 72 | - | r1 = returnFunc(ctx, p, repo, rev) | 72 | + | r1 = returnFunc(ctx, p, repo, rev, opts) |
| 73 | } else { | 73 | } else { | ||
| 74 | r1 = ret.Error(1) | 74 | r1 = ret.Error(1) | ||
| 75 | } | 75 | } | ||
| @@ -86,11 +86,12 @@ type BrowseService_GetCommit_Call struct { | |||||
| 86 | // - p domain.Principal | 86 | // - p domain.Principal | ||
| 87 | // - repo domain.RepoName | 87 | // - repo domain.RepoName | ||
| 88 | // - rev domain.Revision | 88 | // - rev domain.Revision | ||
| 89 | - | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call { | 89 | + | // - opts ports.GetCommitOptions |
| 90 | - | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)} | 90 | + | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any, opts any) *BrowseService_GetCommit_Call { |
| 91 | + | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev, opts)} | |||
| 91 | } | 92 | } | ||
| 92 | 93 | ||||
| 93 | - | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_Call { | 94 | + | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions)) *BrowseService_GetCommit_Call { |
| 94 | _c.Call.Run(func(args mock.Arguments) { | 95 | _c.Call.Run(func(args mock.Arguments) { | ||
| 95 | var arg0 context.Context | 96 | var arg0 context.Context | ||
| 96 | if args[0] != nil { | 97 | if args[0] != nil { | ||
| @@ -108,11 +109,16 @@ func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p doma | |||||
| 108 | if args[3] != nil { | 109 | if args[3] != nil { | ||
| 109 | arg3 = args[3].(domain.Revision) | 110 | arg3 = args[3].(domain.Revision) | ||
| 110 | } | 111 | } | ||
| 112 | + | var arg4 ports.GetCommitOptions | |||
| 113 | + | if args[4] != nil { | |||
| 114 | + | arg4 = args[4].(ports.GetCommitOptions) | |||
| 115 | + | } | |||
| 111 | run( | 116 | run( | ||
| 112 | arg0, | 117 | arg0, | ||
| 113 | arg1, | 118 | arg1, | ||
| 114 | arg2, | 119 | arg2, | ||
| 115 | arg3, | 120 | arg3, | ||
| 121 | + | arg4, | |||
| 116 | ) | 122 | ) | ||
| 117 | }) | 123 | }) | ||
| 118 | return _c | 124 | return _c | ||
| @@ -123,7 +129,7 @@ func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail, | |||||
| 123 | return _c | 129 | return _c | ||
| 124 | } | 130 | } | ||
| 125 | 131 | ||||
| 126 | - | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { | 132 | + | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { |
| 127 | _c.Call.Return(run) | 133 | _c.Call.Return(run) | ||
| 128 | return _c | 134 | return _c | ||
| 129 | } | 135 | } | ||
M internal/ports/services.go +1 -1
| @@ -95,7 +95,7 @@ type BrowseService interface { | |||
| 95 | 95 | ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) | |
| 96 | 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | |
| 97 | 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | |
| 98 | - | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | |
| 98 | + | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error) | |
| 99 | 99 | // RepoStats counts the commits of the branch whose head is given, and | |
| 100 | 100 | // names the latest tag. | |
| 101 | 101 | RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error) | |
| @@ -95,7 +95,7 @@ type BrowseService interface { | |||||
| 95 | ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) | 95 | ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) | ||
| 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | 96 | OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) | ||
| 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | 97 | ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error) | ||
| 98 | - | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) | 98 | + | GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts GetCommitOptions) (domain.CommitDetail, error) |
| 99 | // RepoStats counts the commits of the branch whose head is given, and | 99 | // RepoStats counts the commits of the branch whose head is given, and | ||
| 100 | // names the latest tag. | 100 | // names the latest tag. | ||
| 101 | RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error) | 101 | RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error) | ||
M internal/readertest/conformance.go +2 -0
| @@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) { | |||
| 31 | 31 | name string | |
| 32 | 32 | fn func(*testing.T, Maker) | |
| 33 | 33 | }{ | |
| 34 | + | {"DiffChangedLineLimits", DiffChangedLineLimits}, | |
| 35 | + | {"DiffOmissions", DiffOmissions}, | |
| 34 | 36 | {"ListRefs", testListRefs}, | |
| 35 | 37 | {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing}, | |
| 36 | 38 | {"DetachedHead", testDetachedHead}, | |
| @@ -31,6 +31,8 @@ func Conformance(t *testing.T, mk Maker) { | |||||
| 31 | name string | 31 | name string | ||
| 32 | fn func(*testing.T, Maker) | 32 | fn func(*testing.T, Maker) | ||
| 33 | }{ | 33 | }{ | ||
| 34 | + | {"DiffChangedLineLimits", DiffChangedLineLimits}, | |||
| 35 | + | {"DiffOmissions", DiffOmissions}, | |||
| 34 | {"ListRefs", testListRefs}, | 36 | {"ListRefs", testListRefs}, | ||
| 35 | {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing}, | 37 | {"ListRefsEmptyAndMissing", testListRefsEmptyAndMissing}, | ||
| 36 | {"DetachedHead", testDetachedHead}, | 38 | {"DetachedHead", testDetachedHead}, | ||
A internal/readertest/diff.go +185 -0
| @@ -0,0 +1,185 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package readertest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // DiffChangedLineLimits checks that context is retained without consuming the | |
| 18 | + | // changed-line limits, but still consumes the byte budget. | |
| 19 | + | func DiffChangedLineLimits(t *testing.T, mk Maker) { | |
| 20 | + | g, data := mk(t) | |
| 21 | + | f := NewFixture(t, data, "diff-context-budget") | |
| 22 | + | var before, after strings.Builder | |
| 23 | + | const hunks = 96 | |
| 24 | + | for i := range hunks * 10 { | |
| 25 | + | line := fmt.Sprintf("context %04d", i) | |
| 26 | + | fmt.Fprintln(&before, line) | |
| 27 | + | if i%10 == 4 { | |
| 28 | + | line = fmt.Sprintf("changed %04d", i) | |
| 29 | + | } | |
| 30 | + | fmt.Fprintln(&after, line) | |
| 31 | + | } | |
| 32 | + | f.Write("many", before.String()).Commit("before") | |
| 33 | + | f.Write("many", after.String()).Commit("after") | |
| 34 | + | limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20} | |
| 35 | + | d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Len(t, d.Diff.Files, 1) | |
| 38 | + | file := d.Diff.Files[0] | |
| 39 | + | require.Equal(t, domain.OmitNone, file.Omitted) | |
| 40 | + | assert.False(t, d.Diff.Truncated) | |
| 41 | + | require.Len(t, file.Hunks, hunks) | |
| 42 | + | var changed, patchLines, textBytes, changedBytes int | |
| 43 | + | for _, h := range file.Hunks { | |
| 44 | + | for _, l := range h.Lines { | |
| 45 | + | patchLines++ | |
| 46 | + | textBytes += len(l.Text) | |
| 47 | + | if l.Kind != domain.LineContext { | |
| 48 | + | changed++ | |
| 49 | + | changedBytes += len(l.Text) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | assert.Equal(t, hunks*2, changed) | |
| 54 | + | // Every hunk is read with no line limit too. | |
| 55 | + | free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20}) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | require.Len(t, free.Diff.Files[0].Hunks, hunks) | |
| 58 | + | assert.Greater(t, patchLines, limits.MaxFileLines) | |
| 59 | + | assert.Equal(t, hunks, file.Additions) | |
| 60 | + | assert.Equal(t, hunks, file.Deletions) | |
| 61 | + | // The exact byte boundary fits; one fewer byte must discard the file even | |
| 62 | + | // though every changed line's text alone easily fits. | |
| 63 | + | limits.MaxTotalBytes = textBytes | |
| 64 | + | d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |
| 67 | + | limits.MaxTotalBytes-- | |
| 68 | + | require.Greater(t, limits.MaxTotalBytes, changedBytes) | |
| 69 | + | d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated") | |
| 72 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted) | |
| 73 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // DiffOmissions checks the omission reasons: generated files skipped without | |
| 77 | + | // reading their hunks, one file read without a line cap, and the MaxFiles | |
| 78 | + | // overflow being the only thing that sets Diff.Truncated. | |
| 79 | + | func DiffOmissions(t *testing.T, mk Maker) { | |
| 80 | + | g, data := mk(t) | |
| 81 | + | ctx := context.Background() | |
| 82 | + | f := NewFixture(t, data, "diff-omissions") | |
| 83 | + | f.Write("base.txt", "x\n").Commit("base") | |
| 84 | + | f.Write("a.txt", "small\n") | |
| 85 | + | // The generated file (515 bytes) would overflow the 300-byte budget below | |
| 86 | + | // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks | |
| 87 | + | // are not read. | |
| 88 | + | f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5)) | |
| 89 | + | f.Lines("z-big.txt", "line", 2500) | |
| 90 | + | f.Commit("change") | |
| 91 | + | ||
| 92 | + | limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true} | |
| 93 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | require.Len(t, d.Diff.Files, 3) | |
| 96 | + | byPath := map[string]domain.FileDiff{} | |
| 97 | + | for _, file := range d.Diff.Files { | |
| 98 | + | byPath[file.NewPath] = file | |
| 99 | + | } | |
| 100 | + | assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted) | |
| 101 | + | assert.Len(t, byPath["a.txt"].Hunks, 1) | |
| 102 | + | assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted) | |
| 103 | + | assert.Empty(t, byPath["go.sum"].Hunks) | |
| 104 | + | assert.Equal(t, 5, byPath["go.sum"].Additions) | |
| 105 | + | assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted) | |
| 106 | + | assert.Empty(t, byPath["z-big.txt"].Hunks) | |
| 107 | + | assert.False(t, d.Diff.Truncated) | |
| 108 | + | ||
| 109 | + | // Read, go.sum overflows the bytes that a.txt, read before it, already | |
| 110 | + | // spent: it may fit on its own, so it is OmitBudget, loadable alone. | |
| 111 | + | limits.SkipGenerated = false | |
| 112 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | sum := -1 | |
| 115 | + | for i, file := range d.Diff.Files { | |
| 116 | + | if file.NewPath == "go.sum" { | |
| 117 | + | sum = i | |
| 118 | + | assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes") | |
| 119 | + | assert.Empty(t, file.Hunks) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | require.GreaterOrEqual(t, sum, 0) | |
| 123 | + | // Alone, it is still over the bytes: OmitUnavailable, a dead end. | |
| 124 | + | alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300} | |
| 125 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own") | |
| 128 | + | assert.Empty(t, d.Diff.Files[sum].Hunks) | |
| 129 | + | ||
| 130 | + | // OneFileDiffLimits reads a file over 2,000 changed lines, and only it. | |
| 131 | + | idx := -1 | |
| 132 | + | for i, file := range d.Diff.Files { | |
| 133 | + | if file.NewPath == "z-big.txt" { | |
| 134 | + | idx = i | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | require.GreaterOrEqual(t, idx, 0) | |
| 138 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx)) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | for i, file := range d.Diff.Files { | |
| 141 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 142 | + | if i == idx { | |
| 143 | + | require.Len(t, file.Hunks, 1) | |
| 144 | + | assert.Len(t, file.Hunks[0].Lines, 2500) | |
| 145 | + | } else { | |
| 146 | + | assert.Empty(t, file.Hunks) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | assert.False(t, d.Diff.Truncated) | |
| 150 | + | ||
| 151 | + | // MaxFiles alone sets Diff.Truncated. | |
| 152 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20}) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | assert.Len(t, d.Diff.Files, 2) | |
| 155 | + | assert.True(t, d.Diff.Truncated) | |
| 156 | + | for _, file := range d.Diff.Files { | |
| 157 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // A one-file read is capped at MaxOneFileLines changed lines: the cap | |
| 161 | + | // itself loads, one more line is OmitUnavailable (it cannot be loaded). | |
| 162 | + | f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps") | |
| 163 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1}) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | capIdx, overIdx := -1, -1 | |
| 166 | + | for i, file := range d.Diff.Files { | |
| 167 | + | switch file.NewPath { | |
| 168 | + | case "cap.txt": | |
| 169 | + | capIdx = i | |
| 170 | + | case "over.txt": | |
| 171 | + | overIdx = i | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | require.GreaterOrEqual(t, capIdx, 0) | |
| 175 | + | require.GreaterOrEqual(t, overIdx, 0) | |
| 176 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx)) | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted) | |
| 179 | + | require.Len(t, d.Diff.Files[capIdx].Hunks, 1) | |
| 180 | + | assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines) | |
| 181 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx)) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted) | |
| 184 | + | assert.Empty(t, d.Diff.Files[overIdx].Hunks) | |
| 185 | + | } | |
| @@ -0,0 +1,185 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package readertest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // DiffChangedLineLimits checks that context is retained without consuming the | |||
| 18 | + | // changed-line limits, but still consumes the byte budget. | |||
| 19 | + | func DiffChangedLineLimits(t *testing.T, mk Maker) { | |||
| 20 | + | g, data := mk(t) | |||
| 21 | + | f := NewFixture(t, data, "diff-context-budget") | |||
| 22 | + | var before, after strings.Builder | |||
| 23 | + | const hunks = 96 | |||
| 24 | + | for i := range hunks * 10 { | |||
| 25 | + | line := fmt.Sprintf("context %04d", i) | |||
| 26 | + | fmt.Fprintln(&before, line) | |||
| 27 | + | if i%10 == 4 { | |||
| 28 | + | line = fmt.Sprintf("changed %04d", i) | |||
| 29 | + | } | |||
| 30 | + | fmt.Fprintln(&after, line) | |||
| 31 | + | } | |||
| 32 | + | f.Write("many", before.String()).Commit("before") | |||
| 33 | + | f.Write("many", after.String()).Commit("after") | |||
| 34 | + | limits := domain.DiffLimits{MaxFileLines: hunks * 2, MaxTotalLines: hunks * 2, MaxTotalBytes: 1 << 20} | |||
| 35 | + | d, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Len(t, d.Diff.Files, 1) | |||
| 38 | + | file := d.Diff.Files[0] | |||
| 39 | + | require.Equal(t, domain.OmitNone, file.Omitted) | |||
| 40 | + | assert.False(t, d.Diff.Truncated) | |||
| 41 | + | require.Len(t, file.Hunks, hunks) | |||
| 42 | + | var changed, patchLines, textBytes, changedBytes int | |||
| 43 | + | for _, h := range file.Hunks { | |||
| 44 | + | for _, l := range h.Lines { | |||
| 45 | + | patchLines++ | |||
| 46 | + | textBytes += len(l.Text) | |||
| 47 | + | if l.Kind != domain.LineContext { | |||
| 48 | + | changed++ | |||
| 49 | + | changedBytes += len(l.Text) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | assert.Equal(t, hunks*2, changed) | |||
| 54 | + | // Every hunk is read with no line limit too. | |||
| 55 | + | free, err := g.GetCommit(context.Background(), f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1 << 20}) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | require.Len(t, free.Diff.Files[0].Hunks, hunks) | |||
| 58 | + | assert.Greater(t, patchLines, limits.MaxFileLines) | |||
| 59 | + | assert.Equal(t, hunks, file.Additions) | |||
| 60 | + | assert.Equal(t, hunks, file.Deletions) | |||
| 61 | + | // The exact byte boundary fits; one fewer byte must discard the file even | |||
| 62 | + | // though every changed line's text alone easily fits. | |||
| 63 | + | limits.MaxTotalBytes = textBytes | |||
| 64 | + | d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |||
| 67 | + | limits.MaxTotalBytes-- | |||
| 68 | + | require.Greater(t, limits.MaxTotalBytes, changedBytes) | |||
| 69 | + | d, err = g.GetCommit(context.Background(), f.Name, domain.Revision{}, limits) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.False(t, d.Diff.Truncated, "only MaxFiles sets Diff.Truncated") | |||
| 72 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[0].Omitted) | |||
| 73 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // DiffOmissions checks the omission reasons: generated files skipped without | |||
| 77 | + | // reading their hunks, one file read without a line cap, and the MaxFiles | |||
| 78 | + | // overflow being the only thing that sets Diff.Truncated. | |||
| 79 | + | func DiffOmissions(t *testing.T, mk Maker) { | |||
| 80 | + | g, data := mk(t) | |||
| 81 | + | ctx := context.Background() | |||
| 82 | + | f := NewFixture(t, data, "diff-omissions") | |||
| 83 | + | f.Write("base.txt", "x\n").Commit("base") | |||
| 84 | + | f.Write("a.txt", "small\n") | |||
| 85 | + | // The generated file (515 bytes) would overflow the 300-byte budget below | |||
| 86 | + | // if it were read: OmitGenerated, not OmitUnavailable, proves its hunks | |||
| 87 | + | // are not read. | |||
| 88 | + | f.Write("go.sum", strings.Repeat("h1:"+strings.Repeat("z", 100)+"\n", 5)) | |||
| 89 | + | f.Lines("z-big.txt", "line", 2500) | |||
| 90 | + | f.Commit("change") | |||
| 91 | + | ||||
| 92 | + | limits := domain.DiffLimits{MaxTotalBytes: 300, MaxFileLines: 2000, SkipGenerated: true} | |||
| 93 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | require.Len(t, d.Diff.Files, 3) | |||
| 96 | + | byPath := map[string]domain.FileDiff{} | |||
| 97 | + | for _, file := range d.Diff.Files { | |||
| 98 | + | byPath[file.NewPath] = file | |||
| 99 | + | } | |||
| 100 | + | assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted) | |||
| 101 | + | assert.Len(t, byPath["a.txt"].Hunks, 1) | |||
| 102 | + | assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted) | |||
| 103 | + | assert.Empty(t, byPath["go.sum"].Hunks) | |||
| 104 | + | assert.Equal(t, 5, byPath["go.sum"].Additions) | |||
| 105 | + | assert.Equal(t, domain.OmitLarge, byPath["z-big.txt"].Omitted) | |||
| 106 | + | assert.Empty(t, byPath["z-big.txt"].Hunks) | |||
| 107 | + | assert.False(t, d.Diff.Truncated) | |||
| 108 | + | ||||
| 109 | + | // Read, go.sum overflows the bytes that a.txt, read before it, already | |||
| 110 | + | // spent: it may fit on its own, so it is OmitBudget, loadable alone. | |||
| 111 | + | limits.SkipGenerated = false | |||
| 112 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | sum := -1 | |||
| 115 | + | for i, file := range d.Diff.Files { | |||
| 116 | + | if file.NewPath == "go.sum" { | |||
| 117 | + | sum = i | |||
| 118 | + | assert.Equal(t, domain.OmitBudget, file.Omitted, "earlier files spent bytes") | |||
| 119 | + | assert.Empty(t, file.Hunks) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | require.GreaterOrEqual(t, sum, 0) | |||
| 123 | + | // Alone, it is still over the bytes: OmitUnavailable, a dead end. | |||
| 124 | + | alone := domain.DiffLimits{From: sum, To: sum + 1, MaxTotalBytes: 300} | |||
| 125 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, alone) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[sum].Omitted, "over the bytes on its own") | |||
| 128 | + | assert.Empty(t, d.Diff.Files[sum].Hunks) | |||
| 129 | + | ||||
| 130 | + | // OneFileDiffLimits reads a file over 2,000 changed lines, and only it. | |||
| 131 | + | idx := -1 | |||
| 132 | + | for i, file := range d.Diff.Files { | |||
| 133 | + | if file.NewPath == "z-big.txt" { | |||
| 134 | + | idx = i | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | require.GreaterOrEqual(t, idx, 0) | |||
| 138 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(idx)) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | for i, file := range d.Diff.Files { | |||
| 141 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |||
| 142 | + | if i == idx { | |||
| 143 | + | require.Len(t, file.Hunks, 1) | |||
| 144 | + | assert.Len(t, file.Hunks[0].Lines, 2500) | |||
| 145 | + | } else { | |||
| 146 | + | assert.Empty(t, file.Hunks) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | assert.False(t, d.Diff.Truncated) | |||
| 150 | + | ||||
| 151 | + | // MaxFiles alone sets Diff.Truncated. | |||
| 152 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxFiles: 2, MaxTotalBytes: 1 << 20}) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | assert.Len(t, d.Diff.Files, 2) | |||
| 155 | + | assert.True(t, d.Diff.Truncated) | |||
| 156 | + | for _, file := range d.Diff.Files { | |||
| 157 | + | assert.Equal(t, domain.OmitNone, file.Omitted) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // A one-file read is capped at MaxOneFileLines changed lines: the cap | |||
| 161 | + | // itself loads, one more line is OmitUnavailable (it cannot be loaded). | |||
| 162 | + | f.Lines("cap.txt", "c", domain.MaxOneFileLines).Lines("over.txt", "o", domain.MaxOneFileLines+1).Commit("caps") | |||
| 163 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DiffLimits{MaxTotalBytes: 1}) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | capIdx, overIdx := -1, -1 | |||
| 166 | + | for i, file := range d.Diff.Files { | |||
| 167 | + | switch file.NewPath { | |||
| 168 | + | case "cap.txt": | |||
| 169 | + | capIdx = i | |||
| 170 | + | case "over.txt": | |||
| 171 | + | overIdx = i | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | require.GreaterOrEqual(t, capIdx, 0) | |||
| 175 | + | require.GreaterOrEqual(t, overIdx, 0) | |||
| 176 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(capIdx)) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[capIdx].Omitted) | |||
| 179 | + | require.Len(t, d.Diff.Files[capIdx].Hunks, 1) | |||
| 180 | + | assert.Len(t, d.Diff.Files[capIdx].Hunks[0].Lines, domain.MaxOneFileLines) | |||
| 181 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.OneFileDiffLimits(overIdx)) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | assert.Equal(t, domain.OmitUnavailable, d.Diff.Files[overIdx].Omitted) | |||
| 184 | + | assert.Empty(t, d.Diff.Files[overIdx].Hunks) | |||
| 185 | + | } | |||
M internal/usecase/browse.go +27 -3
| @@ -336,15 +336,39 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, | |||
| 336 | 336 | } | |
| 337 | 337 | ||
| 338 | 338 | // GetCommit returns a commit with its statistics and its diff against the | |
| 339 | - | // first parent, within the default diff limits. | |
| 340 | - | func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | |
| 339 | + | // first parent, within the default diff limits or, with opts.Files, the limits | |
| 340 | + | // of that file window. An invalid window gives domain.ErrInvalid. | |
| 341 | + | func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) { | |
| 342 | + | if (opts.Page && opts.Files != nil) || (opts.Load != nil && !opts.Page) { | |
| 343 | + | return domain.CommitDetail{}, fmt.Errorf("%w: commit page options", domain.ErrInvalid) | |
| 344 | + | } | |
| 345 | + | win := opts.Files | |
| 346 | + | if opts.Page { | |
| 347 | + | win = &ports.FileWindow{From: 0, To: domain.CommitPageFiles} | |
| 348 | + | } | |
| 349 | + | limits, err := windowLimits(win) | |
| 350 | + | if err != nil { | |
| 351 | + | return domain.CommitDetail{}, err | |
| 352 | + | } | |
| 341 | 353 | if _, err := b.authorize(ctx, p, repo); err != nil { | |
| 342 | 354 | return domain.CommitDetail{}, err | |
| 343 | 355 | } | |
| 344 | - | d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits()) | |
| 356 | + | d, err := b.reader.GetCommit(ctx, repo, rev, limits) | |
| 345 | 357 | if err != nil { | |
| 346 | 358 | return domain.CommitDetail{}, err | |
| 347 | 359 | } | |
| 360 | + | if opts.Page { | |
| 361 | + | d.Diff.CollapseFrom(domain.CommitPageFiles) | |
| 362 | + | if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() { | |
| 363 | + | one, err := b.reader.GetCommit(ctx, repo, rev, domain.OneFileDiffLimits(*i)) | |
| 364 | + | if err != nil { | |
| 365 | + | return domain.CommitDetail{}, err | |
| 366 | + | } | |
| 367 | + | if *i < len(one.Diff.Files) { | |
| 368 | + | d.Diff.Files[*i] = one.Diff.Files[*i] | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | } | |
| 348 | 372 | if b.verifier != nil { | |
| 349 | 373 | one := []domain.Commit{d.Commit} | |
| 350 | 374 | b.verify(ctx, repo, one) | |
| @@ -336,15 +336,39 @@ func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, | |||||
| 336 | } | 336 | } | ||
| 337 | 337 | ||||
| 338 | // GetCommit returns a commit with its statistics and its diff against the | 338 | // GetCommit returns a commit with its statistics and its diff against the | ||
| 339 | - | // first parent, within the default diff limits. | 339 | + | // first parent, within the default diff limits or, with opts.Files, the limits |
| 340 | - | func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | 340 | + | // of that file window. An invalid window gives domain.ErrInvalid. |
| 341 | + | func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, opts ports.GetCommitOptions) (domain.CommitDetail, error) { | |||
| 342 | + | if (opts.Page && opts.Files != nil) || (opts.Load != nil && !opts.Page) { | |||
| 343 | + | return domain.CommitDetail{}, fmt.Errorf("%w: commit page options", domain.ErrInvalid) | |||
| 344 | + | } | |||
| 345 | + | win := opts.Files | |||
| 346 | + | if opts.Page { | |||
| 347 | + | win = &ports.FileWindow{From: 0, To: domain.CommitPageFiles} | |||
| 348 | + | } | |||
| 349 | + | limits, err := windowLimits(win) | |||
| 350 | + | if err != nil { | |||
| 351 | + | return domain.CommitDetail{}, err | |||
| 352 | + | } | |||
| 341 | if _, err := b.authorize(ctx, p, repo); err != nil { | 353 | if _, err := b.authorize(ctx, p, repo); err != nil { | ||
| 342 | return domain.CommitDetail{}, err | 354 | return domain.CommitDetail{}, err | ||
| 343 | } | 355 | } | ||
| 344 | - | d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits()) | 356 | + | d, err := b.reader.GetCommit(ctx, repo, rev, limits) |
| 345 | if err != nil { | 357 | if err != nil { | ||
| 346 | return domain.CommitDetail{}, err | 358 | return domain.CommitDetail{}, err | ||
| 347 | } | 359 | } | ||
| 360 | + | if opts.Page { | |||
| 361 | + | d.Diff.CollapseFrom(domain.CommitPageFiles) | |||
| 362 | + | if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() { | |||
| 363 | + | one, err := b.reader.GetCommit(ctx, repo, rev, domain.OneFileDiffLimits(*i)) | |||
| 364 | + | if err != nil { | |||
| 365 | + | return domain.CommitDetail{}, err | |||
| 366 | + | } | |||
| 367 | + | if *i < len(one.Diff.Files) { | |||
| 368 | + | d.Diff.Files[*i] = one.Diff.Files[*i] | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 348 | if b.verifier != nil { | 372 | if b.verifier != nil { | ||
| 349 | one := []domain.Commit{d.Commit} | 373 | one := []domain.Commit{d.Commit} | ||
| 350 | b.verify(ctx, repo, one) | 374 | b.verify(ctx, repo, one) | ||
M internal/usecase/browse_test.go +37 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | 11 | "runtime" | |
| 12 | + | "slices" | |
| 12 | 13 | "strconv" | |
| 13 | 14 | "strings" | |
| 14 | 15 | "sync" | |
| @@ -104,7 +105,7 @@ func browseOps() []browseOp { | |||
| 104 | 105 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | |
| 105 | 106 | }}, | |
| 106 | 107 | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 107 | - | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | |
| 108 | + | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{}) | |
| 108 | 109 | return err | |
| 109 | 110 | }, func(r *mocks.RepoReader) { | |
| 110 | 111 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | |
| @@ -629,12 +630,44 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||
| 629 | 630 | Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil) | |
| 630 | 631 | v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}). | |
| 631 | 632 | Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified}) | |
| 632 | - | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 633 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) | |
| 633 | 634 | require.NoError(t, err) | |
| 634 | 635 | assert.Equal(t, verified, d.Commit.Verification) | |
| 635 | 636 | assert.Equal(t, 2, d.Stats.FilesChanged) | |
| 636 | 637 | }) | |
| 637 | 638 | ||
| 639 | + | t.Run("GetCommit Page collapses past the window and loads one file", func(t *testing.T) { | |
| 640 | + | t.Parallel() | |
| 641 | + | f, v := newFixture(t) | |
| 642 | + | v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything).Return(nil).Maybe() | |
| 643 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |
| 644 | + | files := make([]domain.FileDiff, domain.CommitPageFiles+2) | |
| 645 | + | for i := range files { | |
| 646 | + | files[i].Additions = 1 | |
| 647 | + | } | |
| 648 | + | win := domain.DefaultDiffLimits() | |
| 649 | + | win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 0, domain.CommitPageFiles, true | |
| 650 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, win). | |
| 651 | + | Return(domain.CommitDetail{Diff: domain.Diff{Files: slices.Clone(files)}}, nil) | |
| 652 | + | i := domain.CommitPageFiles + 1 | |
| 653 | + | loaded := slices.Clone(files) | |
| 654 | + | loaded[i].Hunks = []domain.Hunk{{}} | |
| 655 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.OneFileDiffLimits(i)). | |
| 656 | + | Return(domain.CommitDetail{Diff: domain.Diff{Files: loaded}}, nil) | |
| 657 | + | ||
| 658 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Load: &i}) | |
| 659 | + | require.NoError(t, err) | |
| 660 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |
| 661 | + | assert.Equal(t, domain.OmitBudget, d.Diff.Files[domain.CommitPageFiles].Omitted) | |
| 662 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[i].Omitted) | |
| 663 | + | assert.NotEmpty(t, d.Diff.Files[i].Hunks) | |
| 664 | + | ||
| 665 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Load: &i}) | |
| 666 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 667 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Files: &ports.FileWindow{To: 1}}) | |
| 668 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 669 | + | }) | |
| 670 | + | ||
| 638 | 671 | t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) { | |
| 639 | 672 | t.Parallel() | |
| 640 | 673 | f, _ := newFixture(t) // the verifier mock fails the test if called | |
| @@ -642,7 +675,7 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||
| 642 | 675 | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 643 | 676 | require.NoError(t, err) | |
| 644 | 677 | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound) | |
| 645 | - | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 678 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) | |
| 646 | 679 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 647 | 680 | }) | |
| 648 | 681 | ||
| @@ -657,7 +690,7 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||
| 657 | 690 | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 658 | 691 | require.NoError(t, err) | |
| 659 | 692 | assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification) | |
| 660 | - | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | |
| 693 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) | |
| 661 | 694 | require.NoError(t, err) | |
| 662 | 695 | assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification) | |
| 663 | 696 | }) | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | "runtime" | 11 | "runtime" | ||
| 12 | + | "slices" | |||
| 12 | "strconv" | 13 | "strconv" | ||
| 13 | "strings" | 14 | "strings" | ||
| 14 | "sync" | 15 | "sync" | ||
| @@ -104,7 +105,7 @@ func browseOps() []browseOp { | |||||
| 104 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | 105 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | ||
| 105 | }}, | 106 | }}, | ||
| 106 | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | 107 | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | ||
| 107 | - | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | 108 | + | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}, ports.GetCommitOptions{}) |
| 108 | return err | 109 | return err | ||
| 109 | }, func(r *mocks.RepoReader) { | 110 | }, func(r *mocks.RepoReader) { | ||
| 110 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | 111 | r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil) | ||
| @@ -629,12 +630,44 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||||
| 629 | Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil) | 630 | Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil) | ||
| 630 | v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}). | 631 | v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}). | ||
| 631 | Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified}) | 632 | Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified}) | ||
| 632 | - | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | 633 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) |
| 633 | require.NoError(t, err) | 634 | require.NoError(t, err) | ||
| 634 | assert.Equal(t, verified, d.Commit.Verification) | 635 | assert.Equal(t, verified, d.Commit.Verification) | ||
| 635 | assert.Equal(t, 2, d.Stats.FilesChanged) | 636 | assert.Equal(t, 2, d.Stats.FilesChanged) | ||
| 636 | }) | 637 | }) | ||
| 637 | 638 | ||||
| 639 | + | t.Run("GetCommit Page collapses past the window and loads one file", func(t *testing.T) { | |||
| 640 | + | t.Parallel() | |||
| 641 | + | f, v := newFixture(t) | |||
| 642 | + | v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything).Return(nil).Maybe() | |||
| 643 | + | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |||
| 644 | + | files := make([]domain.FileDiff, domain.CommitPageFiles+2) | |||
| 645 | + | for i := range files { | |||
| 646 | + | files[i].Additions = 1 | |||
| 647 | + | } | |||
| 648 | + | win := domain.DefaultDiffLimits() | |||
| 649 | + | win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 0, domain.CommitPageFiles, true | |||
| 650 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, win). | |||
| 651 | + | Return(domain.CommitDetail{Diff: domain.Diff{Files: slices.Clone(files)}}, nil) | |||
| 652 | + | i := domain.CommitPageFiles + 1 | |||
| 653 | + | loaded := slices.Clone(files) | |||
| 654 | + | loaded[i].Hunks = []domain.Hunk{{}} | |||
| 655 | + | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.OneFileDiffLimits(i)). | |||
| 656 | + | Return(domain.CommitDetail{Diff: domain.Diff{Files: loaded}}, nil) | |||
| 657 | + | ||||
| 658 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Load: &i}) | |||
| 659 | + | require.NoError(t, err) | |||
| 660 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted) | |||
| 661 | + | assert.Equal(t, domain.OmitBudget, d.Diff.Files[domain.CommitPageFiles].Omitted) | |||
| 662 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[i].Omitted) | |||
| 663 | + | assert.NotEmpty(t, d.Diff.Files[i].Hunks) | |||
| 664 | + | ||||
| 665 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Load: &i}) | |||
| 666 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 667 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{Page: true, Files: &ports.FileWindow{To: 1}}) | |||
| 668 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 669 | + | }) | |||
| 670 | + | ||||
| 638 | t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) { | 671 | t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) { | ||
| 639 | t.Parallel() | 672 | t.Parallel() | ||
| 640 | f, _ := newFixture(t) // the verifier mock fails the test if called | 673 | f, _ := newFixture(t) // the verifier mock fails the test if called | ||
| @@ -642,7 +675,7 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||||
| 642 | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | 675 | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | ||
| 643 | require.NoError(t, err) | 676 | require.NoError(t, err) | ||
| 644 | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound) | 677 | f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound) | ||
| 645 | - | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | 678 | + | _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) |
| 646 | assert.ErrorIs(t, err, domain.ErrNotFound) | 679 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 647 | }) | 680 | }) | ||
| 648 | 681 | ||||
| @@ -657,7 +690,7 @@ func TestBrowseVerifiesCommits(t *testing.T) { | |||||
| 657 | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | 690 | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | ||
| 658 | require.NoError(t, err) | 691 | require.NoError(t, err) | ||
| 659 | assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification) | 692 | assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification) | ||
| 660 | - | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}) | 693 | + | d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{}, ports.GetCommitOptions{}) |
| 661 | require.NoError(t, err) | 694 | require.NoError(t, err) | ||
| 662 | assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification) | 695 | assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification) | ||
| 663 | }) | 696 | }) | ||
M internal/usecase/ci_pull_test.go +1 -1
| @@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) { | |||
| 107 | 107 | assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got) | |
| 108 | 108 | _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks}) | |
| 109 | 109 | assert.False(t, ok, "binary") | |
| 110 | - | _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true}) | |
| 110 | + | _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge}) | |
| 111 | 111 | assert.False(t, ok, "truncated") | |
| 112 | 112 | _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed}) | |
| 113 | 113 | assert.False(t, ok, "a pure rename has no hunk") | |
| @@ -107,7 +107,7 @@ func TestFilePatch(t *testing.T) { | |||||
| 107 | assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got) | 107 | assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got) | ||
| 108 | _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks}) | 108 | _, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks}) | ||
| 109 | assert.False(t, ok, "binary") | 109 | assert.False(t, ok, "binary") | ||
| 110 | - | _, ok = usecase.FilePatch(domain.FileDiff{Truncated: true}) | 110 | + | _, ok = usecase.FilePatch(domain.FileDiff{Omitted: domain.OmitLarge}) |
| 111 | assert.False(t, ok, "truncated") | 111 | assert.False(t, ok, "truncated") | ||
| 112 | _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed}) | 112 | _, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed}) | ||
| 113 | assert.False(t, ok, "a pure rename has no hunk") | 113 | assert.False(t, ok, "a pure rename has no hunk") | ||
M internal/usecase/ci_pullfiles.go +1 -1
| @@ -212,7 +212,7 @@ func escapeURLPath(p string) string { | |||
| 212 | 212 | // (hunk headers and lines, no file headers). Absent for a binary file, a pure | |
| 213 | 213 | // rename and a file too large for the diff limits, as on GitHub. | |
| 214 | 214 | func filePatch(f domain.FileDiff) (string, bool) { | |
| 215 | - | if f.Binary || f.Truncated || len(f.Hunks) == 0 { | |
| 215 | + | if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 { | |
| 216 | 216 | return "", false | |
| 217 | 217 | } | |
| 218 | 218 | var b strings.Builder | |
| @@ -212,7 +212,7 @@ func escapeURLPath(p string) string { | |||||
| 212 | // (hunk headers and lines, no file headers). Absent for a binary file, a pure | 212 | // (hunk headers and lines, no file headers). Absent for a binary file, a pure | ||
| 213 | // rename and a file too large for the diff limits, as on GitHub. | 213 | // rename and a file too large for the diff limits, as on GitHub. | ||
| 214 | func filePatch(f domain.FileDiff) (string, bool) { | 214 | func filePatch(f domain.FileDiff) (string, bool) { | ||
| 215 | - | if f.Binary || f.Truncated || len(f.Hunks) == 0 { | 215 | + | if f.Binary || f.Omitted != domain.OmitNone || len(f.Hunks) == 0 { |
| 216 | return "", false | 216 | return "", false | ||
| 217 | } | 217 | } | ||
| 218 | var b strings.Builder | 218 | var b strings.Builder | ||
M internal/usecase/export_test.go +3 -0
| @@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) { | |||
| 217 | 217 | func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName { | |
| 218 | 218 | return s.popDue(retryItem{key: repo, due: due}) | |
| 219 | 219 | } | |
| 220 | + | ||
| 221 | + | // WindowLimits exposes windowLimits to the tests. | |
| 222 | + | var WindowLimits = windowLimits | |
| @@ -217,3 +217,6 @@ func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) { | |||||
| 217 | func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName { | 217 | func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName { | ||
| 218 | return s.popDue(retryItem{key: repo, due: due}) | 218 | return s.popDue(retryItem{key: repo, due: due}) | ||
| 219 | } | 219 | } | ||
| 220 | + | ||||
| 221 | + | // WindowLimits exposes windowLimits to the tests. | |||
| 222 | + | var WindowLimits = windowLimits | |||
M internal/usecase/mergerequests.go +42 -19
| @@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||
| 493 | 493 | return repo, mr, nil | |
| 494 | 494 | } | |
| 495 | 495 | ||
| 496 | + | // windowLimits is the diff limits of a read: nil gives | |
| 497 | + | // domain.DefaultDiffLimits (the API contract, generated files read); a window | |
| 498 | + | // lists every file (MaxFiles 0), reads From..To and skips generated files; a | |
| 499 | + | // Full window reads the one file From with domain.OneFileDiffLimits. An invalid | |
| 500 | + | // window (negative, To < From, Full without To == From+1) is domain.ErrInvalid. | |
| 501 | + | func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) { | |
| 502 | + | if w == nil { | |
| 503 | + | return domain.DefaultDiffLimits(), nil | |
| 504 | + | } | |
| 505 | + | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) { | |
| 506 | + | return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full) | |
| 507 | + | } | |
| 508 | + | if w.Full { | |
| 509 | + | return domain.OneFileDiffLimits(w.From), nil | |
| 510 | + | } | |
| 511 | + | l := domain.DefaultDiffLimits() | |
| 512 | + | l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true | |
| 513 | + | return l, nil | |
| 514 | + | } | |
| 515 | + | ||
| 496 | 516 | // Get returns a merge request with its commits, its diff, its comments and the | |
| 497 | 517 | // read position. The diff compares the merge base of the target and the head | |
| 498 | 518 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | |
| @@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||
| 507 | 527 | // diff is no longer the one the caller shows (before any diff is read); | |
| 508 | 528 | // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid. | |
| 509 | 529 | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | |
| 510 | - | limits := domain.DefaultDiffLimits() | |
| 511 | - | if w := opts.Files; w != nil { | |
| 512 | - | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) { | |
| 513 | - | return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To) | |
| 514 | - | } | |
| 515 | - | limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To | |
| 530 | + | limits, err := windowLimits(opts.Files) | |
| 531 | + | if err != nil { | |
| 532 | + | return ports.MergeRequestDetail{}, err | |
| 516 | 533 | } | |
| 517 | 534 | _, mr, err := m.get(ctx, p, name, number) | |
| 518 | 535 | if err != nil { | |
| @@ -604,6 +621,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 604 | 621 | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil { | |
| 605 | 622 | return ports.MergeRequestDetail{}, err | |
| 606 | 623 | } | |
| 624 | + | // The page of a client without scripts reads one collapsed file in full. | |
| 625 | + | if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() { | |
| 626 | + | _, one, err := m.git.DiffCommits(ctx, name, base, head, domain.OneFileDiffLimits(*i)) | |
| 627 | + | if err != nil { | |
| 628 | + | return ports.MergeRequestDetail{}, err | |
| 629 | + | } | |
| 630 | + | if *i < len(one.Files) { | |
| 631 | + | d.Diff.Files[*i] = one.Files[*i] | |
| 632 | + | } | |
| 633 | + | } | |
| 607 | 634 | return d, nil | |
| 608 | 635 | } | |
| 609 | 636 | ||
| @@ -763,18 +790,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||
| 763 | 790 | // file's hunks within the default total limits would reject a comment on a | |
| 764 | 791 | // file the earlier ones left no budget for. So a first read lists the files | |
| 765 | 792 | // with no hunks (From past the end: cheap, git is stopped at once), and a | |
| 766 | - | // second reads the hunks of the one file within the default per-file limit | |
| 767 | - | // and total limits, as the web view does for a window holding it. | |
| 793 | + | // second loads the one file in full (a Full window: the byte guard and | |
| 794 | + | // domain.MaxOneFileLines only, generated files included), as the web view | |
| 795 | + | // does when it loads a collapsed file: any line the page can show can be | |
| 796 | + | // commented on, and a file over those limits takes no line comment. | |
| 768 | 797 | list := domain.DefaultDiffLimits() | |
| 769 | 798 | list.MaxFiles, list.From = 0, math.MaxInt | |
| 770 | 799 | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list) | |
| 771 | 800 | if err != nil { | |
| 772 | 801 | return err | |
| 773 | 802 | } | |
| 774 | - | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path }) | |
| 803 | + | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path }) | |
| 775 | 804 | if idx >= 0 { | |
| 776 | - | one := domain.DefaultDiffLimits() | |
| 777 | - | one.MaxFiles, one.From, one.To = 0, idx, idx+1 | |
| 805 | + | one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true}) | |
| 806 | + | if err != nil { | |
| 807 | + | return err | |
| 808 | + | } | |
| 778 | 809 | if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil { | |
| 779 | 810 | return err | |
| 780 | 811 | } | |
| @@ -788,14 +819,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||
| 788 | 819 | return v.Err() | |
| 789 | 820 | } | |
| 790 | 821 | ||
| 791 | - | // diffFileName is the path a comment designates for f, as Diff.HasLine reads it. | |
| 792 | - | func diffFileName(f domain.FileDiff) string { | |
| 793 | - | if f.Status == domain.FileDeleted { | |
| 794 | - | return f.OldPath | |
| 795 | - | } | |
| 796 | - | return f.NewPath | |
| 797 | - | } | |
| 798 | - | ||
| 799 | 822 | // ResolveComment marks a comment resolved or unresolved. The principal needs | |
| 800 | 823 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | |
| 801 | 824 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | |
| @@ -493,6 +493,26 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||||
| 493 | return repo, mr, nil | 493 | return repo, mr, nil | ||
| 494 | } | 494 | } | ||
| 495 | 495 | ||||
| 496 | + | // windowLimits is the diff limits of a read: nil gives | |||
| 497 | + | // domain.DefaultDiffLimits (the API contract, generated files read); a window | |||
| 498 | + | // lists every file (MaxFiles 0), reads From..To and skips generated files; a | |||
| 499 | + | // Full window reads the one file From with domain.OneFileDiffLimits. An invalid | |||
| 500 | + | // window (negative, To < From, Full without To == From+1) is domain.ErrInvalid. | |||
| 501 | + | func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) { | |||
| 502 | + | if w == nil { | |||
| 503 | + | return domain.DefaultDiffLimits(), nil | |||
| 504 | + | } | |||
| 505 | + | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) { | |||
| 506 | + | return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full) | |||
| 507 | + | } | |||
| 508 | + | if w.Full { | |||
| 509 | + | return domain.OneFileDiffLimits(w.From), nil | |||
| 510 | + | } | |||
| 511 | + | l := domain.DefaultDiffLimits() | |||
| 512 | + | l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true | |||
| 513 | + | return l, nil | |||
| 514 | + | } | |||
| 515 | + | ||||
| 496 | // Get returns a merge request with its commits, its diff, its comments and the | 516 | // Get returns a merge request with its commits, its diff, its comments and the | ||
| 497 | // read position. The diff compares the merge base of the target and the head | 517 | // read position. The diff compares the merge base of the target and the head | ||
| 498 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | 518 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | ||
| @@ -507,12 +527,9 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||||
| 507 | // diff is no longer the one the caller shows (before any diff is read); | 527 | // diff is no longer the one the caller shows (before any diff is read); | ||
| 508 | // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid. | 528 | // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid. | ||
| 509 | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | 529 | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | ||
| 510 | - | limits := domain.DefaultDiffLimits() | 530 | + | limits, err := windowLimits(opts.Files) |
| 511 | - | if w := opts.Files; w != nil { | 531 | + | if err != nil { |
| 512 | - | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) { | 532 | + | return ports.MergeRequestDetail{}, err |
| 513 | - | return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To) | |||
| 514 | - | } | |||
| 515 | - | limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To | |||
| 516 | } | 533 | } | ||
| 517 | _, mr, err := m.get(ctx, p, name, number) | 534 | _, mr, err := m.get(ctx, p, name, number) | ||
| 518 | if err != nil { | 535 | if err != nil { | ||
| @@ -604,6 +621,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 604 | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil { | 621 | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil { | ||
| 605 | return ports.MergeRequestDetail{}, err | 622 | return ports.MergeRequestDetail{}, err | ||
| 606 | } | 623 | } | ||
| 624 | + | // The page of a client without scripts reads one collapsed file in full. | |||
| 625 | + | if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() { | |||
| 626 | + | _, one, err := m.git.DiffCommits(ctx, name, base, head, domain.OneFileDiffLimits(*i)) | |||
| 627 | + | if err != nil { | |||
| 628 | + | return ports.MergeRequestDetail{}, err | |||
| 629 | + | } | |||
| 630 | + | if *i < len(one.Files) { | |||
| 631 | + | d.Diff.Files[*i] = one.Files[*i] | |||
| 632 | + | } | |||
| 633 | + | } | |||
| 607 | return d, nil | 634 | return d, nil | ||
| 608 | } | 635 | } | ||
| 609 | 636 | ||||
| @@ -763,18 +790,22 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||||
| 763 | // file's hunks within the default total limits would reject a comment on a | 790 | // file's hunks within the default total limits would reject a comment on a | ||
| 764 | // file the earlier ones left no budget for. So a first read lists the files | 791 | // file the earlier ones left no budget for. So a first read lists the files | ||
| 765 | // with no hunks (From past the end: cheap, git is stopped at once), and a | 792 | // with no hunks (From past the end: cheap, git is stopped at once), and a | ||
| 766 | - | // second reads the hunks of the one file within the default per-file limit | 793 | + | // second loads the one file in full (a Full window: the byte guard and |
| 767 | - | // and total limits, as the web view does for a window holding it. | 794 | + | // domain.MaxOneFileLines only, generated files included), as the web view |
| 795 | + | // does when it loads a collapsed file: any line the page can show can be | |||
| 796 | + | // commented on, and a file over those limits takes no line comment. | |||
| 768 | list := domain.DefaultDiffLimits() | 797 | list := domain.DefaultDiffLimits() | ||
| 769 | list.MaxFiles, list.From = 0, math.MaxInt | 798 | list.MaxFiles, list.From = 0, math.MaxInt | ||
| 770 | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list) | 799 | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list) | ||
| 771 | if err != nil { | 800 | if err != nil { | ||
| 772 | return err | 801 | return err | ||
| 773 | } | 802 | } | ||
| 774 | - | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path }) | 803 | + | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path }) |
| 775 | if idx >= 0 { | 804 | if idx >= 0 { | ||
| 776 | - | one := domain.DefaultDiffLimits() | 805 | + | one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true}) |
| 777 | - | one.MaxFiles, one.From, one.To = 0, idx, idx+1 | 806 | + | if err != nil { |
| 807 | + | return err | |||
| 808 | + | } | |||
| 778 | if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil { | 809 | if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil { | ||
| 779 | return err | 810 | return err | ||
| 780 | } | 811 | } | ||
| @@ -788,14 +819,6 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||||
| 788 | return v.Err() | 819 | return v.Err() | ||
| 789 | } | 820 | } | ||
| 790 | 821 | ||||
| 791 | - | // diffFileName is the path a comment designates for f, as Diff.HasLine reads it. | |||
| 792 | - | func diffFileName(f domain.FileDiff) string { | |||
| 793 | - | if f.Status == domain.FileDeleted { | |||
| 794 | - | return f.OldPath | |||
| 795 | - | } | |||
| 796 | - | return f.NewPath | |||
| 797 | - | } | |||
| 798 | - | ||||
| 799 | // ResolveComment marks a comment resolved or unresolved. The principal needs | 822 | // ResolveComment marks a comment resolved or unresolved. The principal needs | ||
| 800 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | 823 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | ||
| 801 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | 824 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | ||
M internal/usecase/mergerequests_files_test.go +1 -1
| @@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) { | |||
| 63 | 63 | for i, f := range d.Diff.Files { | |
| 64 | 64 | assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath) | |
| 65 | 65 | assert.Equal(t, 1, f.Additions) | |
| 66 | - | assert.False(t, f.Truncated) | |
| 66 | + | assert.Equal(t, domain.OmitNone, f.Omitted) | |
| 67 | 67 | if i >= 305 && i < 310 { | |
| 68 | 68 | require.Len(t, f.Hunks, 1, f.NewPath) | |
| 69 | 69 | assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text) | |
| @@ -63,7 +63,7 @@ func TestGetMergeRequestFilesWindow(t *testing.T) { | |||||
| 63 | for i, f := range d.Diff.Files { | 63 | for i, f := range d.Diff.Files { | ||
| 64 | assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath) | 64 | assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath) | ||
| 65 | assert.Equal(t, 1, f.Additions) | 65 | assert.Equal(t, 1, f.Additions) | ||
| 66 | - | assert.False(t, f.Truncated) | 66 | + | assert.Equal(t, domain.OmitNone, f.Omitted) |
| 67 | if i >= 305 && i < 310 { | 67 | if i >= 305 && i < 310 { | ||
| 68 | require.Len(t, f.Hunks, 1, f.NewPath) | 68 | require.Len(t, f.Hunks, 1, f.NewPath) | ||
| 69 | assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text) | 69 | assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text) | ||
A internal/usecase/window_test.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/usecase" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestWindowLimits(t *testing.T) { | |
| 19 | + | def := domain.DefaultDiffLimits() | |
| 20 | + | win := def | |
| 21 | + | win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true | |
| 22 | + | open := win | |
| 23 | + | open.From, open.To = 7, 0 | |
| 24 | + | ||
| 25 | + | tests := []struct { | |
| 26 | + | name string | |
| 27 | + | w *ports.FileWindow | |
| 28 | + | want domain.DiffLimits | |
| 29 | + | bad bool | |
| 30 | + | }{ | |
| 31 | + | {name: "nil is the default", want: def}, | |
| 32 | + | {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win}, | |
| 33 | + | {name: "an open window", w: &ports.FileWindow{From: 7}, want: open}, | |
| 34 | + | {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)}, | |
| 35 | + | {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true}, | |
| 36 | + | {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true}, | |
| 37 | + | {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true}, | |
| 38 | + | {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true}, | |
| 39 | + | {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true}, | |
| 40 | + | {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true}, | |
| 41 | + | } | |
| 42 | + | for _, tt := range tests { | |
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |
| 44 | + | got, err := usecase.WindowLimits(tt.w) | |
| 45 | + | if tt.bad { | |
| 46 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | assert.Equal(t, tt.want, got) | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestGetMergeRequestFullAndGenerated(t *testing.T) { | |
| 56 | + | r := newMRRig(t) | |
| 57 | + | r.seed() | |
| 58 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |
| 59 | + | r.commit(author, "a.txt", "one\n", "a") | |
| 60 | + | r.commit(author, "go.sum", "x y z\n", "sum") | |
| 61 | + | r.push("feat") | |
| 62 | + | n := r.open("feat").Number | |
| 63 | + | ||
| 64 | + | t.Run("full with a wider window is invalid", func(t *testing.T) { | |
| 65 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}}) | |
| 66 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 67 | + | }) | |
| 68 | + | ||
| 69 | + | t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) { | |
| 70 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}}) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | byPath := map[string]domain.FileDiff{} | |
| 73 | + | for _, f := range d.Diff.Files { | |
| 74 | + | byPath[f.NewPath] = f | |
| 75 | + | } | |
| 76 | + | assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted) | |
| 77 | + | assert.Empty(t, byPath["go.sum"].Hunks) | |
| 78 | + | assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted) | |
| 79 | + | assert.NotEmpty(t, byPath["a.txt"].Hunks) | |
| 80 | + | ||
| 81 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | for _, f := range d.Diff.Files { | |
| 84 | + | assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath) | |
| 85 | + | assert.NotEmpty(t, f.Hunks, f.NewPath) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func bigContent(n int) string { | |
| 91 | + | var b strings.Builder | |
| 92 | + | for i := range n { | |
| 93 | + | fmt.Fprintf(&b, "line %d\n", i) | |
| 94 | + | } | |
| 95 | + | return b.String() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestCheckLineBeyondFileLimit(t *testing.T) { | |
| 99 | + | r := newMRRig(t) | |
| 100 | + | r.seed() | |
| 101 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |
| 102 | + | r.commit(author, "big.txt", bigContent(3000), "big") | |
| 103 | + | r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum") | |
| 104 | + | r.push("feat") | |
| 105 | + | n := r.open("feat").Number | |
| 106 | + | ||
| 107 | + | add := func(path string, line int) error { | |
| 108 | + | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"}) | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | require.NoError(t, add("big.txt", 1)) | |
| 112 | + | require.NoError(t, add("big.txt", 2500)) | |
| 113 | + | require.NoError(t, add("big.txt", 3000)) | |
| 114 | + | require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid) | |
| 115 | + | // A generated file is collapsed in the web windows, but loadable: its | |
| 116 | + | // lines can be commented on. | |
| 117 | + | require.NoError(t, add("go.sum", 2)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestBrowseGetCommitFullWindow(t *testing.T) { | |
| 121 | + | r := newMRRig(t) | |
| 122 | + | r.seed() | |
| 123 | + | r.commit(author, "a-big.txt", bigContent(3000), "big") | |
| 124 | + | id := r.head() | |
| 125 | + | r.push("main") | |
| 126 | + | b := usecase.NewBrowse(r.store, r.git) | |
| 127 | + | rev := domain.RevisionOf(id) | |
| 128 | + | ||
| 129 | + | d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{}) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | require.Len(t, d.Diff.Files, 1) | |
| 132 | + | assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted) | |
| 133 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |
| 134 | + | ||
| 135 | + | d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}}) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | require.Len(t, d.Diff.Files, 1) | |
| 138 | + | f := d.Diff.Files[0] | |
| 139 | + | assert.Equal(t, domain.OmitNone, f.Omitted) | |
| 140 | + | require.NotEmpty(t, f.Hunks) | |
| 141 | + | assert.Equal(t, 3000, f.Additions) | |
| 142 | + | ||
| 143 | + | _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}}) | |
| 144 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) { | |
| 148 | + | r := newMRRig(t) | |
| 149 | + | r.seed() | |
| 150 | + | r.commit(author, "go.sum", "h1:x\n", "sum") | |
| 151 | + | id := r.head() | |
| 152 | + | r.push("main") | |
| 153 | + | b := usecase.NewBrowse(r.store, r.git) | |
| 154 | + | rev := domain.RevisionOf(id) | |
| 155 | + | ||
| 156 | + | d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{}) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Len(t, d.Diff.Files, 1) | |
| 159 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files") | |
| 160 | + | ||
| 161 | + | d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}}) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | require.Len(t, d.Diff.Files, 1) | |
| 164 | + | assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them") | |
| 165 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | "bnema.dev/ermite/internal/usecase" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestWindowLimits(t *testing.T) { | |||
| 19 | + | def := domain.DefaultDiffLimits() | |||
| 20 | + | win := def | |||
| 21 | + | win.MaxFiles, win.From, win.To, win.SkipGenerated = 0, 2, 5, true | |||
| 22 | + | open := win | |||
| 23 | + | open.From, open.To = 7, 0 | |||
| 24 | + | ||||
| 25 | + | tests := []struct { | |||
| 26 | + | name string | |||
| 27 | + | w *ports.FileWindow | |||
| 28 | + | want domain.DiffLimits | |||
| 29 | + | bad bool | |||
| 30 | + | }{ | |||
| 31 | + | {name: "nil is the default", want: def}, | |||
| 32 | + | {name: "a window", w: &ports.FileWindow{From: 2, To: 5}, want: win}, | |||
| 33 | + | {name: "an open window", w: &ports.FileWindow{From: 7}, want: open}, | |||
| 34 | + | {name: "full", w: &ports.FileWindow{From: 3, To: 4, Full: true}, want: domain.OneFileDiffLimits(3)}, | |||
| 35 | + | {name: "negative from", w: &ports.FileWindow{From: -1}, bad: true}, | |||
| 36 | + | {name: "negative to", w: &ports.FileWindow{To: -1}, bad: true}, | |||
| 37 | + | {name: "to before from", w: &ports.FileWindow{From: 5, To: 3}, bad: true}, | |||
| 38 | + | {name: "full without to", w: &ports.FileWindow{From: 3, Full: true}, bad: true}, | |||
| 39 | + | {name: "full of two files", w: &ports.FileWindow{From: 3, To: 5, Full: true}, bad: true}, | |||
| 40 | + | {name: "full of no file", w: &ports.FileWindow{From: 3, To: 3, Full: true}, bad: true}, | |||
| 41 | + | } | |||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | got, err := usecase.WindowLimits(tt.w) | |||
| 45 | + | if tt.bad { | |||
| 46 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | assert.Equal(t, tt.want, got) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestGetMergeRequestFullAndGenerated(t *testing.T) { | |||
| 56 | + | r := newMRRig(t) | |||
| 57 | + | r.seed() | |||
| 58 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |||
| 59 | + | r.commit(author, "a.txt", "one\n", "a") | |||
| 60 | + | r.commit(author, "go.sum", "x y z\n", "sum") | |||
| 61 | + | r.push("feat") | |||
| 62 | + | n := r.open("feat").Number | |||
| 63 | + | ||||
| 64 | + | t.Run("full with a wider window is invalid", func(t *testing.T) { | |||
| 65 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}}) | |||
| 66 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 67 | + | }) | |||
| 68 | + | ||||
| 69 | + | t.Run("a windowed read collapses a generated file, a nil read does not", func(t *testing.T) { | |||
| 70 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 0, To: 10}}) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | byPath := map[string]domain.FileDiff{} | |||
| 73 | + | for _, f := range d.Diff.Files { | |||
| 74 | + | byPath[f.NewPath] = f | |||
| 75 | + | } | |||
| 76 | + | assert.Equal(t, domain.OmitGenerated, byPath["go.sum"].Omitted) | |||
| 77 | + | assert.Empty(t, byPath["go.sum"].Hunks) | |||
| 78 | + | assert.Equal(t, domain.OmitNone, byPath["a.txt"].Omitted) | |||
| 79 | + | assert.NotEmpty(t, byPath["a.txt"].Hunks) | |||
| 80 | + | ||||
| 81 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | for _, f := range d.Diff.Files { | |||
| 84 | + | assert.Equal(t, domain.OmitNone, f.Omitted, f.NewPath) | |||
| 85 | + | assert.NotEmpty(t, f.Hunks, f.NewPath) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func bigContent(n int) string { | |||
| 91 | + | var b strings.Builder | |||
| 92 | + | for i := range n { | |||
| 93 | + | fmt.Fprintf(&b, "line %d\n", i) | |||
| 94 | + | } | |||
| 95 | + | return b.String() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestCheckLineBeyondFileLimit(t *testing.T) { | |||
| 99 | + | r := newMRRig(t) | |||
| 100 | + | r.seed() | |||
| 101 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |||
| 102 | + | r.commit(author, "big.txt", bigContent(3000), "big") | |||
| 103 | + | r.commit(author, "go.sum", "h1:x\nh1:y\n", "sum") | |||
| 104 | + | r.push("feat") | |||
| 105 | + | n := r.open("feat").Number | |||
| 106 | + | ||||
| 107 | + | add := func(path string, line int) error { | |||
| 108 | + | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: path, Line: line, Side: domain.SideNew, Text: "c"}) | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | require.NoError(t, add("big.txt", 1)) | |||
| 112 | + | require.NoError(t, add("big.txt", 2500)) | |||
| 113 | + | require.NoError(t, add("big.txt", 3000)) | |||
| 114 | + | require.ErrorIs(t, add("big.txt", 3001), domain.ErrInvalid) | |||
| 115 | + | // A generated file is collapsed in the web windows, but loadable: its | |||
| 116 | + | // lines can be commented on. | |||
| 117 | + | require.NoError(t, add("go.sum", 2)) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestBrowseGetCommitFullWindow(t *testing.T) { | |||
| 121 | + | r := newMRRig(t) | |||
| 122 | + | r.seed() | |||
| 123 | + | r.commit(author, "a-big.txt", bigContent(3000), "big") | |||
| 124 | + | id := r.head() | |||
| 125 | + | r.push("main") | |||
| 126 | + | b := usecase.NewBrowse(r.store, r.git) | |||
| 127 | + | rev := domain.RevisionOf(id) | |||
| 128 | + | ||||
| 129 | + | d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{}) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | require.Len(t, d.Diff.Files, 1) | |||
| 132 | + | assert.Equal(t, domain.OmitLarge, d.Diff.Files[0].Omitted) | |||
| 133 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |||
| 134 | + | ||||
| 135 | + | d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 1, Full: true}}) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | require.Len(t, d.Diff.Files, 1) | |||
| 138 | + | f := d.Diff.Files[0] | |||
| 139 | + | assert.Equal(t, domain.OmitNone, f.Omitted) | |||
| 140 | + | require.NotEmpty(t, f.Hunks) | |||
| 141 | + | assert.Equal(t, 3000, f.Additions) | |||
| 142 | + | ||||
| 143 | + | _, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 2, Full: true}}) | |||
| 144 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestBrowseGetCommitWindowCollapsesGenerated(t *testing.T) { | |||
| 148 | + | r := newMRRig(t) | |||
| 149 | + | r.seed() | |||
| 150 | + | r.commit(author, "go.sum", "h1:x\n", "sum") | |||
| 151 | + | id := r.head() | |||
| 152 | + | r.push("main") | |||
| 153 | + | b := usecase.NewBrowse(r.store, r.git) | |||
| 154 | + | rev := domain.RevisionOf(id) | |||
| 155 | + | ||||
| 156 | + | d, err := b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{}) | |||
| 157 | + | require.NoError(t, err) | |||
| 158 | + | require.Len(t, d.Diff.Files, 1) | |||
| 159 | + | assert.Equal(t, domain.OmitNone, d.Diff.Files[0].Omitted, "API reads keep generated files") | |||
| 160 | + | ||||
| 161 | + | d, err = b.GetCommit(r.ctx, root, r.repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: 25}}) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | require.Len(t, d.Diff.Files, 1) | |||
| 164 | + | assert.Equal(t, domain.OmitGenerated, d.Diff.Files[0].Omitted, "web windows collapse them") | |||
| 165 | + | assert.Empty(t, d.Diff.Files[0].Hunks) | |||
| 166 | + | } | |||