diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e15..987a021d87a67 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -6,7 +6,11 @@ import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; -import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { act, createElement, isValidElement, type ReactNode } from "react"; @@ -16,7 +20,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" const state = vi.hoisted(() => ({ scopes: new Set(), baseDirectory: "", + os: "linux", + home: "/home/tester", projects: [] as string[], + cloneDestinations: [] as string[], existingProjects: [] as { environmentId: string; id: string; @@ -35,6 +42,21 @@ const state = vi.hoisted(() => ({ environments: [{ environmentId: "environment", environmentLabel: "Environment" }], })); +// Stands in for the host's `~` expansion, which filesystem.browse reports as parentPath. +function browseResult(partialPath: string) { + const expanded = /^~(?:[\\/]|$)/.test(partialPath) + ? `${state.home}${partialPath.slice(1)}` + : partialPath; + const normalized = state.os === "windows" ? expanded.replaceAll("/", "\\") : expanded; + return { + parentPath: normalized.slice( + 0, + Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")) + 1, + ), + entries: [], + }; +} + function loadRouters() { const requireNavigation = NodeModule.createRequire( NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), @@ -112,7 +134,7 @@ vi.mock("../../state/entities", () => ({ [ "environment", { - environment: { platform: { os: "linux" }, capabilities: {} }, + environment: { platform: { os: state.os }, capabilities: {} }, newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, @@ -134,16 +156,27 @@ vi.mock("../../state/use-remote-environment-registry", () => ({ }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); -vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: + () => + async ({ input }: { input: { partialPath: string } }) => + // filesystem.browse rejects an empty partialPath. + input.partialPath === "" + ? AsyncResult.failure(Cause.fail(new Error("Invalid filesystem.browse input"))) + : AsyncResult.success(browseResult(input.partialPath)), +})); vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), })); -vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); +vi.mock("../../state/filesystem", () => ({ + filesystemEnvironment: { browse: (target: unknown) => target }, +})); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + state.cloneDestinations.push(input.destinationPath); if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success(undefined); @@ -498,14 +531,18 @@ describe("local project navigation", () => { state.waitForProject.mockReset(); const created = Promise.withResolvers>>(); const projected = Promise.withResolvers(); + const waitingForProject = Promise.withResolvers(); state.createResult = created.promise; - state.waitForProject.mockReturnValue(projected.promise); + state.waitForProject.mockImplementation(() => { + waitingForProject.resolve(); + return projected.promise; + }); const submit = findAction( renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), "Add project", ); if (!submit) throw new Error("Add project action missing"); - return { created, projected, submit }; + return { created, projected, waitingForProject, submit }; } it("keeps navigation in place and explains a project projection timeout", async () => { @@ -632,12 +669,12 @@ describe("local project navigation", () => { ])( "waits for projection then selects the draft from $origin", async ({ withDraft, withChooser }) => { - const { created, projected, submit } = setup(); + const { created, projected, waitingForProject, submit } = setup(); const navigation = mountProjectFlow(withDraft, withChooser, true); const before = navigation.getState(); const submission = submit(); created.resolve(AsyncResult.success(undefined)); - await created.promise; + await waitingForProject.promise; expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); expect(navigation.getState()).toBe(before); projected.resolve({ environmentId: "environment", id: "project" }); @@ -756,3 +793,91 @@ describe("local folder path", () => { expect(screen.path).toBe("~/code/app"); }); }); + +describe("adding an already registered project", () => { + const existing = { + environmentId: "environment", + id: "existing-project", + title: "Bike documents", + workspaceRoot: "/home/tester/repro/bike-documents", + }; + + function setup(input: { baseDirectory: string; os?: string; home?: string; root?: string }) { + state.navigationState = undefined; + state.scopes = new Set([ + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + AuthFilesystemReadScope, + ]); + state.baseDirectory = input.baseDirectory; + state.os = input.os ?? "linux"; + state.home = input.home ?? "/home/tester"; + state.existingProjects = [{ ...existing, workspaceRoot: input.root ?? existing.workspaceRoot }]; + state.projects = []; + state.cloneDestinations = []; + state.createResult = null; + state.startCloneResult = null; + state.dispatch.mockReset(); + state.alert.mockReset(); + return { + navigation: mountProjectFlow(false), + [Symbol.dispose]: () => { + state.os = "linux"; + state.home = "/home/tester"; + state.existingProjects = []; + }, + }; + } + + it.each([ + { path: "~/repro/bike-documents/" }, + { path: "~/", root: "/home/tester" }, + { + path: "~\\repro\\bike-documents", + os: "windows", + home: "C:\\Users\\tester", + root: "C:\\Users\\tester\\repro\\bike-documents", + }, + ])("opens the existing project for local folder $path", async ({ path, ...host }) => { + using flow = setup({ baseDirectory: path, ...host }); + const submit = findAction( + renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), + "Add project", + ); + if (!submit) throw new Error("Add project action missing"); + await submit(); + + expect(state.projects).toEqual([]); + expect(state.alert).not.toHaveBeenCalled(); + expect(flow.navigation.getState().routes.at(-1)?.params).toEqual({ + draft: { environmentId: "environment", projectId: existing.id, title: existing.title }, + }); + }); + + it("opens the existing project with the prompt instead of cloning into it", async () => { + using flow = setup({ baseDirectory: "~/repro" }); + const submit = findAction( + renderScreen(() => + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/bike-documents.git", + repositoryName: "bike-documents", + prompt: "Fix the bike", + }), + ), + "Clone project", + ); + if (!submit) throw new Error("Clone action missing"); + await submit(); + + expect(state.cloneDestinations).toEqual([]); + expect(flow.navigation.getState().routes.at(-1)?.params).toEqual({ + draft: { + environmentId: "environment", + projectId: existing.id, + title: existing.title, + draftId: "prompt-draft", + }, + }); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index c41e1e20ba678..faac2abc5df1b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -377,6 +377,32 @@ function useBrowsePathInput( return committed; }; + // `~` expands on the host, so ask its browser for the absolute directory before matching + // registered roots. Resolution trims `~/` to `~` and the browse helper only knows `~/`, + // so restore the separator, and accept Windows `~\` the way the host expands it. + const resolveHomePath = async (path: string): Promise => { + if ( + !/^~(?:[\\/]|$)/.test(path) || + !environment || + !readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { + return path; + } + const browseQuery = + path === "~" || (isWindowsPlatform(environment.platform) && path.startsWith("~\\")) + ? `~/${path.slice(2)}` + : path; + const browsePath = getFilesystemBrowsePath(browseQuery, environment.platform); + const result = await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: browsePath.directoryPath }, + }); + return AsyncResult.isSuccess(result) && result.value + ? getCloneDestinationPath(result.value.parentPath, browsePath.filterQuery) + : path; + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates the browse navigation coordinator on unmount so in-flight browses do not commit useEffect( () => () => { @@ -385,7 +411,7 @@ function useBrowsePathInput( [browseNavigation], ); - return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath, resolveHomePath }; } function useEnvironmentOptions( @@ -721,16 +747,65 @@ async function draftWithPrompt( return draftId; } -function useCreateProject(environment: EnvironmentOption | null) { +function useOpenExistingProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); - const navigation = useNavigation(); - const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const updateProject = useAtomCommand(projectEnvironment.update, { label: "project unarchive", }); const projects = useProjects(); - return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { + return async ( + path: string, + originRoute: ReturnType, + prompt?: string | null, + ): Promise => { + if (!environment) return false; + const existing = findExistingAddProject({ + projects, + environmentId: environment.environmentId, + path, + }); + if (!existing) return false; + // Adding an archived project's folder again brings the project back. + if ( + existing.archivedAt != null && + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag !== "Success" + ) { + Dialog.alert( + "Project already exists", + `${existing.title} is still archived. Try again later.`, + ); + } + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + ...(prompt + ? { draftId: await draftWithPrompt(existing.environmentId, existing.id, prompt) } + : {}), + }, + originRoute, + ); + return true; + }; +} + +function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openExistingProject = useOpenExistingProject(environment); + + return async ( + workspaceRoot: string, + lookupPath: string, + originRoute: ReturnType, + ) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -743,35 +818,7 @@ function useCreateProject(environment: EnvironmentOption | null) { return; } - const existing = findExistingAddProject({ - projects, - environmentId: environment.environmentId, - path: workspaceRoot, - }); - if (existing) { - // Adding an archived project's folder again brings the project back. - const unarchived = - existing.archivedAt == null || - ( - await updateProject({ - environmentId: existing.environmentId, - input: { projectId: existing.id, archived: false }, - }) - )._tag === "Success"; - Dialog.alert( - "Project already exists", - unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, - ); - openNewTaskDraft( - { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }, - originRoute, - ); - return; - } + if (await openExistingProject(lookupPath, originRoute)) return; const projectId = ProjectId.make(uuidv4()); const command = buildProjectCreateCommand({ @@ -1301,8 +1348,9 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st environment?.environmentId ?? null, AuthOrchestrationOperateScope, ); + const navigation = useNavigation(); const createProject = useCreateProject(environment); - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + const { isBrowseNavigating, navigateToBrowsePath, pathInput, resolveHomePath, setPathInput } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1320,8 +1368,13 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st return; } + const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - const result = await createProject(resolved.path); + const result = await createProject( + resolved.path, + await resolveHomePath(resolved.path), + originRoute, + ); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } @@ -1392,11 +1445,9 @@ export function AddProjectDestinationScreen(props: { ? getCloneDirectoryPath(remoteUrl, true, repositoryName) : null; const cloneDirectoryName = ghqDirectoryPath?.split("/").at(-1) ?? repositoryName; - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput( - environment, - cloneDirectoryName, - ghqDirectoryPath ?? repositoryName, - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, resolveHomePath, setPathInput } = + useBrowsePathInput(environment, cloneDirectoryName, ghqDirectoryPath ?? repositoryName); + const openExistingProject = useOpenExistingProject(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1424,6 +1475,16 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); + try { + if (await openExistingProject(await resolveHomePath(resolved.path), originRoute, prompt)) { + setIsSubmitting(false); + return; + } + } catch (cause) { + setError(errorMessage(cause)); + setIsSubmitting(false); + return; + } const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId,