From 094803b0cf7b2edc71549832811c806d74c60d33 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 29 Oct 2026 20:25:14 +0530 Subject: [PATCH] Search developers and commits from the Log, as the web does A search button beside New commit in the Log's top bar opens Search on the Log's stack: one field for developers or commits, as the web's phone header opens it. Before you type it offers the web's searches to try. Then the developers whose name or handle matches come first, with Follow beside them, then the commits nearest in meaning, closest first. It asks the signed API's search/users and search/commits, so the people you've muted or blocked stay out, and neither answer is kept on the phone. --- app/Devnull/Client.php | 6 + app/NativeComponents/Log.php | 31 +- app/NativeComponents/Search.php | 270 ++++++++++++++++++ .../native/partials/people-search.blade.php | 2 +- resources/views/native/search.blade.php | 70 +++++ routes/mobile.php | 2 + tests/Feature/Screens/SearchTest.php | 190 ++++++++++++ 7 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 app/NativeComponents/Search.php create mode 100644 resources/views/native/search.blade.php create mode 100644 tests/Feature/Screens/SearchTest.php diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 40bd8a7d..59871611 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -97,6 +97,12 @@ /** Whom the commit composers suggest for an @mention, as the web's composers ask. */ public const string MentionSuggestionsPath = 'search/mentions'; + /** The people matching a name or handle, as the web's search finds them, with whether you follow each one. */ + public const string UserSearchPath = 'search/users'; + + /** The commits nearest in meaning to a term, closest first, as the web's search finds them. */ + public const string CommitSearchPath = 'search/commits'; + /** * Where a `/feature` or `/bug` draft goes, to be opened as an issue upstream. */ diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 9d03448e..3fe6ebbc 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -17,6 +17,7 @@ use App\Devnull\Timeline; use App\Icons\Android; use App\Icons\Ios; +use App\Icons\Lucide; use App\Models\PendingWrite; use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\ExpandsThreads; @@ -324,14 +325,32 @@ public function navTitle(): string return 'devnull'; } + /** + * Search, as the web's phone header has it, then a new commit. + */ public function navigationOptions(): ?NavBarOptions { - return NavBarOptions::make()->action( - NavAction::make('compose') - ->icon(ios: Ios::SquareAndPencil, android: Android::Edit) - ->a11yLabel('New commit') - ->press('composeInSheet'), - ); + return NavBarOptions::make() + ->action( + NavAction::make('search') + ->icon(ios: Lucide::Search->ios(), android: Lucide::Search->android()) + ->a11yLabel('Search') + ->press('openSearch'), + ) + ->action( + NavAction::make('compose') + ->icon(ios: Ios::SquareAndPencil, android: Android::Edit) + ->a11yLabel('New commit') + ->press('composeInSheet'), + ); + } + + /** + * Search opens on the Log's own stack, so back comes here. + */ + public function openSearch(): void + { + $this->openInTab('/search'); } /** diff --git a/app/NativeComponents/Search.php b/app/NativeComponents/Search.php new file mode 100644 index 00000000..725e52f5 --- /dev/null +++ b/app/NativeComponents/Search.php @@ -0,0 +1,270 @@ + */ + public array $people = []; + + /** + * The term the people were found for, so a slow answer never lands under a newer one. + */ + public ?string $peopleFoundFor = null; + + /** @var list */ + public array $commits = []; + + /** + * The term the commits were found for. + */ + public ?string $commitsFoundFor = null; + + /** + * The rows from the last frame, so a render only builds the rows that changed, as on the Log. + */ + private ?RowCache $rowCache = null; + + public function navTitle(): string + { + return 'Search'; + } + + public function updatedQuery(): void + { + $this->search(); + } + + /** + * One of the web's searches to try, or a term picked some other way: it goes in the field and is searched. + */ + public function pick(string $term): void + { + $this->query = $term; + $this->search(); + } + + public function open(string $username): void + { + $person = $this->person($username); + + if ($person !== null) { + $this->openInTab('/users/'.rawurlencode($person->user->username), ['user' => $person->user]); + } + } + + /** + * Follows or unfollows straight away, and puts it back if the server says no, as in a list of followers. + */ + public function toggleFollow(string $username): void + { + $person = $this->person($username); + + if ($person === null || $this->isMe($username)) { + return; + } + + $following = ! $person->isFollowed; + $this->setFollowed($username, $following); + + $done = $this->attempt(function (Client $devnull) use ($username, $following): bool { + $following ? $devnull->follow($username) : $devnull->unfollow($username); + + return true; + }); + + if ($done !== true) { + $this->setFollowed($username, ! $following); + } + } + + public function isMe(string $username): bool + { + return $this->me()?->username === $username; + } + + /** + * The term as typed, trimmed. Developers are found without the @ a handle is typed with, as on the web. + */ + protected function term(): string + { + return trim($this->query); + } + + protected function searchesCommits(string $term): bool + { + return mb_strlen($term) >= self::ShortestCommitTerm; + } + + /** + * Asks devnull for the developers and the commits matching the term. An emptied field shows the searches to try. + */ + protected function search(): void + { + $term = $this->term(); + $this->refreshFailed = false; + $this->error = null; + + if ($term === '') { + $this->people = []; + $this->peopleFoundFor = null; + $this->commits = []; + $this->commitsFoundFor = null; + + return; + } + + $this->refresh(Client::UserSearchPath, ['q' => ltrim($term, '@')], function (array $json) use ($term): void { + if ($this->term() !== $term) { + return; + } + + $people = is_array($json['data'] ?? null) ? $json['data'] : []; + $this->people = array_values(array_map(Connection::fromArray(...), array_filter($people, 'is_array'))); + $this->peopleFoundFor = $term; + }); + + if (! $this->searchesCommits($term)) { + $this->commits = []; + $this->commitsFoundFor = null; + + return; + } + + $this->refresh(Client::CommitSearchPath, ['q' => $term], function (array $json) use ($term): void { + if ($this->term() !== $term) { + return; + } + + $commits = is_array($json['data'] ?? null) ? $json['data'] : []; + $this->commits = array_values(array_map(Commit::fromArray(...), array_filter($commits, 'is_array'))); + $this->commitsFoundFor = $term; + }); + } + + /** + * The quiet line's retry, and a refresh after a failure: the same term again. + */ + protected function revalidate(): void + { + $this->search(); + } + + protected function showsContent(): bool + { + return $this->people !== [] || $this->commits !== []; + } + + /** + * @return iterable + */ + protected function shownCommits(): iterable + { + return [...$this->commits, ...$this->threadCommits()]; + } + + protected function updateShownCommits(Closure $update): void + { + $this->commits = array_map($update, $this->commits); + $this->threads = array_map(fn (array $replies): array => array_map($update, $replies), $this->threads); + } + + private function person(string $username): ?Connection + { + foreach ($this->people as $person) { + if ($person->user->username === $username) { + return $person; + } + } + + return null; + } + + private function setFollowed(string $username, bool $following): void + { + $this->people = array_map( + fn (Connection $person): Connection => $person->user->username === $username ? $person->withFollowed($following) : $person, + $this->people, + ); + } + + public function render(): View + { + $term = $this->term(); + $viewer = $this->me(); + $this->rowCache ??= new RowCache; + $this->rowCache->startFrame(); + + return view('native.search', [ + 'term' => $term, + 'searchesCommits' => $this->searchesCommits($term), + 'rowCache' => $this->rowCache, + 'viewer' => $viewer, + 'stashed' => app(Stash::class)->names($viewer?->id), + 'myColour' => $this->myColour(), + 'rows' => Timeline::ranked([], $this->commits), + ]); + } +} diff --git a/resources/views/native/partials/people-search.blade.php b/resources/views/native/partials/people-search.blade.php index f8be822a..c2b5ad2d 100644 --- a/resources/views/native/partials/people-search.blade.php +++ b/resources/views/native/partials/people-search.blade.php @@ -9,7 +9,7 @@ class="w-full text-theme-ink" native:model.debounce.250ms="query" :autofocus="$autofocus ?? false" :max-length="100" - a11y-label="Find people" + :a11y-label="$a11yLabel ?? 'Find people'" /> diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php new file mode 100644 index 00000000..1172e448 --- /dev/null +++ b/resources/views/native/search.blade.php @@ -0,0 +1,70 @@ +{{-- + The web's search: one field for developers or commits. Before you type, the web's searches to try. Then the + developers whose name or handle matches, then the commits nearest in meaning, on a rail of their own as the web's + results page draws them. A term with nothing near it answers in git's voice. +--}} + + @include('native.partials.error') + @include('native.partials.refresh-failed') + @include('native.partials.commit-sheet') + @include('native.partials.live-stream-sheet') + + + @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) + + + + + @if ($term === '') + + + Try searching for… + + @foreach (\App\NativeComponents\Search::Suggestions as $suggestion) + + + + {{ $suggestion }} + + + + + @endforeach + @else + @if ($people !== []) + + Developers + + @foreach ($people as $person) + @include('native.partials.connection', ['person' => $person, 'isMe' => $person->user->username === $viewer?->username, 'showsFollowsYou' => true, 'myColour' => $myColour]) + @endforeach + @endif + + @if ($searchesCommits) + + Most relevant commits + @if ($commitsFoundFor !== null) + {{ count($commits) }} {{ count($commits) === 1 ? 'match' : 'matches' }} + @endif + + + @if ($commitsFoundFor === null && $error === null) + @foreach (range(1, 3) as $skeleton) + @include('native.partials.rail-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Searching commits']) + @endforeach + @elseif ($commitsFoundFor !== null && $commits === []) + @include('native.partials.empty', [ + 'command' => "fatal: nothing close to '{$commitsFoundFor}'", + 'message' => 'Try describing the idea in other words.', + ]) + @else + @include('native.partials.feed', ['rows' => $rows, 'rowCache' => $rowCache]) + @endif + @elseif ($peopleFoundFor === $term && $people === []) + + fatal: nothing matches '{{ $term }}' + + @endif + @endif + + diff --git a/routes/mobile.php b/routes/mobile.php index c60918d7..e0b3087a 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -18,6 +18,7 @@ use App\NativeComponents\Remotes; use App\NativeComponents\Safety; use App\NativeComponents\ScheduledCommits; +use App\NativeComponents\Search; use App\NativeComponents\Starred; use App\NativeComponents\ThisDevice; use Illuminate\Support\Facades\Route; @@ -51,5 +52,6 @@ Route::native("{$tab}/orgs/{org}/members", OrgMembers::class); Route::native("{$tab}/orgs/{org}/members/add", AddOrgMembers::class); Route::native("{$tab}/scheduled", ScheduledCommits::class); + Route::native("{$tab}/search", Search::class); } }); diff --git a/tests/Feature/Screens/SearchTest.php b/tests/Feature/Screens/SearchTest.php new file mode 100644 index 00000000..5c24047c --- /dev/null +++ b/tests/Feature/Screens/SearchTest.php @@ -0,0 +1,190 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * @param array $overrides + * @return array + */ +function foundPerson(string $username, array $overrides = []): array +{ + return userJson(['id' => "id-{$username}", 'handle' => $username, 'name' => ucfirst($username), 'is_followed' => false, 'follows_you' => false, ...$overrides]); +} + +/** + * Answers people searches with $people and commit searches with $commits. Anything else, such as the Log behind + * the search button, gets an empty page. + * + * @param list> $people + * @param list> $commits + * @param array $writes + */ +function serveSearch(array $people = [], array $commits = [], array $writes = []): void +{ + serveApi(function (Request $request) use ($people, $commits, $writes) { + $route = $request->method().' '.parse_url($request->url(), PHP_URL_PATH); + + return match (true) { + isset($writes[$route]) => Http::response($writes[$route][0], $writes[$route][1] ?? 200), + $route === 'GET /api/v1/search/users' => Http::response(['data' => $people]), + $route === 'GET /api/v1/search/commits' => Http::response(['data' => $commits]), + default => Http::response(pageJson([])), + }; + }); +} + +/** + * The q each search of $kind ("users" or "commits") was sent with, in order. + * + * @return list + */ +function searchedFor(string $kind): array +{ + return collect(Http::recorded()) + ->map(fn (array $call): string => $call[0]->url()) + ->filter(fn (string $url): bool => str_contains($url, "/search/{$kind}")) + ->map(function (string $url): string { + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query['q']; + }) + ->values() + ->all(); +} + +it('opens search from the Log, beside New commit, on the Log stack', function (string $platform) { + serveSearch(); + + $screen = finishRefreshes(Native::visit('/log', platform: $platform)); + $actions = array_column(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'top_bar_action'), 'props'); + + expect(array_column($actions, 'id'))->toBe(['search', 'compose']) + ->and($actions[0])->toMatchArray(['icon' => 'custom:search', 'a11y_label' => 'Search']); + + $screen->press('openSearch')->assertNavigatedTo('/log/search'); +})->with(['ios', 'android']); + +it("offers the web's searches to try before you type, and searches the one you pick", function () { + serveSearch(commits: [commitJson(['id' => 'c1', 'body' => 'Shipped my weekend project'])]); + + $screen = Native::visit('/log/search') + ->assertNavTitle('Search') + ->assertSee('Try searching for…'); + + foreach (Search::Suggestions as $suggestion) { + $screen->assertSee($suggestion); + } + + $screen->tap('suggestion-shipping-a-side-project') + ->assertSet('query', 'shipping a side project') + ->assertDontSee('Try searching for…') + ->assertSee('Most relevant commits') + ->assertSee('1 match') + ->assertSee('Shipped my weekend project'); + + expect(searchedFor('commits'))->toBe(['shipping a side project']); +}); + +it('finds developers, then the commits nearest in meaning, closest first', function () { + serveSearch( + people: [foundPerson('ada', ['name' => 'Ada Lovelace', 'follows_you' => true]), foundPerson('adam')], + commits: [commitJson(['id' => 'c2', 'body' => 'Closest']), commitJson(['id' => 'c1', 'body' => 'Further'])], + ); + + $screen = Native::visit('/log/search') + ->set('query', '@ada') + ->assertSee('Developers') + ->assertSee('Ada Lovelace') + ->assertSee('Follows you') + ->assertSee('2 matches'); + + expect(childRefs($screen, 'search-results'))->toContain('developers-heading', 'person-ada', 'person-adam', 'commits-heading') + ->and(array_map(fn ($commit): string => $commit->id, $screen->instance()->commits))->toBe(['c2', 'c1']) + ->and(dayMarkers($screen))->toBe([]) + // A handle is found without its @, and commits by what was typed. + ->and(searchedFor('users'))->toBe(['ada']) + ->and(searchedFor('commits'))->toBe(['@ada']); +}); + +it('only finds developers for a term too short to search commits by', function () { + serveSearch(); + + Native::visit('/log/search') + ->set('query', 'zz') + ->assertSee("fatal: nothing matches 'zz'") + ->assertDontSee('Most relevant commits'); + + expect(searchedFor('users'))->toBe(['zz']) + ->and(searchedFor('commits'))->toBe([]); +}); + +it("answers a term with no commits near it in git's voice", function () { + serveSearch(); + + Native::visit('/log/search') + ->set('query', 'quantum knitting') + ->assertSee('0 matches') + ->assertSee("fatal: nothing close to 'quantum knitting'"); +}); + +it('keeps an answer for an earlier term from landing under a newer one', function () { + holdRefreshes(); + // Each term finds the developer it names. + serveApi(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return Http::response(['data' => [foundPerson($query['q'])]]); + }); + + $screen = Native::visit('/log/search')->set('query', 'ad')->set('query', 'grace'); + + // The newer term's answer lands first, and the older one after it. + finishRefreshes($screen, newestFirst: true); + + expect(array_map(fn ($person): string => $person->user->username, $screen->instance()->people))->toBe(['grace']) + ->and($screen->instance()->peopleFoundFor)->toBe('grace'); +}); + +it('goes back to the searches to try when the field is emptied', function () { + serveSearch(people: [foundPerson('ada')]); + + Native::visit('/log/search') + ->set('query', 'ada') + ->assertSee('Ada') + ->set('query', ' ') + ->assertSee('Try searching for…') + ->assertDontSee('Developers') + ->assertSet('people', []); +}); + +it('opens a developer from the results on the same stack', function () { + serveSearch(people: [foundPerson('ada')]); + + Native::visit('/log/search')->set('query', 'ada')->tap('person-ada')->assertNavigatedTo('/log/users/ada'); +}); + +it('follows a developer from the results, and puts it back when devnull refuses', function () { + serveSearch( + people: [foundPerson('ada'), foundPerson('grace')], + writes: [ + 'POST /api/v1/users/ada/follow' => [null, 204], + 'POST /api/v1/users/grace/follow' => [['message' => 'Too Many Attempts.'], 429], + ], + ); + + $screen = Native::visit('/log/search')->set('query', 'a')->tap('follow-ada'); + + expect(treeShows(nodeByRef($screen, 'person-ada'), 'Following'))->toBeTrue(); + + $screen->tap('follow-grace')->assertSee('Too Many Attempts.'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Following'))->toBeFalse(); +});