Files
paseo/packages/app/src/add-project-flow/model.ts
T
Mohamed BoudraandGitHub 02c4bc76a6 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
2026-08-09 21:46:58 +02:00

299 lines
8.4 KiB
TypeScript

export interface AddProjectHost {
serverId: string;
label: string;
canAddProject: boolean;
canBrowse: boolean;
canCloneGithubRepositories: boolean;
canSearchGithubRepositories: boolean;
canCreateDirectory: boolean;
}
export interface GithubRepositoryChoice {
id: string;
nameWithOwner: string;
cloneUrl: string;
cloneProtocol?: "https" | "ssh";
description: string | null;
updatedAt: string | null;
}
interface PageState {
activeIndex: number;
error: string | null;
}
interface SearchPageState extends PageState {
query: string;
}
export type AddProjectPage =
| ({ kind: "host" } & SearchPageState)
| ({ kind: "method"; hostId: string; isSubmitting: boolean } & PageState)
| ({ kind: "directory-search"; hostId: string; isSubmitting: boolean } & SearchPageState)
| ({ kind: "github-search"; hostId: string } & SearchPageState)
| ({
kind: "github-location";
hostId: string;
repository: GithubRepositoryChoice;
isSubmitting: boolean;
} & SearchPageState)
| ({ kind: "new-directory-parent"; hostId: string } & SearchPageState)
| {
kind: "new-directory-name";
hostId: string;
parentPath: string;
name: string;
activeIndex: number;
error: string | null;
isSubmitting: boolean;
};
export interface AddProjectFlowState {
hosts: AddProjectHost[];
pages: AddProjectPage[];
newDirectoryNameDrafts: Record<string, string>;
githubLocationDrafts: Record<string, { query: string; activeIndex: number }>;
}
export interface OpenAddProjectFlowInput {
hosts: AddProjectHost[];
preferredHostId?: string;
}
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 { kind: "method", hostId, activeIndex: 0, error: null, isSubmitting: false };
}
export function openAddProjectFlow(input: OpenAddProjectFlowInput): AddProjectFlowState {
const preferredHost = input.preferredHostId
? input.hosts.find((host) => host.serverId === input.preferredHostId)
: null;
const onlyHost = input.hosts.length === 1 ? input.hosts[0] : null;
const initialHost = preferredHost ?? onlyHost;
return {
hosts: input.hosts,
pages: initialHost ? [methodPage(initialHost.serverId)] : [searchPage("host")],
newDirectoryNameDrafts: {},
githubLocationDrafts: {},
};
}
export function applyAvailableAddProjectHosts(
state: AddProjectFlowState,
hosts: AddProjectHost[],
preferredHostId?: string,
): AddProjectFlowState {
const current = currentAddProjectPage(state);
if (state.pages.length !== 1 || current.kind !== "host") {
return { ...state, hosts };
}
const preferredHost = preferredHostId
? hosts.find((host) => host.serverId === preferredHostId)
: null;
const onlyHost = hosts.length === 1 ? hosts[0] : null;
const initialHost = preferredHost ?? onlyHost;
return {
...state,
hosts,
pages: initialHost ? [methodPage(initialHost.serverId)] : state.pages,
};
}
export function currentAddProjectPage(state: AddProjectFlowState): AddProjectPage {
const page = state.pages[state.pages.length - 1];
if (!page) {
throw new Error("Add Project flow must always contain a page");
}
return page;
}
export function updateCurrentAddProjectPage(
state: AddProjectFlowState,
update: (page: AddProjectPage) => AddProjectPage,
): AddProjectFlowState {
const index = state.pages.length - 1;
return {
...state,
pages: state.pages.map((page, pageIndex) => (pageIndex === index ? update(page) : page)),
};
}
export function pushAddProjectPage(
state: AddProjectFlowState,
page: AddProjectPage,
): AddProjectFlowState {
return { ...state, pages: [...state.pages, page] };
}
export function backAddProjectPage(state: AddProjectFlowState): AddProjectFlowState | null {
if (state.pages.length === 1) {
return null;
}
return { ...state, pages: state.pages.slice(0, -1) };
}
export function chooseAddProjectHost(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, methodPage(hostId));
}
export function openDirectorySearchPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, {
...searchPage("directory-search"),
hostId,
isSubmitting: false,
});
}
export function openGithubSearchPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, { ...searchPage("github-search"), hostId });
}
export function openGithubLocationPage(
state: AddProjectFlowState,
hostId: string,
repository: GithubRepositoryChoice,
): AddProjectFlowState {
const draft = state.githubLocationDrafts[githubLocationDraftKey(hostId, repository.id)];
return pushAddProjectPage(state, {
kind: "github-location",
query: draft?.query ?? "",
activeIndex: draft?.activeIndex ?? 0,
error: null,
hostId,
repository,
isSubmitting: false,
});
}
export function openNewDirectoryParentPage(
state: AddProjectFlowState,
hostId: string,
): AddProjectFlowState {
return pushAddProjectPage(state, { ...searchPage("new-directory-parent"), hostId });
}
export function openNewDirectoryNamePage(
state: AddProjectFlowState,
hostId: string,
parentPath: string,
): AddProjectFlowState {
const draftKey = newDirectoryDraftKey(hostId, parentPath);
return pushAddProjectPage(state, {
kind: "new-directory-name",
hostId,
parentPath,
name: state.newDirectoryNameDrafts[draftKey] ?? "",
activeIndex: 0,
error: null,
isSubmitting: false,
});
}
export function setAddProjectPageInput(
state: AddProjectFlowState,
value: string,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
const updated = updateCurrentAddProjectPage(state, (current) => {
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;
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
return {
...updated,
githubLocationDrafts: {
...updated.githubLocationDrafts,
[draftKey]: { query: value, activeIndex: 0 },
},
};
}
export function setNewDirectoryName(
state: AddProjectFlowState,
value: string,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
if (page.kind !== "new-directory-name") return state;
const draftKey = newDirectoryDraftKey(page.hostId, page.parentPath);
const updated = setAddProjectPageInput(state, value);
return {
...updated,
newDirectoryNameDrafts: {
...updated.newDirectoryNameDrafts,
[draftKey]: value,
},
};
}
function newDirectoryDraftKey(hostId: string, parentPath: string): string {
return `${hostId}\u0000${parentPath}`;
}
function githubLocationDraftKey(hostId: string, repositoryId: string): string {
return `${hostId}\u0000${repositoryId}`;
}
export function setAddProjectActiveIndex(
state: AddProjectFlowState,
activeIndex: number,
): AddProjectFlowState {
const page = currentAddProjectPage(state);
const updated = updateCurrentAddProjectPage(state, (current) => ({ ...current, activeIndex }));
if (page.kind !== "github-location") return updated;
const draftKey = githubLocationDraftKey(page.hostId, page.repository.id);
return {
...updated,
githubLocationDrafts: {
...updated.githubLocationDrafts,
[draftKey]: { query: page.query, activeIndex },
},
};
}
export function moveAddProjectActiveIndex(
activeIndex: number,
optionCount: number,
direction: "next" | "previous",
): number {
if (optionCount === 0) return 0;
const delta = direction === "next" ? 1 : -1;
const next = activeIndex + delta;
if (next < 0) return optionCount - 1;
if (next >= optionCount) return 0;
return next;
}
export function moveAddProjectSelection(
activeIndex: number,
selectable: readonly boolean[],
direction: "next" | "previous",
): number {
if (!selectable.some(Boolean)) return 0;
let next = activeIndex;
for (let count = 0; count < selectable.length; count += 1) {
next = moveAddProjectActiveIndex(next, selectable.length, direction);
if (selectable[next]) return next;
}
return activeIndex;
}