From 7e662abcd2d0a9f55234bc114e1e6e03bb67d987 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 6 Nov 2026 14:04:55 +0000 Subject: [PATCH] Link hashtags to a commit search, as the web does A hashtag in a commit, a reply, a scheduled commit or a Remotes message now shows in Commit Blue at medium weight. Tapping it opens Search in the same tab with the hashtag in the field and its commits loading, the way the web links a hashtag to its search page with q set to it. It uses the web's rule from dev-null-sh/dev-null.sh@b7456c2c: a # and a letter, then letters, marks, digits and underscores, not straight after a word character, &, /, #, > or -. It is found in the prose only, after links, mentions and colors, so #fff stays a color and a # inside a URL, a mention or code is left alone. The composer's preview leaves hashtags as plain text, as the web's static preview does. Search opened this way doesn't focus its field, as on the web, so the keyboard stays out of the results. The # counts towards the three characters devnull needs to search commits, as it does on the web. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/BodyRun.php | 9 +- app/Devnull/Data/CommitBody.php | 113 ++++++++++++++---- .../Concerns/OpensHashtags.php | 17 +++ .../Concerns/ShowsCommits.php | 1 + app/NativeComponents/Conversation.php | 2 + app/NativeComponents/ScheduledCommits.php | 2 + app/NativeComponents/Search.php | 27 +++++ .../native/partials/commit-body.blade.php | 5 + .../partials/composer-preview.blade.php | 2 +- .../native/partials/message-body.blade.php | 11 +- resources/views/native/search.blade.php | 2 +- tests/Feature/Screens/CommitDetailTest.php | 27 +++++ tests/Feature/Screens/ComposerPreviewTest.php | 11 ++ tests/Feature/Screens/ConversationTest.php | 16 +++ tests/Feature/Screens/SearchTest.php | 20 ++++ tests/Pest.php | 6 +- tests/Unit/Devnull/CommitBodyTest.php | 57 +++++++++ 17 files changed, 297 insertions(+), 31 deletions(-) create mode 100644 app/NativeComponents/Concerns/OpensHashtags.php diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index 59f93131..02295ad5 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -5,13 +5,14 @@ /** * A run of a paragraph: plain prose, a mention, inline code, a link, or a * standalone `/dev/null`, which draws as the wordmark, or a hex color, which - * draws with a swatch of it. A link's text is the URL as written, and a - * color's is the color as written, `#` included. A mention of a GitHub - * organization has its handle in $username too, and $isOrganization set. + * draws with a swatch of it, or a hashtag, which searches commits for it. A + * link's text is the URL as written, and a color's or a hashtag's is as + * written, `#` included. A mention of a GitHub organization has its handle in + * $username too, and $isOrganization set. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null, public bool $isOrganization = false) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null, public bool $isOrganization = false, public bool $isHashtag = false) {} public function isMention(): bool { diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index ac7ea774..0adf5784 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,12 +4,12 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with URLs and mentions linked, hex colors beside a - * swatch, and `/dev/null` as the wordmark) and inline code flow together in - * paragraphs, lines that start with `>` group into quotes, and fenced code - * blocks stand on their own. + * CommitBody does: prose (with URLs, mentions and hashtags linked, hex colors + * beside a swatch, and `/dev/null` as 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 and color offsets count code points into the whole body, but segments aren't + * Mention, color and hashtag 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 * prose the line break next to a block. So each segment is found in the body, * moving forward, and mentions are placed by where their segment starts. @@ -41,6 +41,14 @@ final class CommitBody */ private const string Color = '/(?:(?))[#\x{FF03}]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\x{00AD}\x{2060}\x{200A}-\x{200D}\x{20E2}]|\z))/iu'; + /** + * A hashtag: a `#` and a letter, then letters, marks, digits and + * underscores, not straight after a word character, `&`, `/`, `#`, `>` or + * `-`, so `a#b`, `{` and a URL's fragment aren't one. The web's rule, + * from linkified-text.tsx. + */ + private const string Hashtag = '/(?-])#\p{L}[\p{L}\p{M}\p{N}_]*/u'; + /** * A `/dev/null` that stands on its own, not part of a longer path, a word * or a name. The web's rule, from withWordmarks() in linkified-text.tsx. @@ -116,6 +124,7 @@ public static function blocks(string $body, ?array $segments, array $mentions = $runs = []; $cursor = 0; $colors = self::colors($body); + $hashtags = self::hashtags($body); foreach ($segments as $segment) { switch ($segment['type'] ?? null) { @@ -132,12 +141,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [])); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [], [])); break; } - array_push($runs, ...self::withLinks($text, $start, $mentions, $colors)); + array_push($runs, ...self::withLinks($text, $start, $mentions, $colors, $hashtags)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -193,7 +202,7 @@ private static function paragraphs(array $runs): array $quoted = false; foreach ($runs as $run) { - $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor(); + $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor() && ! $run->isHashtag; $lines = $prose ? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: []) @@ -266,15 +275,16 @@ private static function withoutTrailingBreak(array $runs): array * * @param list $mentions * @param list $colors + * @param list $hashtags * @return list */ - private static function withLinks(string $text, ?int $start, array $mentions, array $colors): array + private static function withLinks(string $text, ?int $start, array $mentions, array $colors, array $hashtags): array { $runs = []; $position = 0; $prose = fn (string $part, int $at): array => $start === null ? self::withWordmarks($part) - : self::withMentions($part, $start + $at, $mentions, $colors); + : self::withMentions($part, $start + $at, $mentions, $colors, $hashtags); foreach (BodyLink::in($text) as [$offset, $length, $link]) { if ($offset > $position) { @@ -297,9 +307,10 @@ private static function withLinks(string $text, ?int $start, array $mentions, ar * * @param list $mentions * @param list $colors + * @param list $hashtags * @return list */ - private static function withMentions(string $text, int $start, array $mentions, array $colors): array + private static function withMentions(string $text, int $start, array $mentions, array $colors, array $hashtags): array { $runs = []; $position = 0; @@ -321,7 +332,7 @@ private static function withMentions(string $text, int $start, array $mentions, } if ($offset > $position) { - array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors)); + array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors, $hashtags)); } $runs[] = new BodyRun($handle, username: $mention->username, isOrganization: $mention->isOrganization); @@ -329,7 +340,7 @@ private static function withMentions(string $text, int $start, array $mentions, } if ($position < $length) { - array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors)); + array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors, $hashtags)); } return $runs; @@ -340,9 +351,10 @@ private static function withMentions(string $text, int $start, array $mentions, * in the whole body that fall inside it, as the web's withColors() does. * * @param list $colors + * @param list $hashtags * @return list */ - private static function withColors(string $text, int $start, array $colors): array + private static function withColors(string $text, int $start, array $colors, array $hashtags): array { $runs = []; $position = 0; @@ -355,11 +367,42 @@ private static function withColors(string $text, int $start, array $colors): arr continue; } - array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + array_push($runs, ...self::withHashtags(mb_substr($text, $position, $offset - $position), $start + $position, $hashtags)); $runs[] = new BodyRun(mb_substr($text, $offset, $colorLength), color: $value); $position = $offset + $colorLength; } + array_push($runs, ...self::withHashtags(mb_substr($text, $position), $start + $position, $hashtags)); + + return $runs; + } + + /** + * Splits prose that starts at $start in the body around the hashtags + * found in the whole body that fall inside it, as the web's withHashtags() + * does. One a color already took, like `#fff`, never falls inside. + * + * @param list $hashtags + * @return list + */ + private static function withHashtags(string $text, int $start, array $hashtags): array + { + $runs = []; + $position = 0; + $length = mb_strlen($text); + + foreach ($hashtags as [$offset, $hashtagLength]) { + $offset -= $start; + + if ($offset < $position || $offset + $hashtagLength > $length) { + continue; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + $runs[] = new BodyRun(mb_substr($text, $offset, $hashtagLength), isHashtag: true); + $position = $offset + $hashtagLength; + } + array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); return $runs; @@ -375,11 +418,7 @@ private static function withColors(string $text, int $start, array $colors): arr */ private static function colors(string $body): array { - $prose = ''; - - foreach (self::parts($body) as $index => $part) { - $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); - } + $prose = self::prose($body); preg_match_all(self::Color, "\n".$prose, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); @@ -390,6 +429,40 @@ private static function colors(string $body): array ], $matches); } + /** + * The hashtags in the body's prose, in order, as where each starts and how + * long it is in code points, `#` included. Code is blanked out first, so + * nothing in it counts. + * + * @return list + */ + private static function hashtags(string $body): array + { + $prose = self::prose($body); + + preg_match_all(self::Hashtag, $prose, $matches, PREG_OFFSET_CAPTURE); + + return array_map(fn (array $match): array => [ + mb_strlen(substr($prose, 0, $match[1])), + mb_strlen($match[0]), + ], $matches[0]); + } + + /** + * The body with its code blanked out to spaces of the same length, so + * what is found in the prose keeps its place in the body. + */ + private static function prose(string $body): string + { + $prose = ''; + + foreach (self::parts($body) as $index => $part) { + $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); + } + + return $prose; + } + /** * Splits prose around each standalone `/dev/null`, which becomes a * wordmark run. Like the web, the prose between two mentions is looked at diff --git a/app/NativeComponents/Concerns/OpensHashtags.php b/app/NativeComponents/Concerns/OpensHashtags.php new file mode 100644 index 00000000..b7b5b1be --- /dev/null +++ b/app/NativeComponents/Concerns/OpensHashtags.php @@ -0,0 +1,17 @@ +openInTab('/search', ['query' => $hashtag]); + } +} diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index da0f7fea..05407927 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -43,6 +43,7 @@ trait ShowsCommits { use OpensCommitSheet; + use OpensHashtags; use OpensStargazers; use VotesOnPolls; use WritesInBackground; diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 2302f411..2f4d0ba9 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -24,6 +24,7 @@ use App\Icons\Lucide; use App\Models\PendingWrite; use App\NativeComponents\Concerns\AttachesPhotos; +use App\NativeComponents\Concerns\OpensHashtags; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\PlaysVoiceNotes; use App\NativeComponents\Concerns\RecordsVoiceNotes; @@ -103,6 +104,7 @@ class Conversation extends NativeComponent { use AttachesPhotos; + use OpensHashtags; use OpensInTab; use PlaysVoiceNotes; use RecordsVoiceNotes; diff --git a/app/NativeComponents/ScheduledCommits.php b/app/NativeComponents/ScheduledCommits.php index 180bc470..2b0be955 100644 --- a/app/NativeComponents/ScheduledCommits.php +++ b/app/NativeComponents/ScheduledCommits.php @@ -9,6 +9,7 @@ use App\Devnull\SiteLink; use App\Devnull\Timezone; use App\Devnull\Toasts; +use App\NativeComponents\Concerns\OpensHashtags; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\TalksToDevnull; @@ -36,6 +37,7 @@ */ class ScheduledCommits extends NativeComponent { + use OpensHashtags; use OpensInTab; use RefreshesInBackground; use TalksToDevnull; diff --git a/app/NativeComponents/Search.php b/app/NativeComponents/Search.php index 725e52f5..c82ff53d 100644 --- a/app/NativeComponents/Search.php +++ b/app/NativeComponents/Search.php @@ -31,6 +31,10 @@ * devnull won't search commits for fewer than three characters, so a shorter * term only finds developers. * + * A hashtag tapped in a commit or a message opens it with the hashtag as the + * term ($data['query']), searched straight away, as the web's hashtag links + * do, and the field isn't focused, so the keyboard leaves the results in view. + * * Neither answer is kept (see ResponseCache): a search is about the moment. */ class Search extends NativeComponent @@ -66,6 +70,11 @@ class Search extends NativeComponent public string $query = ''; + /** + * Whether the field takes focus when the screen opens: only for an empty one, as on the web. + */ + public bool $autofocus = true; + /** @var list */ public array $people = []; @@ -87,6 +96,16 @@ class Search extends NativeComponent */ private ?RowCache $rowCache = null; + public function mount(): void + { + $query = $this->data('query'); + + if (is_string($query) && trim($query) !== '') { + $this->autofocus = false; + $this->pick($query); + } + } + public function navTitle(): string { return 'Search'; @@ -106,6 +125,14 @@ public function pick(string $term): void $this->search(); } + /** + * A hashtag in one of the results searches for it here, in place of what's in the field. + */ + public function openHashtag(string $hashtag): void + { + $this->pick($hashtag); + } + public function open(string $username): void { $person = $this->person($username); diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 349ac9e1..2e447d8c 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -13,6 +13,9 @@ A hex color has a square of that color before it, as the web's swatch, held to it by a narrow no-break space so they wrap together. + A hashtag is medium weight in Commit Blue and searches commits for it on a tap, as the web's. In the composer's + preview ($static), as in the web's static preview, it stays plain text. + 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. @@ -52,6 +55,8 @@ @elseif ($run->isColor())   +@elseif ($run->isHashtag && ! ($static ?? false)) + @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/resources/views/native/partials/composer-preview.blade.php b/resources/views/native/partials/composer-preview.blade.php index f56ce5de..7959f818 100644 --- a/resources/views/native/partials/composer-preview.blade.php +++ b/resources/views/native/partials/composer-preview.blade.php @@ -8,7 +8,7 @@ @if (trim($sheetBody) === '' && $sheetImages === [] && $sheetPoll === null) Nothing to preview yet. @else - @include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true]) + @include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true, 'static' => true]) @include('native.partials.commit-media', ['commit' => $draftCommit, 'withEmbeds' => false, 'scoreAction' => false]) @if ($draftCommit->poll) diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 9edf3c3a..a69fc811 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,11 +1,12 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions, links and inline code, hex colors beside their swatch, 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. + mentions, links and hashtags, inline code, hex colors beside their swatch, 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. A link is in $linkInk, Commit Blue off your lane and the lane's label colour on it, with a named link's owner muted in $linkPrefixInk, and opens by its place among the body's links (see Conversation::openMessageLink). + A hashtag is medium weight in $mentionInk, as a mention is, and searches commits for it on a tap. $maxLines caps the body to the first block at that many lines, for the pinned sheet's preview, so its links work before Show more. --}} @@ -33,6 +34,8 @@ @elseif ($run->isColor())   +@elseif ($run->isHashtag) + @else @endif diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index 1172e448..29f914c8 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -10,7 +10,7 @@ @include('native.partials.live-stream-sheet') - @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) + @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => $autofocus, 'a11yLabel' => 'Search developers or commits']) diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index f9ce6fc6..fc0da284 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -581,6 +581,33 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); }); +it('links a hashtag to a search for it in the tab, leaving a hex color and code as they are', function () { + $body = 'Shipped #laravel in #fff and `#code`'; + + fakeThread([ + 'body' => $body, + 'body_segments' => [ + ['type' => 'text', 'text' => 'Shipped #laravel in #fff and '], + ['type' => 'code', 'code' => '#code'], + ], + ]); + + $screen = Native::visit('/log/commits/c2'); + + expect(paragraphRuns($screen, 'Shipped'))->toBe([ + ['Shipped '], + ['#laravel', 'hashtag'], + [' in '], + ["■\u{202F}#fff", 'wordmark'], + [' and '], + ['#code', 'code'], + ]); + + $screen->press("openHashtag('#laravel')")->assertNavigatedTo('/log/search'); + + expect($screen->instance()->getNavigationIntent()->data)->toBe(['query' => '#laravel']); +}); + it('draws a hex color after a square of that color, in light and dark mode alike', function () { $body = 'Brand orange is #ff8a4c, not #123'; diff --git a/tests/Feature/Screens/ComposerPreviewTest.php b/tests/Feature/Screens/ComposerPreviewTest.php index 90e41ac0..8f66b849 100644 --- a/tests/Feature/Screens/ComposerPreviewTest.php +++ b/tests/Feature/Screens/ComposerPreviewTest.php @@ -66,6 +66,17 @@ function previewComposerOnLog(?array $preview = null): TestableComponent ->and(treeShows(nodeByRef($screen, 'sheet-draft-preview'), 'Shipped 🚀 with '))->toBeTrue(); }); +it('leaves a hashtag in the preview as plain text, as the web does', function () { + $screen = finishRefreshes(previewComposerOnLog([['data' => [ + 'body' => 'Shipped #laravel', + 'body_segments' => [['type' => 'text', 'text' => 'Shipped #laravel']], + 'preview_segments' => null, + 'mentions' => [], + ]]])->input('sheet-body', 'Shipped #laravel')->tap('sheet-preview')); + + expect(paragraphRuns($screen, 'Shipped #laravel'))->toBe([['Shipped '], ['#laravel']]); +}); + it('shows the photos, the poll and the commit being quoted in the preview', function () { $screen = previewComposerOnLog() ->call('quoteCommit', 'c1') diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 14d6bc75..ed33e1a3 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -723,6 +723,22 @@ function pulledAvatars(TestableComponent $screen, int $id): array finishRefreshes(Native::visit('/remotes/conversations/ada'))->press("openProfile('grace')")->assertNavigatedTo('/remotes/users/grace'); }); +it('links a hashtag in a message to a search for it in the tab', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'loving #nativephp lately']), + ], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(paragraphRuns($screen, 'loving'))->toBe([ + ['loving '], + ['#nativephp', 'hashtag'], + [' lately'], + ]); + + $screen->press("openHashtag('#nativephp')")->assertNavigatedTo('/remotes/search'); +}); + it('opens no link preview that is not a web page', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'Tap me', 'link_preview' => ['url' => 'javascript:alert(1)', 'title' => 'Tap me']]), diff --git a/tests/Feature/Screens/SearchTest.php b/tests/Feature/Screens/SearchTest.php index 5c24047c..c9ad12a8 100644 --- a/tests/Feature/Screens/SearchTest.php +++ b/tests/Feature/Screens/SearchTest.php @@ -114,6 +114,26 @@ function searchedFor(string $kind): array ->and(searchedFor('commits'))->toBe(['@ada']); }); +it('searches commits for a tapped hashtag straight away, without taking focus', function () { + serveSearch(commits: [commitJson(['id' => 'c1', 'body' => 'Shipped it #ab with #laravel'])]); + + $screen = Native::visit('/log/search', ['query' => '#ab']) + ->assertSet('query', '#ab') + ->assertSet('autofocus', false) + ->assertSee('1 match') + ->assertSee('Shipped it'); + + // The # counts towards the three characters devnull searches commits for, as on the web. + expect(searchedFor('commits'))->toBe(['#ab']); + + // A hashtag in the results searches for it here. + $screen->press("openHashtag('#laravel')") + ->assertNoNavigation() + ->assertSet('query', '#laravel'); + + expect(searchedFor('commits'))->toBe(['#ab', '#laravel']); +}); + it('only finds developers for a term too short to search commits by', function () { serveSearch(); diff --git a/tests/Pest.php b/tests/Pest.php index 65830772..41e32e2e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -667,7 +667,11 @@ function paragraphRuns(TestableComponent $screen, string $text): array $text = $run['props']['text'] ?? str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children'] ?? []))); return match (true) { - isset($run['on_press']) => [$text, str_starts_with($text, '@') ? 'mention' : 'link'], + isset($run['on_press']) => [$text, match (true) { + str_starts_with($text, '@') => 'mention', + str_starts_with($text, '#') => 'hashtag', + default => 'link', + }], isset($run['style']['bg_color']) => [$text, 'code'], isset($run['children']) => [$text, 'wordmark'], default => [$text], diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index 8be74974..7872bbb3 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -374,6 +374,63 @@ function colorsOf(string $body): array expect(colorsOf('See https://example.com/#fff for #abc'))->toBe([['#abc', 'abc']]); }); +/** + * The hashtags in a body that is all prose, as written. + * + * @return list + */ +function hashtagsOf(string $body): array +{ + $hashtags = []; + + foreach (CommitBody::blocks($body, null) as $block) { + foreach ($block->runs as $run) { + if ($run->isHashtag) { + $hashtags[] = $run->text; + } + } + } + + return $hashtags; +} + +it('links hashtags, as the web does', function () { + expect(hashtagsOf('Shipped it #buildinpublic, then #laravel_12.'))->toBe(['#buildinpublic', '#laravel_12']) + ->and(runsOf(CommitBody::blocks('Shipped #laravel today', null)))->toBe([ + ['Shipped '], + ['#laravel'], + [' today'], + ]); +}); + +it('links hashtags in any script, counting code points so what comes before them does not shift them', function () { + expect(hashtagsOf('🚀 Bom dia #café e #日本語 #Ünïcödé'))->toBe(['#café', '#日本語', '#Ünïcödé']); +}); + +it('leaves a hex color as a color rather than a hashtag', function () { + expect(hashtagsOf('White is #fff and orange #ff8a4c, but #fffoo is a tag'))->toBe(['#fffoo']) + ->and(colorsOf('White is #fff'))->toBe([['#fff', 'fff']]); +}); + +it('only takes a hashtag that starts with a letter and does not follow a word, &, /, #, > or -', function () { + expect(hashtagsOf('a#b { #123 ##two x-#dash >#quote /#path #1st (#ok)'))->toBe(['#ok']); +}); + +it('leaves hashtags in URLs, mentions and code alone', function () { + $body = 'See https://example.com/a#section with @ada#x and `#code` then #real'; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'See https://example.com/a#section with @ada#x and '], + ['type' => 'code', 'code' => '#code'], + ['type' => 'text', 'text' => ' then #real'], + ], [new Mention('ada', 39, 4)]); + + $hashtags = array_values(array_map(fn (BodyRun $run): string => $run->text, array_filter($blocks[0]->runs, fn (BodyRun $run): bool => $run->isHashtag))); + + expect($hashtags)->toBe(['#real']) + ->and(hashtagsOf("```\n#block\n```\nand #after"))->toBe(['#after']); +}); + /** * Each block as its kind and its text, runs joined. *