Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 103 additions & 10 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getCloneDestinationPath,
getCloneDirectoryName,
getDefaultCloneUrl,
isGitHubRepositoryShorthand,
normalizePastedCloneUrl,
resolveAddProjectPath,
sortAddProjectProviderSources,
Expand All @@ -31,7 +32,12 @@ import {
inferProjectTitleFromPath,
isWindowsPlatform,
} from "@t3tools/client-runtime/state/projects";
import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
import {
CommandId,
type EnvironmentId,
ProjectId,
type SourceControlRepositoryInfo,
} from "@t3tools/contracts";
import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
Expand All @@ -55,6 +61,7 @@ import { useThemeColor } from "../../lib/useThemeColor";
import { uuidv4 } from "../../lib/uuid";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import { useDebouncedValue } from "../../state/queries";
import {
useRemoteConnectionStatus,
useRemoteEnvironmentRuntime,
Expand All @@ -72,6 +79,8 @@ interface EnvironmentOption {
readonly connectionErrorTraceId: string | null;
}

const REPOSITORY_SEARCH_DEBOUNCE_MS = 100;

const environmentOptionOrder = Order.mapInput(
Order.Struct({
label: Order.String,
Expand Down Expand Up @@ -652,11 +661,55 @@ export function AddProjectRepositoryScreen(props: {
reportFailure: false,
});
const navigation = useNavigation();
const iconColor = useThemeColor("--color-icon");
const environment = useEnvironmentFromParam(props.environmentId);
const source = sourceFromParam(props.source);
const [repositoryInput, setRepositoryInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedRepositoryInput = repositoryInput.trim();
const debouncedRepositoryInput = useDebouncedValue(
normalizedRepositoryInput,
REPOSITORY_SEARCH_DEBOUNCE_MS,
);
const githubRepositorySearchEnvironmentId =
source === "github" ? (environment?.environmentId ?? null) : null;
const settledRepositoryInput =
normalizedRepositoryInput.length > 0 && normalizedRepositoryInput === debouncedRepositoryInput
? debouncedRepositoryInput
: null;
const repositorySearch = useEnvironmentQuery(
githubRepositorySearchEnvironmentId !== null && settledRepositoryInput !== null
? sourceControlEnvironment.repositorySearch({
environmentId: githubRepositorySearchEnvironmentId,
input: {
provider: "github",
query: settledRepositoryInput,
},
})
: null,
);
const repositories = repositorySearch.data;
const isRepositorySearchPending =
normalizedRepositoryInput.length > 0 &&
(normalizedRepositoryInput !== debouncedRepositoryInput || repositorySearch.isPending);
const visibleError = error ?? repositorySearch.error;

const selectRepository = useCallback(
(repository: SourceControlRepositoryInfo) => {
if (!environment) return;
navigation.dispatch(
StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl: getDefaultCloneUrl(repository),
repositoryTitle: repository.nameWithOwner,
repositoryName: getCloneDirectoryName(repository.nameWithOwner),
}),
);
},
[environment, navigation, source],
);

const lookupRepository = useCallback(async () => {
if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return;
Expand All @@ -678,6 +731,11 @@ export function AddProjectRepositoryScreen(props: {
return;
}

if (provider === "github" && !isGitHubRepositoryShorthand(repositoryInput)) {
setIsSubmitting(false);
return;
}

const result = await lookupRepositoryQuery({
environmentId: environment.environmentId,
input: {
Expand All @@ -704,29 +762,64 @@ export function AddProjectRepositoryScreen(props: {

return (
<AddProjectShell>
{error ? <ErrorBanner message={error} /> : null}
{visibleError ? <ErrorBanner message={visibleError} /> : null}
{environment ? (
<>
<TextInput
className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-base leading-snug"
value={repositoryInput}
onChangeText={setRepositoryInput}
onChangeText={(value) => {
if (value.trim() !== normalizedRepositoryInput) {
setError(null);
}
setRepositoryInput(value);
}}
autoCapitalize="none"
autoCorrect={false}
placeholder={
source === "url"
? "https://github.com/org/repo.git"
: addProjectRemoteSourcePathHint(source)
}
returnKeyType="next"
returnKeyType={source === "github" ? "done" : "next"}
onSubmitEditing={() => void lookupRepository()}
/>
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
onPress={() => void lookupRepository()}
loading={isSubmitting}
/>
{source === "github" ? null : (
<PrimaryActionButton
label={source === "url" ? "Continue" : "Lookup repository"}
disabled={isSubmitting || repositoryInput.trim().length === 0}
onPress={() => void lookupRepository()}
loading={isSubmitting}
/>
)}
Comment thread
cursor[bot] marked this conversation as resolved.
{source === "github" && isRepositorySearchPending ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub lookup shows no loading state

Medium Severity

The GitHub spinner now follows isRepositorySearchPending instead of isSubmitting. Shorthand Enter lookup still sets isSubmitting, but that flag no longer drives any GitHub UI, so a lookup after search settles has no progress indicator for the whole request.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 91ad373. Configure here.

<View className="items-center py-3">
<ActivityIndicator />
</View>
) : null}
{repositories ? (
<>
<SectionTitle>Repositories</SectionTitle>
<ListSection>
{repositories.length === 0 ? (
<View className="items-center px-4 py-5">
<Text className="text-sm text-foreground-muted">No repositories found.</Text>
</View>
) : (
repositories.map((repository, index) => (
<ListRow
key={repository.nameWithOwner}
title={repository.nameWithOwner}
subtitle={repository.url}
icon={<SourceControlIcon kind="github" size={18} color={String(iconColor)} />}
isFirst={index === 0}
onPress={() => selectRepository(repository)}
/>
Comment on lines +810 to +817

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High projects/AddProjectScreen.tsx:810

Repository rows remain tappable while lookupRepository is resolving, so pressing Enter and then tapping a result dispatches two StackActions.push("AddProjectDestination") actions and leaves two clone flows on the navigation stack. Disable the result rows while isSubmitting is true.

Suggested change
<ListRow
key={repository.nameWithOwner}
title={repository.nameWithOwner}
subtitle={repository.url}
icon={<SourceControlIcon kind="github" size={18} color={String(iconColor)} />}
isFirst={index === 0}
onPress={() => selectRepository(repository)}
/>
<ListRow
key={repository.nameWithOwner}
title={repository.nameWithOwner}
subtitle={repository.url}
icon={<SourceControlIcon kind="github" size={18} color={String(iconColor)} />}
isFirst={index === 0}
disabled={isSubmitting}
onPress={() => selectRepository(repository)}
/>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/projects/AddProjectScreen.tsx around lines 810-817:

Repository rows remain tappable while `lookupRepository` is resolving, so pressing Enter and then tapping a result dispatches two `StackActions.push("AddProjectDestination")` actions and leaves two clone flows on the navigation stack. Disable the result rows while `isSubmitting` is true.

))
)}
</ListSection>
</>
) : null}
</>
) : (
<EmptyEnvironmentState />
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlSearchRepositories]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
cwd: input.cwd,
args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
}).pipe(Effect.map((result) => JSON.parse(result.stdout))),
searchRepositories: () => Effect.succeed([]),
createRepository: (input) =>
Effect.fail(
new GitHubCli.GitHubCliCommandError({
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
stderrTruncated: false,
});

const repositorySearchResult = (nameWithOwner: string) => ({
nameWithOwner,
url: `https://github.com/${nameWithOwner}`,
sshUrl: `git@github.com:${nameWithOwner}.git`,
});

const repositorySearchOutput = (input: {
readonly owner: ReadonlyArray<string>;
readonly global?: ReadonlyArray<string>;
}): VcsProcess.VcsProcessOutput =>
processOutput(
JSON.stringify({
data: {
owner: { nodes: input.owner.map(repositorySearchResult) },
global: { nodes: (input.global ?? []).map(repositorySearchResult) },
},
}),
);

const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();

const layer = GitHubCli.layer.pipe(
Expand Down Expand Up @@ -292,6 +311,105 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("searches repositories with the authenticated owner's matches first", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
repositorySearchOutput({
owner: ["current-user/skills"],
global: ["mattpocock/skills", "current-user/skills", "someone-else/skills"],
}),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.searchRepositories({
cwd: "/repo",
query: "skills",
});

assert.deepStrictEqual(
result.map((repository) => repository.nameWithOwner),
["current-user/skills", "mattpocock/skills", "someone-else/skills"],
);
assert.equal(result[0]?.sshUrl, "git@github.com:current-user/skills.git");
expect(mockRun).toHaveBeenCalledTimes(1);
expect(mockRun).toHaveBeenCalledWith({
operation: "GitHubCli.execute",
command: "gh",
args: expect.arrayContaining([
"api",
"graphql",
"ownerQuery=skills in:name user:@me fork:true",
"globalQuery=skills in:name fork:false",
]),
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("returns an exact owner and repository path before other owner matches", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
repositorySearchOutput({
owner: ["octocat/codething-tools", "octocat/codething-mvp"],
}),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.searchRepositories({
cwd: "/repo",
query: " octocat/codething-mvp ",
});

assert.deepStrictEqual(result, [
{
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
},
{
nameWithOwner: "octocat/codething-tools",
url: "https://github.com/octocat/codething-tools",
sshUrl: "git@github.com:octocat/codething-tools.git",
},
]);
expect(mockRun).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(layer)),
);

it.effect("searches within an owner for a partial repository path", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(repositorySearchOutput({ owner: ["octocat/codething-mvp"] })),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.searchRepositories({ cwd: "/repo", query: "octocat/code" });

assert.deepStrictEqual(
result.map((repository) => repository.nameWithOwner),
["octocat/codething-mvp"],
);
expect(mockRun).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(layer)),
);

it.effect("returns no repository search results when there are no matches", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(Effect.succeed(repositorySearchOutput({ owner: [] })));

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.searchRepositories({ cwd: "/repo", query: "does-not-exist" });

assert.deepStrictEqual(result, []);
expect(mockRun).toHaveBeenCalledTimes(1);
}).pipe(Effect.provide(layer)),
);

it.effect("creates repositories and parses clone URLs from create output", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
Loading
Loading