From 312d27d6d4caba431b6b60c43f276abe6b9f4ebb Mon Sep 17 00:00:00 2001 From: sonali-dudhia Date: Mon, 26 Oct 2026 11:42:35 +0530 Subject: [PATCH] Draw quote blocks in commits and remotes, as the web does A line that starts with `>` now draws as a quote behind an amber rule on the left, marker dropped, like the web's blockquote from dev-null-sh/dev-null.sh#970. Lines next to each other join one quote, a `>` inside code or mid-line stays as written, and mentions, links and inline code inside a quote work as anywhere else. In your own remotes bubbles the rule takes the bubble's label colour. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/BodyBlock.php | 18 +++- app/Devnull/Data/CommitBody.php | 97 +++++++++++++++++-- .../native/partials/commit-body.blade.php | 9 ++ .../native/partials/message-body.blade.php | 11 ++- .../native/partials/message-item.blade.php | 4 +- tests/Feature/Screens/CommitDetailTest.php | 23 +++++ tests/Feature/Screens/ConversationTest.php | 17 ++++ tests/Unit/Devnull/CommitBodyTest.php | 73 ++++++++++++++ 8 files changed, 238 insertions(+), 14 deletions(-) diff --git a/app/Devnull/Data/BodyBlock.php b/app/Devnull/Data/BodyBlock.php index 38a0763..356e8f5 100644 --- a/app/Devnull/Data/BodyBlock.php +++ b/app/Devnull/Data/BodyBlock.php @@ -3,14 +3,15 @@ namespace App\Devnull\Data; /** - * A block of a commit body: a paragraph of runs, or a fenced code block. + * A block of a commit body: a paragraph of runs, a quote (lines that start + * with `>`, the marker dropped), or a fenced code block. */ final readonly class BodyBlock { /** * @param list $runs */ - private function __construct(public array $runs = [], public ?string $code = null, public ?string $language = null) {} + private function __construct(public array $runs = [], public ?string $code = null, public ?string $language = null, public bool $quoted = false) {} /** * @param list $runs @@ -20,6 +21,14 @@ public static function paragraph(array $runs): self return new self(runs: $runs); } + /** + * @param list $runs + */ + public static function quote(array $runs): self + { + return new self(runs: $runs, quoted: true); + } + public static function code(string $code, ?string $language = null): self { return new self(code: $code, language: $language); @@ -29,4 +38,9 @@ public function isCode(): bool { return $this->code !== null; } + + public function isQuote(): bool + { + return $this->quoted; + } } diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index 0559d6a..575d787 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -5,8 +5,8 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's * CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as - * the wordmark) and inline code flow together in paragraphs, and fenced code - * blocks stand on their own. + * the wordmark) and inline code flow together in paragraphs, lines that start + * with `>` group into quotes, and fenced code blocks stand on their own. * * Mention offsets count code points into the whole body, but segments aren't * exact slices of it: inline code loses its backticks, a block its fences, and @@ -146,10 +146,8 @@ public static function blocks(string $body, ?array $segments, array $mentions = break; case 'block': - if ($runs !== []) { - $blocks[] = BodyBlock::paragraph($runs); - $runs = []; - } + array_push($blocks, ...self::paragraphs($runs)); + $runs = []; [$code, $cursor] = self::fencedCode($body, $cursor); $language = $segment['language'] ?? null; @@ -160,13 +158,96 @@ public static function blocks(string $body, ?array $segments, array $mentions = } } - if ($runs !== []) { - $blocks[] = BodyBlock::paragraph($runs); + array_push($blocks, ...self::paragraphs($runs)); + + return $blocks; + } + + /** + * Splits a paragraph's runs into paragraphs and quotes, as the web's + * bodyBlocks() does: prose that starts a line with `>` quotes that line, + * minus the marker and one space after it, and the lines next to it that + * do the same join it. A `>` inside code, or anywhere but a line's start, + * stays as written. The line break before each change is dropped, since + * the blocks are drawn one under the other. + * + * @param list $runs + * @return list + */ + private static function paragraphs(array $runs): array + { + /** @var list}> $groups */ + $groups = []; + $atLineStart = true; + $quoted = false; + + foreach ($runs as $run) { + $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null; + + $lines = $prose + ? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: []) + : [$run]; + + foreach ($lines as $line) { + if ($atLineStart) { + $quoted = $prose && str_starts_with($line->text, '>'); + } + + if ($atLineStart && $quoted) { + $line = new BodyRun((string) preg_replace('/^> ?/', '', $line->text)); + } + + $last = array_key_last($groups); + + if ($last === null || $groups[$last]['quoted'] !== $quoted) { + if ($last !== null) { + $groups[$last]['runs'] = self::withoutTrailingBreak($groups[$last]['runs']); + } + + $groups[] = ['quoted' => $quoted, 'runs' => []]; + $last = array_key_last($groups); + } + + if ($line->text !== '') { + $groups[$last]['runs'][] = $line; + } + + $atLineStart = $prose && str_ends_with($line->text, "\n"); + } + } + + $blocks = []; + + foreach ($groups as $group) { + if ($group['runs'] !== [] || $group['quoted']) { + $blocks[] = $group['quoted'] ? BodyBlock::quote($group['runs']) : BodyBlock::paragraph($group['runs']); + } } return $blocks; } + /** + * @param list $runs + * @return list + */ + private static function withoutTrailingBreak(array $runs): array + { + $last = array_pop($runs); + + if ($last === null) { + return $runs; + } + + $text = (string) preg_replace('/\r?\n$/', '', $last->text); + + if ($text !== '') { + $runs[] = $last->text === $text ? $last : new BodyRun($text); + } + + return $runs; + } + /** * Splits prose that starts at $start in the body around the links inside * it, then the prose between them around its mentions, as the web does. diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 7b628cf..75118b7 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -10,6 +10,9 @@ slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null". + A quote, lines that start with ">", sits behind an amber rule on the left like the web's blockquote, its + runs drawn as a paragraph's. + A link is Commit Blue and opens on a tap, numbered by its place among the body's links. Like the web, a URL shows shortened, and a dev/null commit or a GitHub repository, issue or pull request shows as a mono name with the owner muted, held on one line by a word joiner after its slash. The web's dev/null and GitHub marks before those are icons, @@ -32,6 +35,9 @@ @else +@if ($block->isQuote()) + +@endif @foreach ($block->runs as $run) @if ($run->isMention()) @@ -49,6 +55,9 @@ @endif @endforeach +@if ($block->isQuote()) + +@endif @endif @endforeach @if ($folded) diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 293ae71..ca8a1b7 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,7 +1,8 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions and inline code, and fenced code as mono text in a box. $ink is the bubble's text colour, so a body - on your lane reads in that lane's label colour. + mentions and inline code, quotes behind a rule on the left, and fenced code as mono text in a box. $ink is the + bubble's text colour, so a body on your lane reads in that lane's label colour, and $quoteRule the quote's rule: + amber, or the label colour on your lane, as the web's pale rule in your own bubbles. --}} @foreach ($message->content as $block) @if ($block->isCode()) @@ -9,6 +10,9 @@ @else +@if ($block->isQuote()) + +@endif @foreach ($block->runs as $run) @if ($run->isMention()) @@ -19,5 +23,8 @@ @endif @endforeach +@if ($block->isQuote()) + +@endif @endif @endforeach diff --git a/resources/views/native/partials/message-item.blade.php b/resources/views/native/partials/message-item.blade.php index dfcbbd6..549b1b6 100644 --- a/resources/views/native/partials/message-item.blade.php +++ b/resources/views/native/partials/message-item.blade.php @@ -63,11 +63,11 @@ @endif @if ($message->isPending()) - @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour]) + @include('native.partials.message-body', ['ink' => $message->status === 'rejected' ? 'text-theme-ink' : 'text-theme-on-'.$myColour, 'mentionInk' => 'text-theme-on-'.$myColour, 'quoteRule' => $message->status === 'rejected' ? 'border-theme-lane-5' : 'border-theme-on-'.$myColour]) @else - @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary']) + @include('native.partials.message-body', ['ink' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-ink', 'mentionInk' => $mine ? 'text-theme-on-'.$myColour : 'text-theme-primary', 'quoteRule' => $mine ? 'border-theme-on-'.$myColour : 'border-theme-lane-5']) @endif @if (! $mine) diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 2f0ee42..fab2e25 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -448,6 +448,29 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); }); +it('draws quote lines behind an amber rule, without their markers', function () { + $body = "> Ship it on Friday?\n> @ada said so\nNot on my watch"; + + fakeThread([ + 'body' => $body, + 'body_segments' => [['type' => 'text', 'text' => $body]], + 'mentions' => [['username' => 'ada', 'offset' => 23, 'length' => 4]], + ]); + + $screen = Native::visit('/log/commits/c2') + ->assertSee('Not on my watch') + ->assertDontSee('> Ship it'); + + $quote = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'column' + && (float) ($node['props']['border_l'] ?? 0) === 2.0); + + expect($quote)->not->toBeNull() + ->and($quote['props'])->toMatchArray(['border_t' => 0, 'border_r' => 0, 'border_b' => 0]) + ->and(strtolower($quote['style']['border_color']))->toBe(strtolower((string) theme('lane-5'))) + ->and(paragraphRuns($screen, 'Ship it'))->toBe([["Ship it on Friday?\n"], ['@ada', 'mention'], [' said so']]) + ->and(paragraphRuns($screen, 'Not on my watch'))->toBe([['Not on my watch']]); +}); + it('shows the focused commit with its media and stars', function () { fakeThread([ 'author' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace']), diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 7864d17..fcd0c34 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -68,6 +68,23 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->and(nodeByRef($screen, 'thread')['props']['scroll_anchor'] ?? null)->toBe('bottom'); }); +it('draws quotes in messages behind a rule, amber in theirs and in the label colour in yours', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => "> did you see the PR?\nnot yet"]), + remoteMessageJson(['id' => 2, 'user_id' => RemotesViewerId, 'body' => "> not yet\nlooking now"]), + ])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')) + ->assertSee('did you see the PR?') + ->assertDontSee('> did you see the PR?'); + + $rule = fn (string $ref): ?string => findNode(nodeByRef($screen, $ref), fn (array $node): bool => (float) ($node['props']['border_l'] ?? 0) === 2.0)['style']['border_color'] ?? null; + + expect(strtolower((string) $rule('bubble-1')))->toBe(strtolower((string) theme('lane-5'))) + ->and(strtolower((string) $rule('bubble-2')))->not->toBe(strtolower((string) theme('lane-5'))) + ->and($rule('bubble-2'))->not->toBeNull(); +}); + it('marks HEAD where you left off, and pulls what is showing', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'first']), diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index 135c372..ea950e1 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -299,3 +299,76 @@ function linkIn(array $blocks): array 'https://gist.github.com/laravel/framework', 'https://notgithub.com/laravel/framework', ]); + +/** + * Each block as its kind and its text, runs joined. + * + * @param list $blocks + * @return list + */ +function quotesOf(array $blocks): array +{ + return array_map(fn (BodyBlock $block): array => [ + match (true) { + $block->isCode() => 'code', + $block->isQuote() => 'quote', + default => 'paragraph', + }, + $block->isCode() ? (string) $block->code : implode('', array_map(fn (BodyRun $run): string => $run->text, $block->runs)), + ], $blocks); +} + +it('groups lines that start with > into quotes, as the web does', function () { + $body = "> First `code`\n> @ada https://example.com\nNormal > text\n>Last"; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => '> First '], + ['type' => 'code', 'code' => 'code'], + ['type' => 'text', 'text' => "\n> @ada https://example.com\nNormal > text\n>Last"], + ], [new Mention('ada', 17, 4)]); + + expect(quotesOf($blocks))->toBe([ + ['quote', "First code\n@ada https://example.com"], + ['paragraph', 'Normal > text'], + ['quote', 'Last'], + ])->and(array_map(fn (BodyRun $run): array => [$run->text, $run->isCode, $run->username, $run->link?->href], $blocks[0]->runs))->toBe([ + ['First ', false, null, null], + ['code', true, null, null], + ["\n", false, null, null], + ['@ada', false, 'ada', null], + [' ', false, null, null], + ['https://example.com', false, null, 'https://example.com'], + ]); +}); + +it('leaves a > that is inside code or not at the start of a line as written', function () { + $body = "`code`> literal\n```text\n> fenced\n```\n> quoted\n>\n> `> inline`\n\nAfter"; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'code', 'code' => 'code'], + ['type' => 'text', 'text' => '> literal'], + ['type' => 'block', 'language' => 'text'], + ['type' => 'text', 'text' => "> quoted\n>\n> "], + ['type' => 'code', 'code' => '> inline'], + ['type' => 'text', 'text' => "\n\nAfter"], + ]); + + expect(quotesOf($blocks))->toBe([ + ['paragraph', 'code> literal'], + ['code', '> fenced'], + ['quote', "quoted\n\n> inline"], + ['paragraph', "\nAfter"], + ]); +}); + +it('keeps Windows line breaks inside a quote and drops the one before the next block', function () { + expect(quotesOf(CommitBody::blocks("Before\r\n> one\r\n> two\r\nAfter", null)))->toBe([ + ['paragraph', 'Before'], + ['quote', "one\r\ntwo"], + ['paragraph', 'After'], + ]); +}); + +it('quotes a line that is only the marker', function () { + expect(quotesOf(CommitBody::blocks('>', null)))->toBe([['quote', '']]); +});