From 13362cb6cf542cc630ea0bc52e254b06cc891066 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:38:58 +0000 Subject: [PATCH 1/3] test(mobile): adding a folder that is already a project reuses it Failing tests for #23237: a home-relative path to an existing project sends project.create, and cloning into an existing project's folder clones and creates instead of opening it. AI-assisted; verified with the tests and tools listed above. --- .../projects/AddProjectScreen.test.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e15..0f419c6582e3e 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -274,6 +274,26 @@ describe("clone project permissions", () => { expect(state.projects).toEqual([]); }, ); + + it("opens the existing project instead of cloning into its folder", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "repo", + workspaceRoot: NodePath.join(state.baseDirectory, "repo"), + }, + ]; + try { + await cloneAction()(); + + expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); + expect(state.projects).toEqual([]); + } finally { + state.existingProjects = []; + } + }); }); function mountProjectFlow( @@ -488,10 +508,10 @@ describe("add project source environment", () => { }); describe("local project navigation", () => { - function setup() { + function setup(baseDirectory = "/tmp/t3-mobile-audit-fixture") { state.navigationState = undefined; state.scopes = new Set([AuthOrchestrationOperateScope]); - state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.baseDirectory = baseDirectory; state.projects = []; state.dispatch.mockReset(); state.alert.mockReset(); @@ -523,6 +543,27 @@ describe("local project navigation", () => { ); }); + it("opens an existing project typed as a home-relative path instead of creating it", async () => { + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "bike-documents", + workspaceRoot: "/home/tester/projects/bike-documents", + }, + ]; + try { + const { created, projected, submit } = setup("~/projects/bike-documents/"); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submit(); + + expect(state.projects).toEqual([]); + } finally { + state.existingProjects = []; + } + }); + it("does not replace a reopened flow when the previous submission finishes", async () => { const { created, projected, submit } = setup(); const navigation = mountProjectFlow(false); From 5558f6b85e30e19d17239dc9bff0125d7c88389d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:45:35 +0000 Subject: [PATCH 2/3] fix(mobile): adding a folder that is already a project opens it Adding a local folder typed as a home-relative path (`~/...`) compared the text against absolute workspace roots, missed the existing project, and sent a project.create the server rejected. The clone destination flow never checked for an existing project at all. Both flows now expand `~` by browsing the host's home before matching, and the clone flow opens the existing project's draft, keeping a prompt-link prompt, instead of cloning into it. Fixes pingdotgg/t3code#23237 AI-assisted; verified with the tests and tools listed above. --- .../projects/AddProjectScreen.test.ts | 17 ++- .../features/projects/AddProjectScreen.tsx | 129 ++++++++++++------ 2 files changed, 105 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index 0f419c6582e3e..c40baa7352a60 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"; @@ -134,7 +138,15 @@ 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: () => () => {} })); +// Only filesystem.browse of `~` answers, with the host's home as its parentPath. +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: + () => + async ({ input }: { input?: { partialPath?: string } }) => + input?.partialPath === "~" + ? AsyncResult.success({ parentPath: "/home/tester", entries: [] }) + : undefined, +})); vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), @@ -554,6 +566,7 @@ describe("local project navigation", () => { ]; try { const { created, projected, submit } = setup("~/projects/bike-documents/"); + state.scopes.add(AuthFilesystemReadScope); created.resolve(AsyncResult.success(undefined)); projected.resolve(null); await submit(); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index c41e1e20ba678..d0482c34b2e3e 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -385,7 +385,26 @@ function useBrowsePathInput( [browseNavigation], ); - return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; + // Registered roots are absolute and `~` expands on the host, so ask the host for its home. + const resolveHomePath = async (path: string): Promise => { + if ( + !/^~(?:[\\/]|$)/.test(path) || + !environment || + !readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { + return path; + } + const home = await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: "~" }, + }); + return AsyncResult.isSuccess(home) && home.value + ? `${home.value.parentPath.replace(/[\\/]+$/, "")}${path.slice(1)}` + : path; + }; + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath, resolveHomePath }; } function useEnvironmentOptions( @@ -721,16 +740,67 @@ 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. + 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, + ...(prompt + ? await draftWithPrompt(existing.environmentId, existing.id, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); + return true; + }; +} + +function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); + const navigation = useNavigation(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openExistingProject = useOpenExistingProject(environment); + + return async ( + workspaceRoot: string, + originRoute = getActiveRoute(navigation), + lookupPath = workspaceRoot, + ) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -743,33 +813,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, - ); + if (await openExistingProject(lookupPath, originRoute)) { return; } @@ -1301,8 +1345,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, setPathInput, resolveHomePath } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1321,7 +1366,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st } setIsSubmitting(true); - const result = await createProject(resolved.path); + const originRoute = getActiveRoute(navigation); + const lookupPath = resolved.path.startsWith("~") + ? await resolveHomePath(resolved.path) + : resolved.path; + const result = await createProject(resolved.path, originRoute, lookupPath); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } @@ -1392,11 +1441,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, setPathInput, resolveHomePath } = + useBrowsePathInput(environment, cloneDirectoryName, ghqDirectoryPath ?? repositoryName); + const openExistingProject = useOpenExistingProject(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1424,6 +1471,10 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); + if (await openExistingProject(await resolveHomePath(resolved.path), originRoute, prompt)) { + setIsSubmitting(false); + return; + } const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, From 5274434be612cd716aa84fe4a81eff9bd728142a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:02:47 +0000 Subject: [PATCH 3/3] fix(mobile): a clone into another repository's project folder no longer opens that project Co-Authored-By: Claude Opus 5.5 --- .../projects/AddProjectScreen.test.ts | 23 +++++++++++++++---- .../features/projects/AddProjectScreen.tsx | 18 +++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index c40baa7352a60..4718133f7a5a5 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -26,7 +26,9 @@ const state = vi.hoisted(() => ({ id: string; title: string; workspaceRoot: string; + repositoryIdentity?: { canonicalKey: string }; }[], + cloneDestinations: [] as string[], createResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, @@ -156,6 +158,7 @@ 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); @@ -242,7 +245,7 @@ function cloneAction() { renderScreen(() => AddProjectDestinationScreen({ environmentId: "environment", - remoteUrl: "https://example.com/repo.git", + remoteUrl: "https://example.com/owner/repo.git", repositoryName: "repo", }), ), @@ -287,23 +290,35 @@ describe("clone project permissions", () => { }, ); - it("opens the existing project instead of cloning into its folder", async () => { + it.each([ + [ + "opens the existing project of that repository instead of cloning into its folder", + "example.com/owner/repo", + false, + ], + ["leaves a folder holding another repository to the clone", "example.com/owner/other", true], + ])("%s", async (_name, canonicalKey, clones) => { state.scopes.add(AuthOrchestrationOperateScope); + state.cloneDestinations = []; + state.startCloneResult = Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Destination is not empty"))), + ); state.existingProjects = [ { environmentId: "environment", id: "existing", title: "repo", workspaceRoot: NodePath.join(state.baseDirectory, "repo"), + repositoryIdentity: { canonicalKey }, }, ]; try { await cloneAction()(); - expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); - expect(state.projects).toEqual([]); + expect(state.cloneDestinations.length > 0).toBe(clones); } finally { state.existingProjects = []; + state.startCloneResult = null; } }); }); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a39520403eacf..56518cd071eac 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -759,7 +759,7 @@ function useOpenExistingProject(environment: EnvironmentOption | null) { return async ( path: string, originRoute: ReturnType, - prompt?: string | null, + clone?: { readonly remoteUrl: string; readonly prompt: string | null }, ): Promise => { if (!environment) return false; const existing = findExistingAddProject({ @@ -768,6 +768,15 @@ function useOpenExistingProject(environment: EnvironmentOption | null) { path, }); if (!existing) return false; + // A folder holding another repository is not this clone; let the clone report it occupied. + if ( + clone && + existing.repositoryIdentity?.canonicalKey !== + sourceControlClients.repositoryKeyFromRemote(clone.remoteUrl) + ) { + return false; + } + const prompt = clone?.prompt; // Adding an archived project's folder again brings the project back. const unarchived = existing.archivedAt == null || @@ -1483,7 +1492,12 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); - if (await openExistingProject(await resolveHomePath(resolved.path), originRoute, prompt)) { + if ( + await openExistingProject(await resolveHomePath(resolved.path), originRoute, { + remoteUrl, + prompt: prompt ?? null, + }) + ) { setIsSubmitting(false); return; }