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(); +});