diff --git a/packages/git-ui/src/components/molecules/diff-file.jsx b/packages/git-ui/src/components/molecules/diff-file.jsx index cf09204..7bd05b8 100644 --- a/packages/git-ui/src/components/molecules/diff-file.jsx +++ b/packages/git-ui/src/components/molecules/diff-file.jsx @@ -9,6 +9,7 @@ import { inSpan, lineKey } from '#/lib/comments.js'; import { ROW_STYLE, SIGN, SIGN_STYLE } from '#/lib/diff-rows.js'; import { bytes } from '#/lib/format.js'; import { diffHighlighter, languageFor } from '#/lib/highlight.js'; +import { inSelection, lineFragment, lineId } from '#/lib/line-links.js'; import { useObjectUrl } from '#/lib/object-url.js'; import { cn } from '#/lib/utils.js'; import { verdictLook } from '#/lib/verdicts.js'; @@ -21,6 +22,40 @@ const STATUS_STYLE = { modified: '', }; +/** + * One number in the gutter, which is also the address of its line. A plain + * click selects the line, and shift takes the span from the line already + * selected to this one. The anchor is a real link, so the browser's own menu + * copies it and a middle click opens it. + * + * A column showing a number the line is not addressed by draws it plainly. A + * context line is addressed on the new side, the way a comment anchors to it, + * so its old number reads as a number and nothing more. + */ +function LineNumber({ value, fragment, style, className, onSelect }) { + if (fragment == null) { + return ( + + {value ?? ''} + + ); + } + return ( + { + event.preventDefault(); + onSelect(event.shiftKey); + }} + > + {value} + + ); +} + /** * One side of a changed image, with its size under it. A side the repository * holds as a Git LFS pointer has no picture to show, so the caption carries @@ -63,8 +98,19 @@ function DiffImage({ image, mediaType, label, path }) { * and `onComment` to publish a new one; * each renders under the line it speaks to, which is what an anchor is for. A * plain commit page passes neither and reads as it always did. + * + * `selection` is the span of lines the address names, when it names one in + * this file, and `onSelectLine` sets a new one from a click in the gutter. */ -export function DiffFile({ file, ref, comments, spans, onComment }) { +export function DiffFile({ + file, + ref, + comments, + spans, + onComment, + selection, + onSelectLine, +}) { const language = languageFor(file.path); const [open, setOpen] = useState(true); // Colouring a whole file twice costs more than colouring a hunk, so it @@ -279,39 +325,65 @@ export function DiffFile({ file, ref, comments, spans, onComment }) { {hunk.lines.map((line) => { const key = lineKey(line); const here = comments?.get(key) ?? []; + const onNew = line.newLine != null; + const at = line.newLine ?? line.oldLine; + const fragment = + onSelectLine && at != null + ? lineFragment({ + path: file.path, + side: onNew ? 'new' : 'old', + start: at, + end: at, + }) + : null; + const select = (shift) => onSelectLine(line, shift); + const linked = inSelection(selection, file.path, line); return (
No such repository.
; @@ -483,6 +515,10 @@ export function CommitPage({ comments={comments.byFile.get(file.path)} spans={comments.spans.get(file.path)} onComment={reviewable && session ? onComment : undefined} + selection={selection} + onSelectLine={(line, shift) => + selectLine(file.path, line, shift) + } ref={(node) => { if (node) sections.current.set(file.path, node); else sections.current.delete(file.path); diff --git a/packages/git-ui/src/pages/file.jsx b/packages/git-ui/src/pages/file.jsx index ac900e7..1c54b04 100644 --- a/packages/git-ui/src/pages/file.jsx +++ b/packages/git-ui/src/pages/file.jsx @@ -7,10 +7,10 @@ import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx'; import { FileCommit } from '#/components/molecules/file-commit.jsx'; import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; import { RefSelect } from '#/components/molecules/ref-select.jsx'; +import { SourceLines } from '#/components/molecules/source-lines.jsx'; import { SourcePanes } from '#/components/molecules/source-panes.jsx'; import { bytes } from '#/lib/format.js'; import { capabilities, rawUrl, repoRecord, repoSize } from '#/lib/git.js'; -import { highlightBlock, languageFor } from '#/lib/highlight.js'; import { useNavigate } from '#/lib/navigation.jsx'; import { useObjectUrl } from '#/lib/object-url.js'; import { fileKey, parentOf, readSource, useFileCommit } from '#/lib/source.js'; @@ -111,32 +111,7 @@ export function FilePage({ repo, refName, path }) { )} ) : data.file.text !== null ? ( -{data.file.binary diff --git a/packages/git-ui/test/line-links.test.js b/packages/git-ui/test/line-links.test.js new file mode 100644 index 0000000..1339ce0 --- /dev/null +++ b/packages/git-ui/test/line-links.test.js @@ -0,0 +1,181 @@ +/** + * The address of a line in a diff: what a click writes into the fragment, and + * what a reader arriving on that fragment gets back. + */ + +import { describe, expect, it } from 'vitest'; +import { + extendSelection, + extendSpan, + inSelection, + lineFragment, + lineId, + parseLineFragment, + parseSourceFragment, + sourceFragment, + sourceLineId, +} from '../src/lib/line-links.js'; + +/** @param {number|null} oldLine @param {number|null} newLine */ +const row = (oldLine, newLine) => ({ oldLine, newLine }); + +describe('lineFragment and parseLineFragment', () => { + it('carries one line there and back', () => { + const selection = { path: 'src/pds.js', side: 'new', start: 12, end: 12 }; + const fragment = lineFragment(selection); + expect(fragment).toBe('line-src/pds.js-new:12'); + expect(parseLineFragment(fragment)).toEqual(selection); + }); + + it('carries a span there and back', () => { + const selection = { path: 'src/pds.js', side: 'old', start: 12, end: 20 }; + const fragment = lineFragment(selection); + expect(fragment).toBe('line-src/pds.js-old:12-20'); + expect(parseLineFragment(fragment)).toEqual(selection); + }); + + // A path holds hyphens more often than not, and the fragment ends with a + // number that could be read as part of one. + it('reads a path with hyphens in it', () => { + const selection = { + path: 'packages/git-ui/src/diff-file.jsx', + side: 'new', + start: 3, + end: 9, + }; + expect(parseLineFragment(lineFragment(selection))).toEqual(selection); + }); + + it('answers nothing to a fragment naming something else', () => { + expect(parseLineFragment('comment-src/pds.js-new:12')).toBeNull(); + expect(parseLineFragment('')).toBeNull(); + expect(parseLineFragment('line-src/pds.js-both:12')).toBeNull(); + }); + + it('orders the ends of a span written backwards', () => { + expect(parseLineFragment('line-a.js-new:20-12')).toEqual({ + path: 'a.js', + side: 'new', + start: 12, + end: 20, + }); + }); + + it('names the row a link lands on', () => { + expect(lineId('src/pds.js', 'new:12')).toBe('line-src/pds.js-new:12'); + }); +}); + +describe('extendSelection', () => { + const at = { path: 'a.js', side: 'new', at: 20 }; + + it('selects one line on a plain click', () => { + expect(extendSelection(null, at)).toEqual({ + path: 'a.js', + side: 'new', + start: 20, + end: 20, + }); + }); + + it('takes the span from the line already selected', () => { + const first = { path: 'a.js', side: 'new', start: 12, end: 12 }; + expect(extendSelection(first, at, true)).toEqual({ + path: 'a.js', + side: 'new', + start: 12, + end: 20, + }); + }); + + // The line already selected is one end of the span, so a shift-click above + // it takes the lines between rather than starting again. + it('takes a span upwards from the line already selected', () => { + const first = { path: 'a.js', side: 'new', start: 30, end: 34 }; + expect(extendSelection(first, at, true)).toEqual({ + path: 'a.js', + side: 'new', + start: 20, + end: 30, + }); + }); + + it('starts again on another file or another side', () => { + const elsewhere = { path: 'b.js', side: 'new', start: 12, end: 12 }; + expect(extendSelection(elsewhere, at, true).start).toBe(20); + const otherSide = { path: 'a.js', side: 'old', start: 12, end: 12 }; + expect(extendSelection(otherSide, at, true).start).toBe(20); + }); +}); + +describe('inSelection', () => { + const selection = { path: 'a.js', side: 'new', start: 12, end: 20 }; + + it('marks the rows the selection names', () => { + expect(inSelection(selection, 'a.js', row(8, 12))).toBe(true); + expect(inSelection(selection, 'a.js', row(null, 16))).toBe(true); + expect(inSelection(selection, 'a.js', row(8, 11))).toBe(false); + }); + + it('marks nothing in another file, or with nothing selected', () => { + expect(inSelection(selection, 'b.js', row(null, 16))).toBe(false); + expect(inSelection(null, 'a.js', row(null, 16))).toBe(false); + }); + + // A deleted row is on the old side alone, which is the side it is addressed + // by, so a selection on the new side passes over it. + it('marks a deleted row only from the old side', () => { + expect(inSelection(selection, 'a.js', row(16, null))).toBe(false); + const old = { path: 'a.js', side: 'old', start: 12, end: 20 }; + expect(inSelection(old, 'a.js', row(16, null))).toBe(true); + }); +}); + +describe('sourceFragment and parseSourceFragment', () => { + it('carries a line and a span there and back', () => { + expect(sourceFragment({ start: 12, end: 12 })).toBe('L12'); + expect(sourceFragment({ start: 12, end: 20 })).toBe('L12-20'); + expect(parseSourceFragment('L12')).toEqual({ start: 12, end: 12 }); + expect(parseSourceFragment('L12-20')).toEqual({ start: 12, end: 20 }); + }); + + // A forge writes the second number with its own L, and a reader pasting one + // of those addresses means the same span. + it('reads a span whose second number carries an L', () => { + expect(parseSourceFragment('L12-L20')).toEqual({ start: 12, end: 20 }); + }); + + it('answers nothing to a fragment naming something else', () => { + expect(parseSourceFragment('line-a.js-new:12')).toBeNull(); + expect(parseSourceFragment('readme')).toBeNull(); + }); + + it('names the row a link lands on', () => { + expect(sourceLineId(12)).toBe('L12'); + expect(sourceLineId(12)).toBe(sourceFragment({ start: 12, end: 12 })); + }); +}); + +describe('extendSpan', () => { + it('selects one line on a plain click', () => { + expect(extendSpan({ start: 4, end: 9 }, 20)).toEqual({ + start: 20, + end: 20, + }); + }); + + it('takes the span from the line already selected, either way', () => { + expect(extendSpan({ start: 12, end: 12 }, 20, true)).toEqual({ + start: 12, + end: 20, + }); + expect(extendSpan({ start: 30, end: 34 }, 20, true)).toEqual({ + start: 20, + end: 30, + }); + }); + + it('selects one line when there is nothing to reach from', () => { + expect(extendSpan(null, 20, true)).toEqual({ start: 20, end: 20 }); + }); +});