diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz deleted file mode 100644 index 9346f85dd023b..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz new file mode 100644 index 0000000000000..2e076b177f3f8 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz differ diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9b28ed178b650..d60f6117e9967 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.44.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.45.tgz", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", "react-native-worklets": "0.13.0", diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index fe2cb546a3ca2..3331f8e644084 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -2,25 +2,61 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { isValidElement, type ReactNode } from "react"; +import { act, createElement, isValidElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ scopes: new Set(), baseDirectory: "", projects: [] as string[], + existingProjects: [] as { + environmentId: string; + id: string; + title: string; + workspaceRoot: string; + }[], + createResult: null as Promise | null, + cloneResult: null as Promise | null, + createNewResult: null as Promise | null, + startCloneResult: null as Promise | null, + connectionStates: {} as Record, + newProjectsRoot: null as string | null, + supportsCloneTracking: false, + waitForProject: vi.fn(), + dispatch: vi.fn(), + alert: vi.fn(), + navigationState: undefined as NavigationState | undefined, + environments: [{ environmentId: "environment", environmentLabel: "Environment" }], })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useCallback: (callback: unknown) => callback, - useMemo: (factory: () => unknown) => factory(), - useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], - useRef: (current: unknown) => ({ current }), - useEffect: () => {}, +function loadRouters() { + const requireNavigation = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), + ); + return requireNavigation("@react-navigation/routers") as { + StackRouter: typeof import("@react-navigation/native").StackRouter; + StackActions: typeof import("@react-navigation/native").StackActions; + CommonActions: typeof import("@react-navigation/native").CommonActions; + }; +} + +vi.mock("../workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); + return { + useSelectWorkspaceDetail: () => (detail: Parameters[0]) => + state.dispatch(selectWorkspaceDetail(detail)), + }; +}); +vi.mock("../../native/StackHeader", () => ({ + nativeHeaderScrollEdgeEffects: () => undefined, })); vi.mock("../../components/ui/NavigationRow", () => ({ NavigationRow: "NavigationRow" })); vi.mock("../../components/ui/Button", () => ({ Button: "Button" })); @@ -30,15 +66,30 @@ vi.mock("../../components/ui/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); vi.mock("react-native", () => ({ Platform: { OS: "ios" }, ActivityIndicator: "ActivityIndicator", - Alert: { alert: () => {} }, + Alert: { alert: state.alert }, Pressable: "Pressable", ScrollView: "ScrollView", View: "View", })); vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ dispatch: () => {} }), + useNavigation: () => ({ + dispatch: state.dispatch, + getState: () => + state.navigationState ?? { + index: 4, + routes: [ + { key: "home", name: "Home" }, + { key: "draft", name: "Thread", params: { draft: {} } }, + { key: "picker", name: "NewTaskProject" }, + { key: "add", name: "AddProject" }, + { key: "local", name: "AddProjectLocal" }, + ], + }, + }), CommonActions: { reset: (input: unknown) => input }, - StackActions: {}, + StackActions: { + push: (name: string, params: unknown) => ({ type: "PUSH", payload: { name, params } }), + }, })); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }), @@ -56,13 +107,18 @@ vi.mock("../../state/session", () => ({ readEnvironmentScope: (_environmentId: string, scope: string) => state.scopes.has(scope), })); vi.mock("../../state/entities", () => ({ - useProjects: () => [], + useProjects: () => state.existingProjects, + waitForProject: state.waitForProject, useServerConfigs: () => new Map([ [ "environment", { - environment: { platform: { os: "linux" }, capabilities: {} }, + environment: { + platform: { os: "linux" }, + capabilities: { projectCloneTracking: state.supportsCloneTracking }, + }, + newProjectsRoot: state.newProjectsRoot, settings: { addProjectBaseDirectory: state.baseDirectory }, }, ], @@ -71,12 +127,15 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../state/use-remote-environment-registry", () => ({ useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), useRemoteConnectionStatus: () => ({ - connectedEnvironments: [{ environmentId: "environment", connectionState: "connected" }], + connectedEnvironments: state.environments.map((environment) => ({ + environmentId: environment.environmentId, + connectionState: state.connectionStates[environment.environmentId] ?? "connected", + })), }), useSavedRemoteConnections: () => ({ - savedConnectionsById: { - connection: { environmentId: "environment", environmentLabel: "Environment" }, - }, + savedConnectionsById: Object.fromEntries( + state.environments.map((environment) => [environment.environmentId, environment]), + ), }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); @@ -88,7 +147,10 @@ vi.mock("../../state/presentation", () => ({ vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { + discovery: (input: unknown) => input, + startProjectClone: () => state.startCloneResult, cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + if (state.cloneResult) return state.cloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success({ cwd: input.destinationPath }); }, @@ -96,41 +158,90 @@ vi.mock("../../state/sourceControl", () => ({ })); vi.mock("../../state/projects", () => ({ projectEnvironment: { - create: async ({ input }: { input: { workspaceRoot: string } }) => { + createNew: () => state.createNewResult, + create: ({ input }: { input: { workspaceRoot: string } }) => { if (!state.scopes.has(AuthOrchestrationOperateScope)) { - return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Project creation denied"))), + ); } state.projects.push(input.workspaceRoot); - return AsyncResult.success(undefined); + return state.createResult ?? Promise.resolve(AsyncResult.success(undefined)); }, }, })); -import { AddProjectDestinationScreen } from "./AddProjectScreen"; +vi.mock("../../state/use-composer-drafts", () => ({ + waitForComposerDraftsLoaded: async () => {}, + createNewTaskDraft: () => "prompt-draft", + setComposerDraftText: () => {}, +})); + +import { + AddProjectNewScreen, + AddProjectDestinationScreen, + AddProjectLocalFolderScreen, + AddProjectSourceScreen, +} from "./AddProjectScreen"; +import { AddProjectSourceRoute } from "./AddProjectSourceRoute"; -function findAction(node: ReactNode, label: string): (() => unknown) | null { +function findAction( + node: ReactNode, + label: string, + includeDisabled = false, +): (() => unknown) | null { if (Array.isArray(node)) { for (const child of node) { - const action = findAction(child, label); + const action = findAction(child, label, includeDisabled); if (action) return action; } return null; } - if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + if ( + !isValidElement<{ + label?: string; + title?: string; + onPress?: () => unknown; + disabled?: boolean; + children?: ReactNode; + }>(node) + ) { return null; } - if (node.props.label === label) return node.props.onPress ?? null; - return findAction(node.props.children, label); + if ((node.props.label ?? node.props.title) === label) { + return node.props.disabled && !includeDisabled ? null : (node.props.onPress ?? null); + } + return findAction(node.props.children, label, includeDisabled); +} + +const mountedRoots: ReturnType[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) act(() => root.unmount()); +}); +function renderScreen(render: () => ReactNode) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + mountedRoots.push(root); + let screen: ReactNode; + function Observer() { + screen = render(); + return null; + } + act(() => root.render(createElement(Observer))); + return screen; } function cloneAction() { const action = findAction( - AddProjectDestinationScreen({ - environmentId: "environment", - remoteUrl: "https://example.com/repo.git", - repositoryName: "repo", - }), + renderScreen(() => + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ), "Clone project", + true, ); if (!action) throw new Error("Clone action missing"); return action; @@ -143,6 +254,7 @@ describe("clone project permissions", () => { ); state.scopes = new Set([AuthSourceControlWriteScope]); state.projects = []; + state.createResult = null; }); afterEach(async () => { @@ -169,3 +281,408 @@ describe("clone project permissions", () => { }, ); }); + +function mountProjectFlow( + withDraft: boolean, + withChooser = true, + withPullRequest = false, + screenName = "AddProjectLocal", +) { + const { StackRouter, StackActions, CommonActions } = loadRouters(); + const base = StackRouter({}); + const router = { ...base, ...workspaceDetailRouter(base) }; + const options = { + routeNames: [ + "Home", + "Thread", + "PullRequest", + "PullRequestFile", + "NewTaskProject", + "AddProject", + "AddProjectLocal", + "AddProjectNew", + "AddProjectDestination", + ], + routeParamList: {}, + routeGetIdList: {}, + }; + let current = router.getInitialState(options); + const home = current.routes[0]!; + const apply = (action: Parameters[1]) => { + const next = router.getStateForAction(current, action, options); + if (!next || next.stale !== false) throw new Error("Navigation action rejected"); + current = next; + state.navigationState = current; + }; + if (withPullRequest) { + apply(StackActions.push("PullRequest", { number: 123 })); + apply(StackActions.push("PullRequestFile", { path: "file.ts" })); + } + const underlyingRoutes = current.routes; + if (withDraft) { + apply(StackActions.push("Thread", { draft: { projectId: "previous-project" } })); + } + const draftKey = withDraft ? current.routes[current.index]!.key : null; + for (const name of [...(withChooser ? ["NewTaskProject"] : []), "AddProject", screenName]) { + apply(StackActions.push(name)); + } + state.navigationState = current; + state.dispatch.mockImplementation((action) => + apply( + action.type === "PUSH" || action.type === "SELECT_WORKSPACE_DETAIL" + ? action + : CommonActions.reset(action), + ), + ); + return { + home, + draftKey, + underlyingRoutes, + flowRoutes: current.routes.slice(underlyingRoutes.length), + getState: () => current, + back: () => apply(StackActions.pop()), + setParams: (params: object) => apply(CommonActions.setParams(params)), + push: (name: string) => apply(StackActions.push(name)), + }; +} + +describe("add project source environment", () => { + it("blocks local add on a requested unavailable host until reconnect or manual choice", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + for (const phase of ["available", "offline", "error"] as const) { + state.connectionStates = { environment: phase }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(state.dispatch).not.toHaveBeenCalled(); + } + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const reconnectedLocal = findAction(screen, "Local folder"); + if (!reconnectedLocal) throw new Error("Reconnected local folder action missing"); + reconnectedLocal(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "environment" } }, + }); + state.connectionStates = { environment: "offline" }; + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Manually selected local folder action missing"); + local(); + expect(state.dispatch).toHaveBeenLastCalledWith({ + type: "PUSH", + payload: { name: "AddProjectLocal", params: { environmentId: "another-environment" } }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("keeps a manual host selection when the requested host finishes connecting", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + state.connectionStates = { environment: "connecting" }; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function SourceObserver() { + screen = AddProjectSourceScreen({ environmentId: "environment" }); + return null; + } + try { + await act(() => root.render(createElement(SourceObserver))); + expect(findAction(screen, "Local folder")).toBeNull(); + expect(navigation.getState().routes.at(-1)?.name).toBe("AddProject"); + const select = findAction(screen, "Another environment"); + if (!select) throw new Error("Connected environment selection missing"); + await act(() => select()); + state.connectionStates = {}; + await act(() => root.render(createElement(SourceObserver))); + const local = findAction(screen, "Local folder"); + if (!local) throw new Error("Local folder action missing"); + local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId: "another-environment" }, + }); + } finally { + await act(() => root.unmount()); + state.connectionStates = {}; + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); + + it("follows warm environment requests while preserving manual selection between requests", async () => { + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.dispatch.mockReset(); + const navigation = mountProjectFlow(false, true, false, "AddProject"); + state.environments = [ + { environmentId: "environment", environmentLabel: "Environment" }, + { environmentId: "another-environment", environmentLabel: "Another environment" }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let actions: { local: () => unknown; selectEnvironment: () => unknown } | undefined; + function SourceRouteObserver(props: { environmentId?: string | string[] }) { + const route = AddProjectSourceRoute({ route: { params: props } }); + const screen = AddProjectSourceScreen(route.props); + const local = findAction(screen, "Local folder"); + const selectEnvironment = findAction(screen, "Environment"); + if (!local || !selectEnvironment) throw new Error("Source actions missing"); + actions = { local, selectEnvironment }; + return null; + } + const openLocalFolder = (environmentId: string) => { + state.dispatch.mockClear(); + actions!.local(); + expect(navigation.getState().routes.at(-1)).toMatchObject({ + name: "AddProjectLocal", + params: { environmentId }, + }); + navigation.back(); + }; + try { + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + await act(() => actions!.selectEnvironment()); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: ["another-environment"] })), + ); + openLocalFolder("environment"); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "environment" })), + ); + await act(() => + root.render(createElement(SourceRouteObserver, { environmentId: "another-environment" })), + ); + openLocalFolder("another-environment"); + } finally { + await act(() => root.unmount()); + state.environments = [{ environmentId: "environment", environmentLabel: "Environment" }]; + } + }); +}); + +describe("local project navigation", () => { + function setup() { + state.navigationState = undefined; + state.scopes = new Set([AuthOrchestrationOperateScope]); + state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.projects = []; + state.dispatch.mockReset(); + state.alert.mockReset(); + state.waitForProject.mockReset(); + const created = Promise.withResolvers>>(); + const projected = Promise.withResolvers(); + state.createResult = created.promise; + state.waitForProject.mockReturnValue(projected.promise); + const submit = findAction( + renderScreen(() => AddProjectLocalFolderScreen({ environmentId: "environment" })), + "Add project", + ); + if (!submit) throw new Error("Add project action missing"); + return { created, projected, submit }; + } + + it("keeps navigation in place and explains a project projection timeout", async () => { + const { created, projected, submit } = setup(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(state.dispatch).not.toHaveBeenCalled(); + expect(state.alert).toHaveBeenCalledExactlyOnceWith( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + }); + + it("does not replace a reopened flow when the previous submission finishes", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + navigation.back(); + navigation.push("AddProjectLocal"); + const remaining = navigation.getState(); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not replace a warm-linked flow sharing the original route key", async () => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(false); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + const originalKey = navigation.getState().routes.at(-1)!.key; + navigation.setParams({ environmentId: "other-environment" }); + const remaining = navigation.getState(); + expect(remaining.routes.at(-1)!.key).toBe(originalKey); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + + it("does not redirect a reopened flow when an earlier legacy clone finishes", async () => { + const { created, projected } = setup(); + state.scopes.add(AuthSourceControlWriteScope); + const cloned = Promise.withResolvers(); + state.cloneResult = cloned.promise; + const navigation = mountProjectFlow(false, true, true, "AddProjectDestination"); + try { + const submission = cloneAction()(); + navigation.back(); + navigation.push("AddProjectDestination"); + const remaining = navigation.getState(); + cloned.resolve(AsyncResult.success({ cwd: "/tmp/t3-mobile-audit-fixture/repo" })); + created.resolve(AsyncResult.success(undefined)); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture/repo"]); + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + state.cloneResult = null; + } + }); + + it("does not redirect a reopened flow when an earlier new project finishes", async () => { + const { projected } = setup(); + state.newProjectsRoot = "/projects"; + const created = Promise.withResolvers(); + const waitingForProject = Promise.withResolvers(); + state.createNewResult = created.promise; + state.waitForProject.mockImplementation(() => { + waitingForProject.resolve(); + return projected.promise; + }); + const navigation = mountProjectFlow(false, true, true, "AddProjectNew"); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let screen: ReactNode; + function NewProjectObserver() { + screen = AddProjectNewScreen({ environmentId: "environment" }); + return null; + } + function enterName(node: ReactNode): boolean { + if (Array.isArray(node)) return node.some(enterName); + if ( + !isValidElement<{ + placeholder?: string; + onChangeText?: (text: string) => void; + children?: ReactNode; + }>(node) + ) + return false; + if (node.props.placeholder === "Project name") { + node.props.onChangeText!("new-project"); + return true; + } + return enterName(node.props.children); + } + try { + await act(() => root.render(createElement(NewProjectObserver))); + await act(() => { + expect(enterName(screen)).toBe(true); + }); + const submit = findAction(screen, "Create project"); + if (!submit) throw new Error("Create project action missing"); + await act(async () => { + submit(); + created.resolve( + AsyncResult.success({ + projectId: "project", + workspaceRoot: "/projects/new-project", + }), + ); + await waitingForProject.promise; + }); + navigation.back(); + navigation.push("AddProjectNew"); + const remaining = navigation.getState(); + await act(async () => { + projected.resolve({ environmentId: "environment", id: "project" }); + await projected.promise; + }); + + expect(navigation.getState()).toEqual(remaining); + expect(state.dispatch).not.toHaveBeenCalled(); + } finally { + await act(() => root.unmount()); + state.newProjectsRoot = null; + state.createNewResult = null; + } + }); + + it.each([ + { origin: "Thread", withDraft: true, withChooser: true }, + { origin: "NewTaskProject", withDraft: false, withChooser: true }, + { origin: "AddProject", withDraft: false, withChooser: false }, + ])( + "waits for projection then selects the draft from $origin", + async ({ withDraft, withChooser }) => { + const { created, projected, submit } = setup(); + const navigation = mountProjectFlow(withDraft, withChooser, true); + const before = navigation.getState(); + const submission = submit(); + created.resolve(AsyncResult.success(undefined)); + await created.promise; + expect(state.projects).toEqual(["/tmp/t3-mobile-audit-fixture"]); + expect(navigation.getState()).toBe(before); + projected.resolve({ environmentId: "environment", id: "project" }); + await submission; + expect(state.dispatch).toHaveBeenCalledTimes(1); + expect(navigation.getState().routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(navigation.getState().routes[1]?.params).toEqual({ + draft: { + environmentId: "environment", + projectId: "project", + title: "t3-mobile-audit-fixture", + }, + }); + navigation.back(); + expect(navigation.getState().routes).toEqual([navigation.home]); + }, + ); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 6de42ccd7a1bc..49de8d219ba2b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,5 @@ +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; @@ -56,7 +58,6 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import { - CommonActions, StackActions, useNavigation, type NavigationState, @@ -370,7 +371,9 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; } -function useEnvironmentOptions(): ReadonlyArray { +function useEnvironmentOptions( + selectedEnvironmentId: EnvironmentId | null = null, +): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); const { connectedEnvironments } = useRemoteConnectionStatus(); @@ -396,25 +399,30 @@ function useEnvironmentOptions(): ReadonlyArray { }; }); return Arr.sort( - options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)), + options.filter( + (environment) => + canCreateProjectInEnvironment(environment.connectionState) || + environment.environmentId === selectedEnvironmentId, + ), environmentOptionOrder, ); })(); } -function useSelectedEnvironment(): { +function useSelectedEnvironment(requestedEnvironmentId: EnvironmentId | null): { readonly environmentOptions: ReadonlyArray; readonly selectedEnvironment: EnvironmentOption | null; readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; } { - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentOptions = useEnvironmentOptions(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(requestedEnvironmentId); + const [previousRequest, setPreviousRequest] = useState(requestedEnvironmentId); + if (previousRequest !== requestedEnvironmentId) { + setPreviousRequest(requestedEnvironmentId); + setSelectedEnvironmentId(requestedEnvironmentId); + } + const environmentOptions = useEnvironmentOptions(selectedEnvironmentId); const selectedEnvironment = - environmentOptions.find( - (environment) => - environment.environmentId === selectedEnvironmentId && - canCreateProjectInEnvironment(environment.connectionState), - ) ?? + environmentOptions.find((environment) => environment.environmentId === selectedEnvironmentId) ?? environmentOptions.find((environment) => canCreateProjectInEnvironment(environment.connectionState), ) ?? @@ -501,10 +509,10 @@ function SourceControlRow(props: { ); } -export function AddProjectSourceScreen() { +export function AddProjectSourceScreen(props: { readonly environmentId?: string | string[] }) { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = - useSelectedEnvironment(); + useSelectedEnvironment(stringParam(props.environmentId) as EnvironmentId | null); const canWriteSourceControl = useEnvironmentScope( selectedEnvironment?.environmentId ?? null, AuthSourceControlWriteScope, @@ -513,9 +521,10 @@ export function AddProjectSourceScreen() { selectedEnvironment?.environmentId ?? null, AuthOrchestrationOperateScope, ); - const canCloneProject = canWriteSourceControl && canCreateProject; + const isConnected = canCreateProjectInEnvironment(selectedEnvironment?.connectionState); + const canCloneProject = isConnected && canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( - selectedEnvironment === null + selectedEnvironment === null || !isConnected ? null : sourceControlEnvironment.discovery({ environmentId: selectedEnvironment.environmentId, @@ -528,7 +537,7 @@ export function AddProjectSourceScreen() { {selectedEnvironment === null ? : null} - {environmentOptions.length > 1 ? ( + {environmentOptions.length > 1 || (selectedEnvironment !== null && !isConnected) ? ( <> Environments @@ -579,6 +588,7 @@ export function AddProjectSourceScreen() { } isFirst={selectedEnvironment.newProjectsRoot === null} - disabled={!canCreateProject} + disabled={!isConnected || !canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -650,27 +660,37 @@ export function AddProjectSourceScreen() { ); } -function openNewTaskDraft( - navigation: { - dispatch: (action: ReturnType) => void; - getState: () => NavigationState | undefined; - }, - params: { - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - cloning?: "1"; - draftId?: string; - }, -) { +function getActiveRoute(navigation: { + getState: () => NavigationState | undefined; +}) { const state = navigation.getState(); - if (!state) return; - const threadIndex = state.routes.findLastIndex((route) => route.name === "Thread"); - const routes = [ - ...state.routes.slice(0, threadIndex < 0 ? state.index : threadIndex), - { key: uuidv4(), name: "Thread", params: { draft: params } }, - ]; - navigation.dispatch(CommonActions.reset({ ...state, index: routes.length - 1, routes })); + return state?.routes[state.index]; +} + +function useOpenNewTaskDraft() { + const navigation = useNavigation>(); + const selectDetail = useSelectWorkspaceDetail(); + return ( + params: { + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + cloning?: "1"; + draftId?: string; + }, + originRoute: NavigationState["routes"][number] | undefined, + ) => { + const activeRoute = getActiveRoute(navigation); + // A warm link can change the task without replacing its native route key. + if ( + !originRoute || + activeRoute?.key !== originRoute.key || + activeRoute.name !== originRoute.name || + activeRoute.params !== originRoute.params + ) + return; + selectDetail({ name: "Thread", params: { draft: params } }); + }; } async function draftWithPrompt( @@ -685,11 +705,12 @@ async function draftWithPrompt( } function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const projects = useProjects(); - return async (workspaceRoot: string) => { + return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -709,11 +730,14 @@ function useCreateProject(environment: EnvironmentOption | null) { }); if (existing) { Dialog.alert("Project already exists", existing.title); - openNewTaskDraft(navigation, { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + }, + originRoute, + ); return; } @@ -730,11 +754,27 @@ function useCreateProject(environment: EnvironmentOption | null) { if (AsyncResult.isFailure(result)) { return result; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: inferProjectTitleFromPath(workspaceRoot), - }); + // A successful command can precede its project in the client store. The + // draft treats a missing project as a reason to return to the chooser. + const project = await waitForProject( + { environmentId: environment.environmentId, projectId }, + 15_000, + ); + if (project === null) { + Dialog.alert( + "Project added", + "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", + ); + return result; + } + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + }, + originRoute, + ); return result; }; } @@ -943,6 +983,7 @@ function FolderBrowser(props: { } export function AddProjectNewScreen(props: { readonly environmentId?: string | string[] }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const environmentOptions = useEnvironmentOptions().filter( (option) => option.newProjectsRoot !== null, @@ -1017,6 +1058,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s const submittingRef = useRef(false); const submit = async () => { if (!environment || trimmedName.length === 0 || submittingRef.current) return; + const originRoute = getActiveRoute(navigation); submittingRef.current = true; setError(null); setIsSubmitting(true); @@ -1067,11 +1109,14 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); return; } - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title: trimmedName, - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title: trimmedName, + }, + originRoute, + ); }; return ( @@ -1262,6 +1307,7 @@ export function AddProjectDestinationScreen(props: { readonly repositoryName?: string | string[]; readonly prompt?: string | string[]; }) { + const openNewTaskDraft = useOpenNewTaskDraft(); const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); @@ -1313,6 +1359,7 @@ export function AddProjectDestinationScreen(props: { return; } + const originRoute = getActiveRoute(navigation); setIsSubmitting(true); if (environment.supportsCloneTracking) { const projectId = ProjectId.make(uuidv4()); @@ -1340,18 +1387,21 @@ export function AddProjectDestinationScreen(props: { "The project was created but has not reached this device yet. It will appear in the project list once the connection catches up.", ); } else { - openNewTaskDraft(navigation, { - environmentId: environment.environmentId, - projectId, - title, - cloning: "1", - ...(prompt - ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( - (draftId) => ({ draftId }), - () => ({}), - ) - : {}), - }); + openNewTaskDraft( + { + environmentId: environment.environmentId, + projectId, + title, + cloning: "1", + ...(prompt + ? await draftWithPrompt(environment.environmentId, projectId, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); } } setIsSubmitting(false); @@ -1367,7 +1417,7 @@ export function AddProjectDestinationScreen(props: { if (AsyncResult.isFailure(cloneResult)) { setError(errorMessage(Cause.squash(cloneResult.cause))); } else { - const createResult = await createProject(cloneResult.value.cwd); + const createResult = await createProject(cloneResult.value.cwd, originRoute); if (createResult && AsyncResult.isFailure(createResult)) { setError(errorMessage(Cause.squash(createResult.cause))); } diff --git a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx index 2dc85606be739..d69e9141fdf9f 100644 --- a/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectSourceRoute.tsx @@ -1,5 +1,12 @@ +import type { StaticScreenProps } from "@react-navigation/native"; import { AddProjectSourceScreen } from "./AddProjectScreen"; -export function AddProjectSourceRoute() { - return ; +type AddProjectSourceRouteParams = { + readonly environmentId?: string | string[]; +}; + +export function AddProjectSourceRoute({ + route, +}: StaticScreenProps) { + return ; } diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 3759b6c3ab65d..c341dec4e2b9a 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", diff --git a/docs/operations/mobile-screens-fork.md b/docs/operations/mobile-screens-fork.md index a48c0f924bfc1..b7b7454c94657 100644 --- a/docs/operations/mobile-screens-fork.md +++ b/docs/operations/mobile-screens-fork.md @@ -1,7 +1,7 @@ # Building the mobile Screens dependency The mobile v5 stack uses a committed tarball from the -[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.44). +[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.45). The fork owns the integration changes; the app does not patch this package. Each archive includes compiled JavaScript, declarations, native source and `t3-fork.json` recording its version, source commit and upstream base. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c7ff560c0e53..80fb20106b354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -560,7 +560,7 @@ importers: version: 7.5.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/native-stack': specifier: 7.21.0 - version: 7.21.0(patch_hash=990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42)(90b832078186123c182216792ec43c25) + version: 7.21.0(patch_hash=990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42)(fedae76d040d2e46f454f147f7fa3a09) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -763,8 +763,8 @@ importers: specifier: ~5.10.1 version: 5.10.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-screens: - specifier: file:deps/react-native-screens-5.0.0-t3.44.tgz - version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: file:deps/react-native-screens-5.0.0-t3.45.tgz + version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-svg: specifier: 15.16.0 version: 15.16.0(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -10850,9 +10850,9 @@ packages: react: '*' react-native: '*' - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz: - resolution: {integrity: sha512-hs8Q9FodRg8JWPk7QeohJwaNrndgxHajgXdTfVH1RpwFhmyWSJV0/mMzqYKh3qX/jTD7TBnRWKyKuvMFwHGF6w==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz} - version: 5.0.0-t3.44 + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz: + resolution: {integrity: sha512-FgmMB7T2tb0pEKgdsRs5sRCIRfIOHFqEk3lqaRA53dYb51rxlE9utrAoxbUPHcAROAAP2QA55KUTD9V71SoNqA==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz} + version: 5.0.0-t3.45 peerDependencies: react: '*' react-native: '*' @@ -15832,7 +15832,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) - '@react-navigation/native-stack@7.21.0(patch_hash=990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42)(90b832078186123c182216792ec43c25)': + '@react-navigation/native-stack@7.21.0(patch_hash=990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42)(fedae76d040d2e46f454f147f7fa3a09)': dependencies: '@react-navigation/elements': 2.9.45(d8845bac868f4d8a58c8a8f12ad0d4a5) '@react-navigation/native': 7.5.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -15840,7 +15840,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.10.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) - react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -21414,7 +21414,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.44.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.45.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0)