Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions browser/data-browser/src/chunks/RTE/SlashMenu/CommandList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import { ScrollArea } from '../../../components/ScrollArea';
import type { SuggestionItem } from '../types';
import { useOnValueChange } from '@helpers/useOnValueChange';
import { Column } from '@components/Row';
import { Spinner } from '@components/Spinner';

export type CommandListRefType = {
onKeyDown: (event: KeyboardEvent) => boolean;
};

export interface CommandListProps {
items: SuggestionItem[];
/** True while the suggestion's async `items()` is still pending. */
loading?: boolean;
ownerDocument?: Document;
command: (item: SuggestionItem) => void;
}
Expand All @@ -35,7 +38,7 @@ const scrollToSelectedItem = (
?.scrollIntoView({ block: 'nearest' });

export const CommandList = forwardRef<CommandListRefType, CommandListProps>(
({ items, command, ownerDocument = document }, ref) => {
({ items, loading = false, command, ownerDocument = document }, ref) => {
const compId = useId();

const [selectedIndex, setSelectedIndex] = useState(0);
Expand Down Expand Up @@ -90,7 +93,13 @@ export const CommandList = forwardRef<CommandListRefType, CommandListProps>(
return (
<ScrollingList type='hover' data-testid='rte-command-list'>
<ContainedColumn gap='0'>
{items.length === 0 && <div>No results found</div>}
{loading && items.length === 0 && (
<SearchingRow role='status' data-testid='rte-command-list-loading'>
<Spinner size='1rem' inheritColor />
<span>Searching...</span>
</SearchingRow>
)}
{!loading && items.length === 0 && <div>No results found</div>}
{items.map((item, index) => {
const Icon = item.icon;

Expand Down Expand Up @@ -158,3 +167,21 @@ const ListItemButton = styled.button<{ active: boolean }>`
`;

const ContainedColumn = styled(Column)``;

// Fades in after a short delay so a fast (or static) item list never flashes
// the loading row.
const SearchingRow = styled.div`
display: flex;
align-items: center;
gap: 1ch;
padding: 0.5rem;
color: ${p => p.theme.colors.textLight};
opacity: 0;
animation: searchingFadeIn 120ms ease-out 150ms forwards;

@keyframes searchingFadeIn {
to {
opacity: 1;
}
}
`;
2 changes: 2 additions & 0 deletions browser/data-browser/src/locales/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -3358,6 +3358,7 @@ msgstr ""
msgid "(empty)"
msgstr ""

#: src/chunks/RTE/SlashMenu/CommandList.tsx
#: src/routes/Search/SearchOverlay.tsx
msgid "Searching..."
msgstr ""
Expand Down Expand Up @@ -12279,6 +12280,7 @@ msgstr ""
#. 0: PRODUCT_NAME
#: src/components/AccountRecoveryCard.tsx
msgid "Signing in to your {0} account does not open this identity yet: this backup was made before that was possible. Confirm once with your recovery code to turn it on."
msgstr ""

#: src/components/BrowserPeerPanel.tsx
msgid "See Cloud Server plans"
Expand Down
1 change: 1 addition & 0 deletions browser/data-browser/src/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -3358,6 +3358,7 @@ msgstr "Document body"
msgid "(empty)"
msgstr "(empty)"

#: src/chunks/RTE/SlashMenu/CommandList.tsx
#: src/routes/Search/SearchOverlay.tsx
msgid "Searching..."
msgstr "Searching..."
Expand Down
2 changes: 2 additions & 0 deletions browser/data-browser/src/locales/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -3358,6 +3358,7 @@ msgstr "Cuerpo del documento"
msgid "(empty)"
msgstr "(vacío)"

#: src/chunks/RTE/SlashMenu/CommandList.tsx
#: src/routes/Search/SearchOverlay.tsx
msgid "Searching..."
msgstr "Buscando..."
Expand Down Expand Up @@ -12279,6 +12280,7 @@ msgstr ""
#. 0: PRODUCT_NAME
#: src/components/AccountRecoveryCard.tsx
msgid "Signing in to your {0} account does not open this identity yet: this backup was made before that was possible. Confirm once with your recovery code to turn it on."
msgstr ""

#: src/components/BrowserPeerPanel.tsx
msgid "See Cloud Server plans"
Expand Down
2 changes: 2 additions & 0 deletions browser/data-browser/src/locales/fr.po
Original file line number Diff line number Diff line change
Expand Up @@ -3358,6 +3358,7 @@ msgstr "Corps du document"
msgid "(empty)"
msgstr "(vide)"

#: src/chunks/RTE/SlashMenu/CommandList.tsx
#: src/routes/Search/SearchOverlay.tsx
msgid "Searching..."
msgstr "Recherche..."
Expand Down Expand Up @@ -12279,6 +12280,7 @@ msgstr ""
#. 0: PRODUCT_NAME
#: src/components/AccountRecoveryCard.tsx
msgid "Signing in to your {0} account does not open this identity yet: this backup was made before that was possible. Confirm once with your recovery code to turn it on."
msgstr ""

#: src/components/BrowserPeerPanel.tsx
msgid "See Cloud Server plans"
Expand Down
25 changes: 23 additions & 2 deletions browser/data-browser/src/routes/Search/SearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { base64StringToFilter } from './searchUtils';
import { InlineFormattedResourceList } from '../../components/InlineFormattedResourceList';
import { ErrorBoundary } from '../../views/ErrorPage';
import { useOnValueChange } from '@helpers/useOnValueChange';
import { Spinner } from '../../components/Spinner';
import { LoaderBlock } from '../../components/Loader';
import { useSearchOverlay } from '../../components/Searchbar/SearchOverlayContext';

const OverlayBackdrop = styled.div`
Expand Down Expand Up @@ -127,6 +129,11 @@ const HeadingIcon = styled.span`
align-items: center;
`;

const SkeletonRow = styled(LoaderBlock)`
height: 3.25rem;
flex-shrink: 0;
`;

const TagHeading = styled.span`
color: ${p => p.theme.colors.textLight};
font-weight: bold;
Expand Down Expand Up @@ -316,9 +323,16 @@ function SearchOverlayContent({
) : (
<>
{heading && (
<HeadingRow>
<HeadingRow
role={loading ? 'status' : undefined}
data-testid={loading ? 'search-overlay-loading' : undefined}
>
<HeadingIcon>
<FaMagnifyingGlass size={12} />
{loading ? (
<Spinner size='0.9rem' inheritColor />
) : (
<FaMagnifyingGlass size={12} />
)}
</HeadingIcon>
{heading}
</HeadingRow>
Expand Down Expand Up @@ -346,6 +360,13 @@ function SearchOverlayContent({

<ResultsArea ref={resultsRef}>
<Column gap='0.5rem'>
{loading && results.length === 0 && (
<>
<SkeletonRow aria-hidden />
<SkeletonRow aria-hidden />
<SkeletonRow aria-hidden />
</>
)}
{results.map((subject, index) => (
<SelectableResult
key={subject}
Expand Down
6 changes: 6 additions & 0 deletions browser/lib/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface SearchOpts {
include?: boolean;
/** Max of how many results to return */
limit?: number;
/**
* Called with the local-index hits as soon as they are available, while the
* hosted `/search` request may still be pending. The final result of
* `search()` includes these hits too. Not called when there are no local hits.
*/
onPartial?: (subjects: string[]) => void;
/** Subjects of resource to scope the search to. This should be a list of parents of the resources you're looking for. */
parents?: string[] | string;
/** Property-Value pair of set filters. */
Expand Down
55 changes: 55 additions & 0 deletions browser/lib/src/store-search-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,58 @@ it('server-only lookup does not wait for a busy local index or a WebSocket', asy
noWebSocket: true,
});
});

const makeResponse = async (results: string[]) => {
const response = new Resource('https://example.com/search');
await response.set(server.properties.results, results, false);

return response;
};

const setLocal = (store: Store, search: () => Promise<string[]> | string[]) =>
store.setClientDb({
isReady: true,
isInitialized: true,
waitForReady: async () => true,
search,
} as unknown as Parameters<Store['setClientDb']>[0]);

it('starts the server request without waiting for a slow local search', async () => {
const store = new Store({ serverUrl: 'https://example.com' });
store.setServerConnected(true);
let resolveLocal: (v: string[]) => void = () => undefined;
const local = new Promise<string[]>(r => (resolveLocal = r));
setLocal(store, () => local);
const fetch = vi
.spyOn(store, 'fetchResourceFromServer')
.mockResolvedValue(await makeResponse(['did:ad:server', 'did:ad:both']));
const onPartial = vi.fn();

const pending = store.search('hello', { onPartial });
await Promise.resolve();
await Promise.resolve();

// Local search is still pending, yet the server was already asked.
expect(fetch).toHaveBeenCalledTimes(1);
expect(onPartial).not.toHaveBeenCalled();

resolveLocal(['did:ad:local', 'did:ad:both']);

expect(await pending).toEqual([
'did:ad:local',
'did:ad:both',
'did:ad:server',
]);
expect(onPartial).toHaveBeenCalledWith(['did:ad:local', 'did:ad:both']);
});

it('still returns server results when the local search fails', async () => {
const store = new Store({ serverUrl: 'https://example.com' });
store.setServerConnected(true);
setLocal(store, () => Promise.reject(new Error('index busy')));
vi.spyOn(store, 'fetchResourceFromServer').mockResolvedValue(
await makeResponse(['did:ad:server']),
);

expect(await store.search('hello')).toEqual(['did:ad:server']);
});
56 changes: 42 additions & 14 deletions browser/lib/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3522,24 +3522,46 @@ export class Store {
// Local hits come from the durable KV index in ClientDb (title,
// description, Loro body, 1-edit prefix fuzzy, PropValSub filters).
const clientDb = this.clientDb;
const kvResults =
const offline = !this._serverConnected && !opts.serverOnly;
const limit = opts.limit ?? 30;

// The local OPFS search can take a while on a large drive, so it must not
// delay the hosted request: both start now and run concurrently.
const localSearch: Promise<string[]> =
!opts.serverOnly &&
clientDb?.isReady &&
typeof clientDb.search === 'function'
? await clientDb.search(query, {
limit: opts.limit ?? 30,
parents: parentScope,
filters: opts.filters,
})
: [];

if (kvResults.length > 0) {
searchDebug('[search] local kv →', kvResults.length, kvResults);
}
? Promise.resolve()
.then(() =>
clientDb.search(query, {
limit,
parents: parentScope,
filters: opts.filters,
}),
)
.then(hits => {
if (hits.length > 0) {
searchDebug('[search] local kv →', hits.length, hits);
opts.onPartial?.(this.withoutDestroyed(hits));
}

return hits;
})
.catch(e => {
// When the server can still answer, a broken local index must
// not take the whole search down.
if (offline) throw e;

searchDebug('[search] local kv failed', e);

return [] as string[];
})
: Promise.resolve([]);

// Offline: hosted `/search` is unreachable. Return whatever the local
// index has (empty if ClientDb is down).
if (!this._serverConnected && !opts.serverOnly) {
if (offline) {
const kvResults = await localSearch;
searchDebug('[search] OFFLINE kv →', kvResults.length, kvResults);

return this.withoutDestroyed(kvResults);
Expand All @@ -3555,15 +3577,21 @@ export class Store {
// returns new matches. Evict only the in-memory synthetic resource so every
// retry observes the server's current result set.
this._resources.delete(this.resolveSubject(searchSubject));
const searchResource = await this.fetchResourceFromServer(searchSubject, {
const serverSearch = this.fetchResourceFromServer(searchSubject, {
noWebSocket: true,
});
// Avoid an unhandled rejection if the server fails while we still await
// the local search.
serverSearch.catch(() => undefined);

const kvResults = await localSearch;
const searchResource = await serverSearch;
const results = searchResource.get(server.properties.results) ?? [];
searchDebug('[search] server search returned', results.length);

return this.withoutDestroyed([
...new Set([...kvResults, ...results]),
]).slice(0, opts.limit ?? 30);
]).slice(0, limit);
}

/**
Expand Down
12 changes: 11 additions & 1 deletion browser/react/src/useServerSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,18 @@ export function useServerSearch(
const RETRY_DELAY_MS = 2000;

const runSearch = () => {
// Show local hits right away; `loading` stays true until the server
// answered, so the UI can combine early results with a pending state.
const onPartial = (partial: string[]) => {
if (cancelled || partial.length === 0) {
return;
}

updateResults(partial, debouncedQuery, memoizedSearchOpts);
};

store
.search(debouncedQuery, memoizedSearchOpts)
.search(debouncedQuery, { ...memoizedSearchOpts, onPartial })
.then(r => {
if (cancelled) {
return;
Expand Down