diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index cc1e8f4e5799..bf457be9bdf7 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -6,12 +6,15 @@ import { buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, + filterGitHubRepositorySuggestions, getAddProjectInitialQuery, getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isCompleteAddProjectRepositoryInput, normalizePastedCloneUrl, + parseGitHubRepositorySuggestionInput, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, @@ -664,50 +667,78 @@ export function AddProjectRepositoryScreen(props: { const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - - const lookupRepository = useCallback(async () => { - if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; - setError(null); - setIsSubmitting(true); - const provider = addProjectRemoteSourceProvider(source); - if (!provider) { - const remoteUrl = normalizePastedCloneUrl(repositoryInput); - navigation.dispatch( - StackActions.push("AddProjectDestination", { + const suggestionInput = + source === "github" ? parseGitHubRepositorySuggestionInput(repositoryInput) : null; + const suggestionOwner = suggestionInput?.owner ?? null; + const repositorySuggestionsQuery = useEnvironmentQuery( + environment && suggestionOwner !== null + ? sourceControlEnvironment.repositories({ environmentId: environment.environmentId, - source, - remoteUrl, - repositoryTitle: remoteUrl, - repositoryName: getCloneDirectoryName(remoteUrl), - }), - ); - setIsSubmitting(false); - return; - } + input: { provider: "github", owner: suggestionOwner.toLowerCase() }, + }) + : null, + ); + const repositorySuggestions = repositorySuggestionsQuery.data?.repositories ?? []; + const areRepositorySuggestionsTruncated = repositorySuggestionsQuery.data?.isTruncated ?? false; - const result = await lookupRepositoryQuery({ - environmentId: environment.environmentId, - input: { - provider, - repository: repositoryInput.trim(), - }, - }); - if (AsyncResult.isFailure(result)) { - setError(errorMessage(Cause.squash(result.cause))); - } else { - const repository = result.value; - navigation.dispatch( - StackActions.push("AddProjectDestination", { - environmentId: environment.environmentId, - source, - remoteUrl: getDefaultCloneUrl(repository), - repositoryTitle: repository.nameWithOwner, - repositoryName: getCloneDirectoryName(repository.nameWithOwner), - }), - ); - } - setIsSubmitting(false); - }, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]); + const matchingRepositorySuggestions = filterGitHubRepositorySuggestions( + repositorySuggestions, + repositoryInput, + ); + + const lookupRepository = useCallback( + async (repositoryOverride?: string) => { + const rawRepository = (repositoryOverride ?? repositoryInput).trim(); + if ( + !environment || + isSubmitting || + !isCompleteAddProjectRepositoryInput(source, rawRepository) + ) { + return; + } + setError(null); + setIsSubmitting(true); + const provider = addProjectRemoteSourceProvider(source); + if (!provider) { + const remoteUrl = normalizePastedCloneUrl(rawRepository); + navigation.dispatch( + StackActions.push("AddProjectDestination", { + environmentId: environment.environmentId, + source, + remoteUrl, + repositoryTitle: remoteUrl, + repositoryName: getCloneDirectoryName(remoteUrl), + }), + ); + setIsSubmitting(false); + return; + } + + const result = await lookupRepositoryQuery({ + environmentId: environment.environmentId, + input: { + provider, + repository: rawRepository, + }, + }); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + } else { + const repository = result.value; + navigation.dispatch( + StackActions.push("AddProjectDestination", { + environmentId: environment.environmentId, + source, + remoteUrl: getDefaultCloneUrl(repository), + repositoryTitle: repository.nameWithOwner, + repositoryName: getCloneDirectoryName(repository.nameWithOwner), + }), + ); + } + setIsSubmitting(false); + }, + [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source], + ); return ( @@ -728,9 +759,29 @@ export function AddProjectRepositoryScreen(props: { returnKeyType="next" onSubmitEditing={() => void lookupRepository()} /> + {matchingRepositorySuggestions.length > 0 ? ( + + {matchingRepositorySuggestions.map((repository, index) => ( + } + onPress={() => void lookupRepository(repository.nameWithOwner)} + /> + ))} + + ) : null} + {areRepositorySuggestionsTruncated ? ( + + Showing the first 100 repositories. Enter the full owner/repository name to look up + another. + + ) : null} void lookupRepository()} loading={isSubmitting} /> diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..b5d6416c2b79 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -91,6 +91,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlListRepositories]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 999a0c202216..d2eb5c28ab2e 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -571,6 +571,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + listRepositories: (input) => + Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected repository list: ${input.owner}`), + }), + ), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index f72259b677eb..f8ee761fd21e 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -304,6 +304,71 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists repositories owned by a GitHub account", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + nameWithOwner: "octocat/hello-world", + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listRepositories({ cwd: "/repo", owner: "octocat" }); + + assert.deepStrictEqual(result, { + repositories: [ + { + nameWithOwner: "octocat/hello-world", + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + }, + ], + isTruncated: false, + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["repo", "list", "octocat", "--limit", "101", "--json", "nameWithOwner,url,sshUrl"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reports when repository suggestions are truncated", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify( + Array.from({ length: 101 }, (_, index) => ({ + nameWithOwner: `octocat/repository-${index}`, + url: `https://github.com/octocat/repository-${index}`, + sshUrl: `git@github.com:octocat/repository-${index}.git`, + })), + ), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listRepositories({ cwd: "/repo", owner: "octocat" }); + + expect(result.repositories).toHaveLength(100); + expect(result.isTruncated).toBe(true); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories and parses clone URLs from create output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 57b9ee6e7a63..c0f1ed7341b8 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -151,6 +151,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError()( + "GitHubRepositoryListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository list JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listRepositories: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, @@ -161,6 +174,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositoryListDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -256,6 +270,17 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly listRepositories: (input: { + readonly cwd: string; + readonly owner: string; + }) => Effect.Effect< + { + readonly repositories: ReadonlyArray; + readonly isTruncated: boolean; + }, + GitHubCliError + >; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -287,9 +312,14 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); +const RawGitHubRepositoryListSchema = Schema.Array(RawGitHubRepositoryCloneUrlsSchema); const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const decodeRawGitHubRepositoryList = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositoryListSchema), +); +const REPOSITORY_SUGGESTION_LIMIT = 100; function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, @@ -443,6 +473,39 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + listRepositories: (input) => + execute({ + cwd: input.cwd, + args: [ + "repo", + "list", + input.owner, + "--limit", + String(REPOSITORY_SUGGESTION_LIMIT + 1), + "--json", + "nameWithOwner,url,sshUrl", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeRawGitHubRepositoryList(raw).pipe( + Effect.mapError( + (cause) => + new GitHubRepositoryListDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((repositories) => ({ + repositories: repositories + .slice(0, REPOSITORY_SUGGESTION_LIMIT) + .map(normalizeRepositoryCloneUrls), + isTruncated: repositories.length > REPOSITORY_SUGGESTION_LIMIT, + })), + ), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index a025ce5ec800..a1f53f39c887 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -400,3 +400,49 @@ it("reports an update hint instead of unauthenticated when gh predates --json", /2\.81\.0/, ); }); + +it.effect("lists repositories through the GitHub adapter and preserves truncation", () => + Effect.gen(function* () { + const input = { cwd: "/repo", owner: "octocat" }; + const result = { + repositories: [ + { + nameWithOwner: "octocat/repo", + url: "https://github.com/octocat/repo", + sshUrl: "git@github.com:octocat/repo.git", + }, + ], + isTruncated: true, + }; + const provider = yield* makeProvider({ + listRepositories: (received) => { + assert.deepStrictEqual(received, input); + return Effect.succeed(result); + }, + }); + assert.ok(provider.listRepositories); + assert.deepStrictEqual(yield* provider.listRepositories(input), result); + }), +); + +it.effect("maps repository listing failures to provider errors", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("authentication failed"), + }); + const provider = yield* makeProvider({ listRepositories: () => Effect.fail(cause) }); + assert.ok(provider.listRepositories); + const error = yield* provider + .listRepositories({ cwd: "/repo", owner: "octocat" }) + .pipe(Effect.flip); + assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.operation, "listRepositories"); + assert.strictEqual(error.repository, "octocat"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.command, "gh"); + assert.strictEqual(error.detail, cause.detail); + assert.strictEqual(error.cause, cause); + }), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 74f08a9a9127..73ddcb146bf3 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -272,6 +272,21 @@ export const make = Effect.gen(function* () { }), ), ), + listRepositories: (input) => + github.listRepositories(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listRepositories", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.owner), + detail: error.detail, + cause: error, + }), + ), + ), createRepository: (input) => github.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..0a587147aeba 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -111,6 +111,16 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly listRepositories?: (input: { + readonly cwd: string; + readonly owner: string; + }) => Effect.Effect< + { + readonly repositories: ReadonlyArray; + readonly isTruncated: boolean; + }, + SourceControlProviderError + >; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9fe089a4184c..8d4364221e73 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -180,6 +180,7 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + ...(provider.listRepositories ? { listRepositories: provider.listRepositories } : {}), createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..9868272ae430 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -118,6 +118,32 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("lists repositories through providers that support owner discovery", () => { + const calls: Array<{ cwd: string; owner: string }> = []; + const provider = makeProvider({ + listRepositories: (input) => + Effect.sync(() => { + calls.push(input); + return { repositories: [CLONE_URLS], isTruncated: true }; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.listRepositories({ + provider: "github", + owner: " octocat ", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, { + repositories: [{ provider: "github", ...CLONE_URLS }], + isTruncated: true, + }); + assert.deepStrictEqual(calls, [{ cwd: "/workspace", owner: "octocat" }]); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("preserves provider failures without deriving the repository message from them", () => { const providerCause = new SourceControlProviderError({ provider: "github", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b38fe3d5c302..293e6d2849e9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -15,6 +15,8 @@ import { type SourceControlPublishRepositoryResult, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, + type SourceControlRepositoryListInput, + type SourceControlRepositoryListResult, type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; @@ -30,6 +32,9 @@ export class SourceControlRepositoryService extends Context.Service< readonly lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Effect.Effect; + readonly listRepositories: ( + input: SourceControlRepositoryListInput, + ) => Effect.Effect; readonly cloneRepository: ( input: SourceControlCloneRepositoryInput, ) => Effect.Effect; @@ -116,6 +121,33 @@ export const make = Effect.gen(function* () { return toRepositoryInfo(providerKind, urls); }); + const listRepositories = Effect.fn("SourceControlRepositoryService.listRepositories")(function* ( + input: SourceControlRepositoryListInput, + ) { + const providerKind = yield* ensureConcreteProvider({ + operation: "listRepositories", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + if (!provider.listRepositories) { + return yield* new SourceControlRepositoryError({ + operation: "listRepositories", + provider: providerKind, + detail: "This source control provider cannot list repositories.", + }); + } + const result = yield* provider.listRepositories({ + cwd: input.cwd ?? config.cwd, + owner: input.owner.trim(), + }); + return { + repositories: result.repositories.map((repository) => + toRepositoryInfo(providerKind, repository), + ), + isTruncated: result.isTruncated, + }; + }); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -266,6 +298,8 @@ export const make = Effect.gen(function* () { ); return SourceControlRepositoryService.of({ + listRepositories: (input) => + listRepositories(input).pipe(mapRepositoryError("listRepositories", input.provider)), lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), cloneRepository: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..94b4b0e5622e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2235,6 +2235,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.sourceControlListRepositories]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlListRepositories, + sourceControlRepositories.listRepositories(input), + { + "rpc.aggregate": "source-control", + }, + ), [WS_METHODS.sourceControlCloneRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlCloneRepository, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fcf86362707e..cb1694f4e924 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,7 +7,10 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + filterGitHubRepositorySuggestions, + isCompleteAddProjectRepositoryInput, normalizePastedCloneUrl, + parseGitHubRepositorySuggestionInput, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -46,6 +49,7 @@ import { LinkIcon, MessageSquareIcon, PaletteIcon, + RefreshCwIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -699,6 +703,24 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); + const githubSuggestionInput = + addProjectCloneFlow?.step === "repository" && addProjectCloneFlow.source === "github" + ? parseGitHubRepositorySuggestionInput(deferredQuery) + : null; + const githubSuggestionOwner = githubSuggestionInput?.owner ?? null; + const githubSuggestionEnvironmentId = + addProjectCloneFlow?.step === "repository" ? addProjectCloneFlow.environmentId : null; + const repositorySuggestionsQuery = useEnvironmentQuery( + githubSuggestionOwner !== null && githubSuggestionEnvironmentId !== null + ? sourceControlEnvironment.repositories({ + environmentId: githubSuggestionEnvironmentId, + input: { provider: "github", owner: githubSuggestionOwner.toLowerCase() }, + }) + : null, + ); + const repositorySuggestions = repositorySuggestionsQuery.data?.repositories ?? []; + const areRepositorySuggestionsTruncated = repositorySuggestionsQuery.data?.isTruncated ?? false; + const isRepositorySuggestionsLoading = repositorySuggestionsQuery.isPending; const projectGroupingSettings = useMemo( () => selectProjectGroupingSettings(clientSettings), [clientSettings], @@ -1958,7 +1980,10 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } - async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { + async function submitAddProjectCloneFlow( + destinationPathInput?: string, + repositoryInput?: string, + ): Promise { if (!addProjectCloneFlow) { return; } @@ -1974,8 +1999,12 @@ function OpenCommandPaletteDialog(props: { } if (addProjectCloneFlow.step === "repository") { - const rawRepository = query.trim(); - if (rawRepository.length === 0 || isRemoteProjectLookingUp) { + const rawRepository = (repositoryInput ?? query).trim(); + if ( + rawRepository.length === 0 || + isRemoteProjectLookingUp || + !isCompleteAddProjectRepositoryInput(addProjectCloneFlow.source, rawRepository) + ) { return; } @@ -2183,9 +2212,58 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const matchingRepositorySuggestions = filterGitHubRepositorySuggestions( + repositorySuggestions, + query, + ); + const repositorySuggestionGroups: CommandPaletteView["groups"] = [ + ...(repositorySuggestionsQuery.error === null + ? [] + : [ + { + value: "github-repository-suggestions-error", + label: "GitHub repositories", + items: [ + { + kind: "action" as const, + value: "retry-github-repository-suggestions", + searchTerms: [], + title: "Retry loading repositories", + description: repositorySuggestionsQuery.error, + icon: , + keepOpen: true, + run: async () => { + repositorySuggestionsQuery.refresh(); + }, + }, + ], + }, + ]), + ...(matchingRepositorySuggestions.length === 0 + ? [] + : [ + { + value: "github-repository-suggestions", + label: `Repositories owned by ${githubSuggestionOwner}${areRepositorySuggestionsTruncated ? " (first 100)" : ""}`, + items: matchingRepositorySuggestions.map((repository) => ({ + kind: "action" as const, + value: `repository-suggestion:${repository.nameWithOwner}`, + searchTerms: [repository.nameWithOwner], + title: repository.nameWithOwner.split("/").at(-1) ?? repository.nameWithOwner, + description: repository.nameWithOwner, + icon: , + keepOpen: true, + run: async () => { + await submitAddProjectCloneFlow(undefined, repository.nameWithOwner); + }, + })), + }, + ]), + ]; + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = repositorySuggestionGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2226,7 +2304,7 @@ function OpenCommandPaletteDialog(props: { const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; const canSubmitRemoteProjectFlow = addProjectCloneFlow?.step === "repository" && - query.trim().length > 0 && + isCompleteAddProjectRepositoryInput(addProjectCloneFlow.source, query) && canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && !isRemoteProjectPending; const fileManagerName = getLocalFileManagerName(navigator.platform); @@ -2297,6 +2375,13 @@ function OpenCommandPaletteDialog(props: { if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); + const highlightedRepository = displayedGroups + .flatMap((group) => group.items) + .find((item) => item.value === highlightedItemValue); + if (highlightedRepository) { + executeItem(highlightedRepository); + return; + } void submitAddProjectCloneFlow(); return; } @@ -2627,7 +2712,15 @@ function OpenCommandPaletteDialog(props: { emptyStateMessage: addProjectCloneFlow.source === "url" ? "Enter a Git clone URL and press Enter to continue." - : "Enter a repository path and press Enter to look it up.", + : isRepositorySuggestionsLoading + ? "Loading repositories…" + : githubSuggestionInput && repositorySuggestions.length > 0 + ? areRepositorySuggestionsTruncated + ? "No match in the first 100 repositories. Enter the full owner/repository name and press Enter." + : "No matching repositories." + : githubSuggestionInput + ? "No repositories found for this owner." + : "Enter a repository path and press Enter to look it up.", } : addProjectCloneFlow?.step === "confirm" ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index cbbd121959d5..8054cfe27d67 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -12,18 +12,110 @@ import { buildProjectCreateCommand, canCreateProjectInEnvironment, findExistingAddProject, + filterGitHubRepositorySuggestions, getAddProjectInitialQuery, getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isCompleteAddProjectRepositoryInput, normalizePastedCloneUrl, + parseGitHubRepositorySuggestionInput, resolveAddProjectPath, sortAddProjectProviderSources, } from "./projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { + it("recognizes GitHub owner inputs and filters repository suggestions", () => { + expect(parseGitHubRepositorySuggestionInput("naveed949/")).toEqual({ + owner: "naveed949", + repositoryQuery: "", + }); + expect(parseGitHubRepositorySuggestionInput("naveed949/t3")).toEqual({ + owner: "naveed949", + repositoryQuery: "t3", + }); + expect(parseGitHubRepositorySuggestionInput("naveed949")).toBeNull(); + + const repositories = [ + { + provider: "github" as const, + nameWithOwner: "naveed949/zebra", + url: "https://github.com/naveed949/zebra", + sshUrl: "git@github.com:naveed949/zebra.git", + }, + { + provider: "github" as const, + nameWithOwner: "naveed949/t3code", + url: "https://github.com/naveed949/t3code", + sshUrl: "git@github.com:naveed949/t3code.git", + }, + ]; + expect(filterGitHubRepositorySuggestions(repositories, "naveed949/")).toEqual([ + repositories[1], + repositories[0], + ]); + expect(filterGitHubRepositorySuggestions(repositories, "naveed949/t3")).toEqual([ + repositories[1], + ]); + expect(isCompleteAddProjectRepositoryInput("github", "naveed949/")).toBe(false); + expect(isCompleteAddProjectRepositoryInput("github", "naveed949")).toBe(false); + expect(isCompleteAddProjectRepositoryInput("github", "naveed949/t3code")).toBe(true); + expect( + isCompleteAddProjectRepositoryInput("github", "https://github.com/naveed949/t3code"), + ).toBe(true); + }); + + it("keeps complete GitHub clone inputs while blocking incomplete shorthand", () => { + for (const input of [ + "https://github.com/owner/repo/", + "https://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + "ssh://git@github.com/owner/repo.git", + "ssh://git@github.com:22/owner/repo.git", + "owner/repo", + ]) { + expect(isCompleteAddProjectRepositoryInput("github", input)).toBe(true); + } + for (const input of [ + "", + "owner", + "owner/", + "owner//", + "http://github.com/owner/repo", + "https://gitlab.com/owner/repo", + "https://github.com/owner/repo/tree/main", + "git@gitlab.com:owner/repo.git", + "ssh://git@gitlab.com/owner/repo.git", + "ftp://github.com/owner/repo", + ]) { + expect(isCompleteAddProjectRepositoryInput("github", input)).toBe(false); + } + }); + + it("filters out suggestions from a previous owner without mutating cached results", () => { + const repositories = Object.freeze([ + { + provider: "github" as const, + nameWithOwner: "first/z", + url: "https://github.com/first/z", + sshUrl: "git@github.com:first/z.git", + }, + { + provider: "github" as const, + nameWithOwner: "first/a", + url: "https://github.com/first/a", + sshUrl: "git@github.com:first/a.git", + }, + ]); + expect( + filterGitHubRepositorySuggestions(repositories, "first/").map((repo) => repo.nameWithOwner), + ).toEqual(["first/a", "first/z"]); + expect(filterGitHubRepositorySuggestions(repositories, "second/")).toEqual([]); + expect(repositories[0]?.nameWithOwner).toBe("first/z"); + }); + it("only allows project creation in connected environments", () => { expect(canCreateProjectInEnvironment("connected")).toBe(true); expect(canCreateProjectInEnvironment("available")).toBe(false); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 3f5fc3667f31..707400f75ab9 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -106,8 +106,63 @@ export function addProjectRemoteSourceProvider( return source === "url" ? null : source; } +export interface GitHubRepositorySuggestionInput { + readonly owner: string; + readonly repositoryQuery: string; +} + +/** Returns the owner and optional repository prefix once a GitHub owner slash is present. */ +export function parseGitHubRepositorySuggestionInput( + input: string, +): GitHubRepositorySuggestionInput | null { + const match = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([^/]*)$/u.exec(input.trim()); + if (!match?.[1]) return null; + return { owner: match[1], repositoryQuery: match[2] ?? "" }; +} + +export function filterGitHubRepositorySuggestions( + repositories: ReadonlyArray, + input: string, +): ReadonlyArray { + const parsed = parseGitHubRepositorySuggestionInput(input); + if (!parsed) return []; + const owner = parsed.owner.toLowerCase(); + const query = parsed.repositoryQuery.toLowerCase(); + return repositories + .filter((repository) => { + const [repositoryOwner, repositoryName] = repository.nameWithOwner.split("/"); + return ( + repository.provider === "github" && + repositoryOwner?.toLowerCase() === owner && + (repositoryName?.toLowerCase().includes(query) ?? false) + ); + }) + .sort((left, right) => left.nameWithOwner.localeCompare(right.nameWithOwner)); +} + +export function isCompleteAddProjectRepositoryInput( + source: AddProjectRemoteSource, + input: string, +): boolean { + const trimmed = input.trim(); + if (trimmed.length === 0) return false; + if (source !== "github") return true; + return ( + GITHUB_REPOSITORY_SHORTHAND.test(trimmed) || + GITHUB_REPOSITORY_HTTPS_URL.test(trimmed) || + GITHUB_REPOSITORY_SCP_URL.test(trimmed) || + GITHUB_REPOSITORY_SSH_URL.test(trimmed) + ); +} + const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +const GITHUB_REPOSITORY_HTTPS_URL = + /^https:\/\/github\.com\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?\/?$/i; +const GITHUB_REPOSITORY_SCP_URL = + /^git@github\.com:[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/i; +const GITHUB_REPOSITORY_SSH_URL = + /^ssh:\/\/git@github\.com(?::\d+)?\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?\/?$/i; /** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */ export function normalizePastedCloneUrl(input: string): string { diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index d04f313d26e5..ad2acb1a3194 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -110,11 +110,12 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" const family = createEnvironmentQueryAtomFamily(runtime, { label: "test.environment-query", staleTimeMs: 60_000, - execute: () => execute, + execute: (_input: unknown) => execute, }); return { atom: family({ environmentId: QUERY_ENVIRONMENT.environmentId, input: undefined }), + family, supervisorSession, supervisorState, }; @@ -274,6 +275,50 @@ describe("environmentRpcKey", () => { }); describe("environment query lifecycle", () => { + it.effect("isolates late repository results when owners or environments change", () => + Effect.scoped( + Effect.gen(function* () { + const pending = yield* Deferred.make(); + const firstStarted = yield* Deferred.make(); + let calls = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + calls += 1; + return calls === 1 + ? Deferred.succeed(firstStarted, undefined).pipe( + Effect.andThen(Deferred.await(pending)), + ) + : Effect.succeed(`result-${calls}`); + }), + ); + const first = harness.family({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { owner: "first" }, + }); + const secondOwner = harness.family({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: { owner: "second" }, + }); + const secondEnvironment = harness.family({ + environmentId: EnvironmentId.make("other-environment"), + input: { owner: "second" }, + }); + const registry = yield* mountEnvironmentQuery(first); + yield* Deferred.await(firstStarted); + const ownerResult = yield* AtomRegistry.getResult(registry, secondOwner); + const environmentResult = yield* AtomRegistry.getResult(registry, secondEnvironment); + expect(ownerResult).toBe("result-2"); + expect(environmentResult).toBe("result-3"); + yield* Deferred.succeed(pending, "late-first-result"); + expect(yield* AtomRegistry.getResult(registry, first)).toBe("late-first-result"); + expect(Option.getOrNull(AsyncResult.value(registry.get(secondOwner)))).toBe(ownerResult); + expect(Option.getOrNull(AsyncResult.value(registry.get(secondEnvironment)))).toBe( + environmentResult, + ); + }), + ), + ); + it.effect( "retries an interrupted query without exposing a failure during session replacement", () => diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..73528df94dad 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -24,6 +24,10 @@ export function createSourceControlEnvironmentAtoms( label: "environment-data:source-control:repository", tag: WS_METHODS.sourceControlLookupRepository, }), + repositories: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repositories", + tag: WS_METHODS.sourceControlListRepositories, + }), cloneRepository: createEnvironmentRpcCommand(runtime, { label: "environment-data:source-control:clone-repository", tag: WS_METHODS.sourceControlCloneRepository, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index cd906aecdfce..1c892b697d7d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -107,6 +107,8 @@ import type { SourceControlPublishRepositoryResult, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositoryListInput, + SourceControlRepositoryListResult, } from "./sourceControl.ts"; import type { DesktopAppActivationRequest, @@ -1510,6 +1512,9 @@ export interface EnvironmentApi { createUrl: (input: AssetCreateUrlInput) => Promise; }; sourceControl: { + listRepositories: ( + input: SourceControlRepositoryListInput, + ) => Promise; lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..55deb90e0532 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -231,6 +231,8 @@ import { SourceControlPublishRepositoryResult, SourceControlRepositoryError, SourceControlRepositoryInfo, + SourceControlRepositoryListInput, + SourceControlRepositoryListResult, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -365,6 +367,7 @@ export const WS_METHODS = { // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlListRepositories: "sourceControl.listRepositories", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -769,6 +772,12 @@ const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLook error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); +const WsSourceControlListRepositoriesRpc = Rpc.make(WS_METHODS.sourceControlListRepositories, { + payload: SourceControlRepositoryListInput, + success: SourceControlRepositoryListResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); + const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, @@ -1240,6 +1249,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsLabelCandidatesRpc, WsPullRequestsSetLabelsRpc, WsSourceControlLookupRepositoryRpc, + WsSourceControlListRepositoriesRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index cc98fbc1bbcc..dfc6cc2ead14 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -68,6 +68,19 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +export const SourceControlRepositoryListInput = Schema.Struct({ + provider: SourceControlProviderKind, + owner: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositoryListInput = typeof SourceControlRepositoryListInput.Type; + +export const SourceControlRepositoryListResult = Schema.Struct({ + repositories: Schema.Array(SourceControlRepositoryInfo), + isTruncated: Schema.Boolean, +}); +export type SourceControlRepositoryListResult = typeof SourceControlRepositoryListResult.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString),