Keep Add Project method selection path-free (#3099)
* fix(app): remove search from project method selection The fixed method list should not accept text because it can be mistaken for the directory path step that follows. * fix(app): preserve native project picker keys Keep hardware-keyboard delivery owned by the Add Project flow even when method selection has no visible text field. * test(app): update add project overlay expectation
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
expectAddProjectPage,
|
||||
expectNewWorkspaceForAddedProject,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -65,12 +66,14 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a single connected host opens directly on method selection", async ({ page }) => {
|
||||
test("method selection has no search field", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -146,7 +149,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
@@ -155,7 +158,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expect(addProjectFlow(page)).toContainText(SECONDARY_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("Escape and Back restore page input and active selection before closing at the root", async ({
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
@@ -168,21 +171,20 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
|
||||
await addProjectFlowInput(page).fill("o");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
await addProjectFlowInput(page).fill("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expectAddProjectPage(page, "method");
|
||||
await expect(addProjectFlowInput(page)).toHaveValue("new");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await chooseAddProjectMethod(page, "new-directory");
|
||||
await expectAddProjectPage(page, "new-directory-parent");
|
||||
await addProjectFlowBack(page).click();
|
||||
|
||||
@@ -216,7 +218,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
endpoint: `localhost:${secondaryHost.port}`,
|
||||
});
|
||||
await openAddProjectFlow(page, "host");
|
||||
await openAddProjectHostSelection(page);
|
||||
await addProjectFlowHost(page, SECONDARY_HOST_ID).click();
|
||||
await expectAddProjectPage(page, "method");
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ test.describe("provider settings overlay stack", () => {
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProject).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
@@ -43,13 +43,21 @@ export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage)
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(
|
||||
async function openAddProjectFlowSurface(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method" = "method",
|
||||
expectedPage: "host" | "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
}
|
||||
|
||||
export async function openAddProjectFlow(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "method");
|
||||
}
|
||||
|
||||
export async function openAddProjectHostSelection(page: Page): Promise<void> {
|
||||
await openAddProjectFlowSurface(page, "host");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ describe("Add Project navigation", () => {
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "method",
|
||||
hostId: "host-1",
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
isSubmitting: false,
|
||||
});
|
||||
expect(backAddProjectPage(state)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -17,15 +17,18 @@ export interface GithubRepositoryChoice {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState {
|
||||
query: string;
|
||||
interface PageState {
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState extends PageState {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type AddProjectPage =
|
||||
| ({ kind: "host" } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
|
||||
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
|
||||
| ({ kind: "github-search"; hostId: string } & SearchPageState)
|
||||
| ({
|
||||
@@ -57,12 +60,14 @@ export interface OpenAddProjectFlowInput {
|
||||
preferredHostId?: string;
|
||||
}
|
||||
|
||||
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
|
||||
type SearchPageKind = Extract<AddProjectPage, { query: string }>["kind"];
|
||||
|
||||
function searchPage<TKind extends SearchPageKind>(kind: TKind) {
|
||||
return { kind, query: "", activeIndex: 0, error: null } as const;
|
||||
}
|
||||
|
||||
function methodPage(hostId: string): AddProjectPage {
|
||||
return { ...searchPage("method"), hostId };
|
||||
return { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
|
||||
}
|
||||
|
||||
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
|
||||
@@ -209,6 +214,7 @@ export function setAddProjectPageInput(
|
||||
if (current.kind === "new-directory-name") {
|
||||
return { ...current, name: value, activeIndex: 0, error: null };
|
||||
}
|
||||
if (current.kind === "method") return current;
|
||||
return { ...current, query: value, activeIndex: 0, error: null };
|
||||
});
|
||||
if (page.kind !== "github-location") return updated;
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
|
||||
@@ -222,12 +222,12 @@ function pageTitle(page: AddProjectPage): string {
|
||||
}
|
||||
}
|
||||
|
||||
function pagePlaceholder(page: AddProjectPage): string {
|
||||
type AddProjectInputPage = Exclude<AddProjectPage, { kind: "method" }>;
|
||||
|
||||
function pagePlaceholder(page: AddProjectInputPage): string {
|
||||
switch (page.kind) {
|
||||
case "host":
|
||||
return "Search hosts...";
|
||||
case "method":
|
||||
return "Search methods...";
|
||||
case "directory-search":
|
||||
return "Search directories or enter a path...";
|
||||
case "github-search":
|
||||
@@ -240,7 +240,7 @@ function pagePlaceholder(page: AddProjectPage): string {
|
||||
}
|
||||
}
|
||||
|
||||
function pageInput(page: AddProjectPage): string {
|
||||
function pageInput(page: AddProjectInputPage): string {
|
||||
return page.kind === "new-directory-name" ? page.name : page.query;
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
const browseInFlightRef = useRef(false);
|
||||
const query = page.kind === "new-directory-name" ? "" : page.query;
|
||||
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -599,23 +599,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
}
|
||||
if (page.kind === "method") {
|
||||
if (!host) return [];
|
||||
const normalized = page.query.trim().toLowerCase();
|
||||
return buildAddProjectMethods(host)
|
||||
.filter(
|
||||
(method) =>
|
||||
!normalized ||
|
||||
method.label.toLowerCase().includes(normalized) ||
|
||||
method.description.toLowerCase().includes(normalized),
|
||||
)
|
||||
.map((method) => ({
|
||||
id: method.id,
|
||||
title: method.label,
|
||||
subtitle: method.description,
|
||||
icon: methodIcon(method.id),
|
||||
disabled: method.disabled,
|
||||
testID: `add-project-flow-method-${method.id}`,
|
||||
select: () => selectMethod(method.id),
|
||||
}));
|
||||
return buildAddProjectMethods(host).map((method) => ({
|
||||
id: method.id,
|
||||
title: method.label,
|
||||
subtitle: method.description,
|
||||
icon: methodIcon(method.id),
|
||||
disabled: method.disabled,
|
||||
testID: `add-project-flow-method-${method.id}`,
|
||||
select: () => selectMethod(method.id),
|
||||
}));
|
||||
}
|
||||
if (page.kind === "directory-search") {
|
||||
return pathOptions.map((option) => {
|
||||
@@ -863,21 +855,41 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<ThemedTextInput
|
||||
key={page.kind}
|
||||
ref={inputRef}
|
||||
value={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
placeholder={pagePlaceholder(page)}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSubmitting}
|
||||
returnKeyType="go"
|
||||
testID="add-project-flow-input"
|
||||
/>
|
||||
{page.kind === "method" && isNative ? (
|
||||
// Native hardware-keyboard events need a focused responder even without a visible field.
|
||||
<TextInput
|
||||
key={page.kind}
|
||||
ref={inputRef}
|
||||
onKeyPress={handleNativeKeyPress}
|
||||
onSubmitEditing={submitActive}
|
||||
showSoftInputOnFocus={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
style={styles.keyboardCapture}
|
||||
testID="add-project-flow-keyboard-capture"
|
||||
/>
|
||||
) : null}
|
||||
{page.kind !== "method" ? (
|
||||
<ThemedTextInput
|
||||
key={page.kind}
|
||||
ref={inputRef}
|
||||
value={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
placeholder={pagePlaceholder(page)}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSubmitting}
|
||||
returnKeyType="go"
|
||||
testID="add-project-flow-input"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
@@ -1012,6 +1024,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
keyboardCapture: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
resultsContent: { paddingVertical: theme.spacing[2] },
|
||||
row: {
|
||||
|
||||
Reference in New Issue
Block a user