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