Show Add Project search loading state (#1762)

* fix(app): show add project search loading state

The Add Project picker now treats a typed value that is ahead of the debounced directory search as an active search, so empty results show a loading message instead of the initial prompt or a blank list.

* fix(app): avoid overlapping project picker states
This commit is contained in:
Mohamed Boudra
2026-06-27 12:44:59 +08:00
committed by GitHub
parent 26a8a04651
commit 696a9f0119
11 changed files with 106 additions and 26 deletions
@@ -73,6 +73,23 @@ async function waitForSidebarProjectListReady(page: Page): Promise<void> {
.waitFor({ state: "visible", timeout: 60_000 });
}
test.describe("Project picker search", () => {
test("shows a loading state after typing while directory suggestions are pending", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarProjectListReady(page);
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill("paseo-loading-state-no-match");
await expect(page.getByText("Start typing a path", { exact: true })).toHaveCount(0);
await expect(page.getByText("Searching...", { exact: true })).toBeVisible();
});
});
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
@@ -7,6 +7,8 @@ import {
TextInput,
View,
type PressableStateCallbackType,
type StyleProp,
type TextStyle,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -68,6 +70,68 @@ function PathRow({ option, active, onSelect }: PathRowProps) {
);
}
interface ProjectPickerResultsProps {
options: ProjectPickerOption[];
activeIndex: number;
isSubmitting: boolean;
openErrorMessage: string | null;
hasQuery: boolean;
isSearching: boolean;
emptyTextStyle: StyleProp<TextStyle>;
errorTextStyle: StyleProp<TextStyle>;
onSelect: (path: string) => void;
}
function ProjectPickerResults({
options,
activeIndex,
isSubmitting,
openErrorMessage,
hasQuery,
isSearching,
emptyTextStyle,
errorTextStyle,
onSelect,
}: ProjectPickerResultsProps) {
const { t } = useTranslation();
const canShowResultState = !isSubmitting && !openErrorMessage;
return (
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{canShowResultState && options.length === 0 && !hasQuery ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{canShowResultState && isSearching ? (
<Text style={emptyTextStyle}>{t("projectPicker.searching")}</Text>
) : null}
{canShowResultState && !isSearching && options.length === 0 && hasQuery ? (
<Text style={emptyTextStyle}>{t("common.empty.noOptionsMatchSearch")}</Text>
) : null}
{canShowResultState && options.length > 0 ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={onSelect}
/>
))}
</>
) : null}
</ScrollView>
);
}
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -116,6 +180,11 @@ export function ProjectPickerModal() {
}),
[directorySuggestionsQuery.data, query, recommendedPaths],
);
const hasQuery = query.trim().length > 0;
const isSearching =
hasQuery &&
options.length === 0 &&
(query !== debouncedQuery || directorySuggestionsQuery.isFetching);
const openErrorMessage = useMemo(() => {
if (!openErrorReason) {
@@ -276,32 +345,17 @@ export function ProjectPickerModal() {
/>
</View>
<ScrollView
style={styles.results}
contentContainerStyle={styles.resultsContent}
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && openErrorMessage ? (
<Text style={errorTextStyle}>{openErrorMessage}</Text>
) : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((option, index) => (
<PathRow
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
onSelect={handleSelectPath}
/>
))}
</>
) : null}
</ScrollView>
<ProjectPickerResults
options={options}
activeIndex={activeIndex}
isSubmitting={isSubmitting}
openErrorMessage={openErrorMessage}
hasQuery={hasQuery}
isSearching={isSearching}
emptyTextStyle={emptyTextStyle}
errorTextStyle={errorTextStyle}
onSelect={handleSelectPath}
/>
</View>
</View>
</Modal>
+1
View File
@@ -388,6 +388,7 @@ describe("translation resources", () => {
it("includes picker, file pane, and tool detail keys for the Batch 4L migration", () => {
expect(en.projectPicker.placeholder).toBe("Type a directory path...");
expect(en.projectPicker.opening).toBe("Opening project...");
expect(en.projectPicker.searching).toBe("Searching...");
expect(en.projectPicker.empty).toBe("Start typing a path");
expect(en.branchSwitcher.currentBranch).toBe(
"Current branch: {{branchName}}. Press to switch branch.",
+1
View File
@@ -1054,6 +1054,7 @@ export const ar: TranslationResources = {
projectPicker: {
placeholder: "اكتب مسار الدليل...",
opening: "افتتاح المشروع...",
searching: "جارٍ البحث...",
empty: "ابدأ بكتابة المسار",
errors: {
directory_not_found: "لم يتم العثور على الدليل.",
+1
View File
@@ -1060,6 +1060,7 @@ export const en = {
projectPicker: {
placeholder: "Type a directory path...",
opening: "Opening project...",
searching: "Searching...",
empty: "Start typing a path",
errors: {
directory_not_found: "Directory not found.",
+1
View File
@@ -1087,6 +1087,7 @@ export const es: TranslationResources = {
projectPicker: {
placeholder: "Escriba una ruta de directorio...",
opening: "Proyecto de apertura...",
searching: "Buscando...",
empty: "Comience a escribir una ruta",
errors: {
directory_not_found: "No se encontró el directorio.",
+1
View File
@@ -1089,6 +1089,7 @@ export const fr: TranslationResources = {
projectPicker: {
placeholder: "Tapez un chemin de répertoire...",
opening: "Projet d'ouverture...",
searching: "Recherche en cours...",
empty: "Commencez à taper un chemin",
errors: {
directory_not_found: "Répertoire introuvable.",
+1
View File
@@ -1067,6 +1067,7 @@ export const ja: TranslationResources = {
projectPicker: {
placeholder: "ディレクトリパスを入力...",
opening: "プロジェクトを開いています...",
searching: "検索中...",
empty: "パスを入力してください",
errors: {
directory_not_found: "ディレクトリが見つかりません。",
+1
View File
@@ -1073,6 +1073,7 @@ export const ptBR: TranslationResources = {
projectPicker: {
placeholder: "Digite um caminho de diretório...",
opening: "Abrindo projeto...",
searching: "Buscando...",
empty: "Comece digitando um caminho",
errors: {
directory_not_found: "Diretório não encontrado.",
+1
View File
@@ -1077,6 +1077,7 @@ export const ru: TranslationResources = {
projectPicker: {
placeholder: "Введите путь к каталогу...",
opening: "Открытие проекта...",
searching: "Идет поиск...",
empty: "Начните вводить путь",
errors: {
directory_not_found: "Каталог не найден.",
+1
View File
@@ -1039,6 +1039,7 @@ export const zhCN: TranslationResources = {
projectPicker: {
placeholder: "输入目录路径...",
opening: "正在打开 project...",
searching: "正在搜索...",
empty: "开始输入路径",
errors: {
directory_not_found: "找不到目录。",