Add a keyboard-driven project setup flow (#2097)
* feat(projects): add keyboard-driven project setup * test(app): update add project loading assertion * fix(projects): harden GitHub project setup
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
backAddProjectPage,
|
||||
chooseAddProjectHost,
|
||||
currentAddProjectPage,
|
||||
moveAddProjectActiveIndex,
|
||||
moveAddProjectSelection,
|
||||
openAddProjectFlow,
|
||||
openDirectorySearchPage,
|
||||
openGithubLocationPage,
|
||||
openNewDirectoryNamePage,
|
||||
openNewDirectoryParentPage,
|
||||
setAddProjectActiveIndex,
|
||||
setAddProjectPageInput,
|
||||
setNewDirectoryName,
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
} from "./options";
|
||||
|
||||
const HOST: AddProjectHost = {
|
||||
serverId: "host-1",
|
||||
label: "Local",
|
||||
canAddProject: true,
|
||||
canBrowse: true,
|
||||
canCloneGithubRepositories: true,
|
||||
canSearchGithubRepositories: true,
|
||||
canCreateDirectory: true,
|
||||
};
|
||||
|
||||
describe("Add Project navigation", () => {
|
||||
it("skips a single connected host without adding it to history", () => {
|
||||
const state = openAddProjectFlow({ hosts: [HOST] });
|
||||
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "method",
|
||||
hostId: "host-1",
|
||||
query: "",
|
||||
activeIndex: 0,
|
||||
error: null,
|
||||
});
|
||||
expect(backAddProjectPage(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("restores page input and selection after Back", () => {
|
||||
const secondHost = { ...HOST, serverId: "host-2", label: "Remote" };
|
||||
let state = openAddProjectFlow({ hosts: [HOST, secondHost] });
|
||||
state = setAddProjectPageInput(state, "rem");
|
||||
state = setAddProjectActiveIndex(state, 1);
|
||||
state = chooseAddProjectHost(state, secondHost.serverId);
|
||||
state = openDirectorySearchPage(state, secondHost.serverId);
|
||||
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
|
||||
expect(currentAddProjectPage(state)).toEqual({
|
||||
kind: "host",
|
||||
query: "rem",
|
||||
activeIndex: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps keyboard selection in both directions", () => {
|
||||
expect(moveAddProjectActiveIndex(2, 3, "next")).toBe(0);
|
||||
expect(moveAddProjectActiveIndex(0, 3, "previous")).toBe(2);
|
||||
expect(moveAddProjectSelection(0, [true, false, true], "next")).toBe(2);
|
||||
});
|
||||
|
||||
it("restores a directory name after returning to and reselecting its parent", () => {
|
||||
let state = openAddProjectFlow({ hosts: [HOST] });
|
||||
state = openNewDirectoryParentPage(state, HOST.serverId);
|
||||
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
|
||||
state = setNewDirectoryName(state, "command-center");
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = openNewDirectoryNamePage(state, HOST.serverId, "~/dev");
|
||||
|
||||
expect(currentAddProjectPage(state)).toMatchObject({
|
||||
kind: "new-directory-name",
|
||||
parentPath: "~/dev",
|
||||
name: "command-center",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the GitHub destination query and active parent when reopening a repository", () => {
|
||||
const repository = {
|
||||
id: "repo-1",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
description: null,
|
||||
visibility: "public",
|
||||
updatedAt: null,
|
||||
};
|
||||
let state = openAddProjectFlow({ hosts: [HOST] });
|
||||
state = openGithubLocationPage(state, HOST.serverId, repository);
|
||||
state = setAddProjectPageInput(state, "~/dev");
|
||||
state = setAddProjectActiveIndex(state, 2);
|
||||
state = backAddProjectPage(state) ?? state;
|
||||
state = openGithubLocationPage(state, HOST.serverId, repository);
|
||||
|
||||
expect(currentAddProjectPage(state)).toMatchObject({
|
||||
kind: "github-location",
|
||||
query: "~/dev",
|
||||
activeIndex: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
...HOST,
|
||||
canBrowse: false,
|
||||
canCloneGithubRepositories: false,
|
||||
canSearchGithubRepositories: false,
|
||||
canCreateDirectory: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: "Find a directory on Local",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "Clone from GitHub",
|
||||
description: "Update this host to clone GitHub repositories",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: "new-directory",
|
||||
label: "New directory",
|
||||
description: "Update this host to create directories",
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers manual URL and protocol-specific owner/repo clone choices", () => {
|
||||
expect(buildManualGithubRepositoryChoices("git@github.com:getpaseo/paseo.git")).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "manual:git@github.com:getpaseo/paseo.git",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
}),
|
||||
]);
|
||||
expect(buildManualGithubRepositoryChoices("getpaseo/paseo")).toEqual([
|
||||
expect.objectContaining({ cloneProtocol: "https", cloneUrl: "getpaseo/paseo" }),
|
||||
expect.objectContaining({ cloneProtocol: "ssh", cloneUrl: "getpaseo/paseo" }),
|
||||
]);
|
||||
expect(buildManualGithubRepositoryChoices("paseo")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows final clone paths while retaining parent paths as values", () => {
|
||||
expect(
|
||||
buildCloneLocationOptions({
|
||||
parents: ["~/dev", "~/workspace"],
|
||||
repositoryName: "paseo",
|
||||
existingPaths: ["~/workspace/paseo"],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "~/dev",
|
||||
path: "~/dev",
|
||||
displayPath: "~/dev/paseo",
|
||||
secondaryText: "Parent directory: ~/dev",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "~/workspace",
|
||||
path: "~/workspace",
|
||||
displayPath: "~/workspace/paseo",
|
||||
secondaryText: "Already exists",
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
visibility: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface SearchPageState {
|
||||
query: string;
|
||||
activeIndex: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type AddProjectPage =
|
||||
| ({ kind: "host" } & SearchPageState)
|
||||
| ({ kind: "method"; hostId: string } & SearchPageState)
|
||||
| ({ 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;
|
||||
}
|
||||
|
||||
function searchPage<TKind extends AddProjectPage["kind"]>(kind: TKind) {
|
||||
return { kind, query: "", activeIndex: 0, error: null } as const;
|
||||
}
|
||||
|
||||
function methodPage(hostId: string): AddProjectPage {
|
||||
return { ...searchPage("method"), hostId };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
isCompleteGitRemote,
|
||||
parseGitHubRemoteUrl,
|
||||
parseGitRemoteLocation,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import type { AddProjectHost, GithubRepositoryChoice } from "./model";
|
||||
|
||||
export type AddProjectMethodId = "directory-search" | "browse" | "github" | "new-directory";
|
||||
|
||||
export interface AddProjectMethodOption {
|
||||
id: AddProjectMethodId;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AddProjectPathOption {
|
||||
id: string;
|
||||
path: string;
|
||||
displayPath: string;
|
||||
secondaryText: string | null;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): AddProjectHost[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return hosts;
|
||||
return hosts.filter(
|
||||
(host) =>
|
||||
host.label.toLowerCase().includes(normalized) ||
|
||||
host.serverId.toLowerCase().includes(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||
const options: AddProjectMethodOption[] = [];
|
||||
if (host.canAddProject) {
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
}
|
||||
if (host.canBrowse) {
|
||||
options.push({
|
||||
id: "browse",
|
||||
label: "Browse",
|
||||
description: "Choose or create a directory in Finder",
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
id: "github",
|
||||
label: "Clone from GitHub",
|
||||
description: githubMethodDescription(host),
|
||||
disabled: !host.canCloneGithubRepositories,
|
||||
});
|
||||
options.push({
|
||||
id: "new-directory",
|
||||
label: "New directory",
|
||||
description: host.canCreateDirectory
|
||||
? `Create an empty directory on ${host.label}`
|
||||
: "Update this host to create directories",
|
||||
disabled: !host.canCreateDirectory,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function githubMethodDescription(host: AddProjectHost): string {
|
||||
if (!host.canCloneGithubRepositories) {
|
||||
return "Update this host to clone GitHub repositories";
|
||||
}
|
||||
if (host.canSearchGithubRepositories) {
|
||||
return "Search projects available to your GitHub account";
|
||||
}
|
||||
return "Enter a GitHub URL or owner/repo";
|
||||
}
|
||||
|
||||
export function pathBaseName(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, "");
|
||||
const parts = trimmed.split(/[\\/]/);
|
||||
return parts[parts.length - 1] ?? trimmed;
|
||||
}
|
||||
|
||||
export function buildManualGithubRepositoryChoices(query: string): GithubRepositoryChoice[] {
|
||||
const repo = query.trim();
|
||||
if (!repo) return [];
|
||||
|
||||
if (isCompleteGitRemote(repo)) {
|
||||
const identity = parseGitHubRemoteUrl(repo);
|
||||
const location = parseGitRemoteLocation(repo);
|
||||
const remoteName = location ? pathBaseName(location.path).replace(/\.git$/u, "") : repo;
|
||||
return [
|
||||
{
|
||||
id: `manual:${repo}`,
|
||||
nameWithOwner: identity?.repo ?? remoteName,
|
||||
cloneUrl: repo,
|
||||
description: "Clone this repository URL",
|
||||
visibility: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const shorthand = repo.match(/^([^\s/]+)\/([^\s/]+)$/u);
|
||||
if (!shorthand) return [];
|
||||
const nameWithOwner = `${shorthand[1]}/${shorthand[2]}`;
|
||||
return (["https", "ssh"] as const).map((cloneProtocol) => ({
|
||||
id: `manual:${cloneProtocol}:${nameWithOwner}`,
|
||||
nameWithOwner,
|
||||
cloneUrl: nameWithOwner,
|
||||
cloneProtocol,
|
||||
description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`,
|
||||
visibility: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function parentDirectory(path: string): string | null {
|
||||
const trimmed = path.replace(/[\\/]+$/, "");
|
||||
const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
||||
if (index < 0) return null;
|
||||
if (index === 0) return trimmed.slice(0, 1);
|
||||
return trimmed.slice(0, index);
|
||||
}
|
||||
|
||||
export function joinDirectoryPath(parent: string, name: string): string {
|
||||
const trimmedParent = parent.replace(/[\\/]+$/, "");
|
||||
const separator = trimmedParent.includes("\\") && !trimmedParent.includes("/") ? "\\" : "/";
|
||||
return `${trimmedParent}${separator}${name}`;
|
||||
}
|
||||
|
||||
export function buildSuggestedParentDirectories(projectPaths: string[]): string[] {
|
||||
const values = [
|
||||
...projectPaths.flatMap((path) => {
|
||||
const parent = parentDirectory(path);
|
||||
return parent ? [parent] : [];
|
||||
}),
|
||||
"~/dev",
|
||||
"~/Developer",
|
||||
"~/src",
|
||||
"~/projects",
|
||||
"~/workspace",
|
||||
"~",
|
||||
];
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
export function buildCloneLocationOptions(input: {
|
||||
parents: string[];
|
||||
repositoryName: string;
|
||||
existingPaths: string[];
|
||||
}): AddProjectPathOption[] {
|
||||
const existing = new Set(input.existingPaths);
|
||||
return input.parents.map((parent) => {
|
||||
const path = joinDirectoryPath(parent, input.repositoryName);
|
||||
return {
|
||||
id: parent,
|
||||
path: parent,
|
||||
displayPath: path,
|
||||
secondaryText: existing.has(path) ? "Already exists" : `Parent directory: ${parent}`,
|
||||
disabled: existing.has(path),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user