From a913099f3ac536b883c6f818a38074bfb8279a1c Mon Sep 17 00:00:00 2001 From: Marko Mitranic Date: Wed, 23 Sep 2026 07:20:39 +0000 Subject: [PATCH 1/3] feat(source-control): search GitHub repositories when cloning The GitHub clone step only accepted an exact owner/repo. Typing part of a name now searches GitHub: the signed-in account's matches first, then all of GitHub, and picking a result continues into the existing clone flow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/projects/AddProjectScreen.tsx | 139 ++++++++----- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/git/GitManager.test.ts | 1 + .../src/sourceControl/GitHubCli.test.ts | 26 +++ apps/server/src/sourceControl/GitHubCli.ts | 86 +++++++++ .../GitHubSourceControlProvider.ts | 14 ++ .../sourceControl/SourceControlProvider.ts | 9 + .../SourceControlProviderRegistry.ts | 1 + .../SourceControlRepositoryService.ts | 28 +++ apps/server/src/ws.ts | 8 + apps/web/src/components/CommandPalette.tsx | 182 +++++++++++------- docs/user/source-control.md | 8 +- .../client-runtime/src/state/sourceControl.ts | 4 + packages/contracts/src/rpc.ts | 10 + packages/contracts/src/sourceControl.ts | 12 ++ 15 files changed, 414 insertions(+), 115 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 84a2938785de..99dce3ab58ba 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -55,6 +55,7 @@ import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; +import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -720,50 +721,65 @@ 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 githubSearchQuery = useDebouncedValue( + source === "github" ? repositoryInput.trim() : "", + 500, + ); + const githubSearch = useEnvironmentQuery( + environment && githubSearchQuery.length >= 3 + ? sourceControlEnvironment.searchRepositories({ environmentId: environment.environmentId, - source, - remoteUrl, - repositoryTitle: remoteUrl, - repositoryName: getCloneDirectoryName(remoteUrl), - }), - ); - setIsSubmitting(false); - return; - } + input: { provider: "github", query: githubSearchQuery }, + }) + : null, + ); - 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 lookupRepository = useCallback( + async (repositoryName: string) => { + if (!environment || repositoryName.length === 0 || isSubmitting) return; + setError(null); + setIsSubmitting(true); + const provider = addProjectRemoteSourceProvider(source); + if (!provider) { + const remoteUrl = normalizePastedCloneUrl(repositoryName); + 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: repositoryName, + }, + }); + 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, navigation, source], + ); return ( @@ -779,17 +795,50 @@ export function AddProjectRepositoryScreen(props: { placeholder={ source === "url" ? "https://github.com/org/repo.git" - : addProjectRemoteSourcePathHint(source) + : source === "github" + ? "Search or enter owner/repo" + : addProjectRemoteSourcePathHint(source) } returnKeyType="next" - onSubmitEditing={() => void lookupRepository()} + onSubmitEditing={() => void lookupRepository(repositoryInput.trim())} /> void lookupRepository()} + onPress={() => void lookupRepository(repositoryInput.trim())} loading={isSubmitting} /> + {source === "github" && githubSearchQuery.length >= 3 ? ( + <> + Repositories + {githubSearch.error ? : null} + + {githubSearch.isPending && githubSearch.data === null ? ( + + + + ) : null} + {(githubSearch.data ?? []).map((repository, index) => ( + + } + isFirst={index === 0} + right={null} + disabled={isSubmitting} + onPress={() => void lookupRepository(repository.nameWithOwner)} + /> + ))} + + + ) : null} ) : ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..0d46780a791c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -100,6 +100,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlSearchRepositories]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6a1a8c1510fa..c030e3b20b5d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -577,6 +577,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({ diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..72930fd398e6 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -516,6 +516,32 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists the account's own repository matches before global matches", () => + Effect.gen(function* () { + const repository = (fullName: string) => ({ fullName, description: null }); + mockRun.mockImplementation((input) => + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify( + input.args[2] === "sleepy user:@me" + ? [repository("octocat/sleepytime")] + : [repository("sleepy-project/sleepy"), repository("octocat/sleepytime")], + ), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.searchRepositories({ cwd: "/repo", query: "sleepy" }); + + assert.deepStrictEqual(result, [ + { nameWithOwner: "octocat/sleepytime", description: null }, + { nameWithOwner: "sleepy-project/sleepy", description: null }, + ]); + }).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 c525740efeae..62f951cfdbec 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -201,6 +201,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError()( + "GitHubRepositorySearchDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid repository search JSON."; + } + + override get message(): string { + return `GitHub CLI failed in searchRepositories: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, @@ -211,6 +224,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -278,6 +292,11 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export interface GitHubRepositorySearchResult { + readonly nameWithOwner: string; + readonly description: string | null; +} + export class GitHubCli extends Context.Service< GitHubCli, { @@ -311,6 +330,12 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + /** Searches the signed-in account's repositories first, then all of GitHub. */ + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -357,6 +382,33 @@ function normalizeRepositoryCloneUrls( }; } +const RawGitHubRepositorySearchSchema = Schema.Array( + Schema.Struct({ + fullName: TrimmedNonEmptyString, + description: Schema.NullishOr(Schema.String), + }), +); +const decodeRawGitHubRepositorySearch = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubRepositorySearchSchema), +); + +/** + * Merges search result lists in order, keeping the first occurrence of each repository. + * + * @example mergeRepositorySearchResults([[a], [a, b]]); // [a, b] + */ +function mergeRepositorySearchResults( + lists: ReadonlyArray>, +): ReadonlyArray { + const seen = new Set(); + return lists.flat().filter((repository) => { + const key = repository.nameWithOwner.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + /** * `gh repo create` prints the canonical URL of the new repository on stdout * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a @@ -612,6 +664,40 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories: (input) => { + const search = (query: string, limit: number) => + execute({ + cwd: input.cwd, + args: [ + "search", + "repos", + query, + "--limit", + String(limit), + "--json", + "fullName,description", + ], + }).pipe( + Effect.flatMap((result) => + decodeRawGitHubRepositorySearch(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }), + ), + ), + ), + Effect.map((repositories) => + repositories.map((repository) => ({ + nameWithOwner: repository.fullName, + description: repository.description ?? null, + })), + ), + ); + + return Effect.all([search(`${input.query} user:@me`, 5), search(input.query, 20)], { + concurrency: "unbounded", + }).pipe(Effect.map(mergeRepositorySearchResults)); + }, createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 372d2a032d79..cde0b54f19ac 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -339,6 +339,20 @@ export const make = Effect.gen(function* () { }), ), ), + searchRepositories: (input) => + github.searchRepositories(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "searchRepositories", + command: error.command, + cwd: input.cwd, + 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 ec61691fdd42..a8baa80f21bf 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -7,6 +7,7 @@ import type { SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, + SourceControlRepositorySearchResult, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -126,6 +127,14 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + /** Optional capability for finding repositories to clone by name. */ + readonly searchRepositories?: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect< + ReadonlyArray, + 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 d8893b29e089..c7fc88e0924a 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -183,6 +183,7 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + ...(provider.searchRepositories ? { searchRepositories: provider.searchRepositories } : {}), createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 9bb3b1e029b6..ad8f8766a8e2 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -17,6 +17,8 @@ import { type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, + type SourceControlRepositorySearchInput, + type SourceControlRepositorySearchResult, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -35,6 +37,12 @@ export class SourceControlRepositoryService extends Context.Service< readonly lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Effect.Effect; + readonly searchRepositories: ( + input: SourceControlRepositorySearchInput, + ) => Effect.Effect< + ReadonlyArray, + SourceControlRepositoryError + >; /** * Everything `cloneRepository` checks before running git: the resolved * remote, the normalized destination, and that the destination is empty. @@ -193,6 +201,24 @@ export const make = Effect.gen(function* () { return toRepositoryInfo(providerKind, urls); }); + const searchRepositories = Effect.fn("SourceControlRepositoryService.searchRepositories")( + function* (input: SourceControlRepositorySearchInput) { + const providerKind = yield* ensureConcreteProvider({ + operation: "searchRepositories", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + if (!provider.searchRepositories) { + return yield* new SourceControlRepositoryError({ + operation: "searchRepositories", + provider: providerKind, + detail: "Repository search is not supported for this provider.", + }); + } + return yield* provider.searchRepositories({ cwd: config.cwd, query: input.query }); + }, + ); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -445,6 +471,8 @@ export const make = Effect.gen(function* () { return SourceControlRepositoryService.of({ lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), + searchRepositories: (input) => + searchRepositories(input).pipe(mapRepositoryError("searchRepositories", input.provider)), prepareClone: (input) => prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")), cloneRepository: (input, options) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 17500460d9ba..7a931059fef9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2972,6 +2972,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.sourceControlSearchRepositories]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlSearchRepositories, + sourceControlRepositories.searchRepositories(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 7ee1d12036db..885c94ee52e0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -97,7 +97,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; -import { useThreadSearch } from "../state/queries"; +import { useDebouncedValue, useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -371,6 +371,9 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } + if (flow.source === "github") { + return "Search GitHub repositories or enter owner/repo"; + } return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; } @@ -1033,6 +1036,18 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + const githubRepositorySearchQuery = useDebouncedValue( + isRemoteProjectRepositoryStep && addProjectCloneFlow.source === "github" ? query.trim() : "", + 500, + ); + const githubRepositorySearch = useEnvironmentQuery( + isRemoteProjectRepositoryStep && githubRepositorySearchQuery.length >= 3 + ? sourceControlEnvironment.searchRepositories({ + environmentId: addProjectCloneFlow.environmentId, + input: { provider: "github", query: githubRepositorySearchQuery }, + }) + : null, + ); // The destination step pins the repository folder onto the browsed path, so // the proposed clone target is "/" instead of the bare // folder. A lookup reports "owner/repo"; a pasted clone URL falls back to its @@ -2297,82 +2312,27 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } - async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { - if (!addProjectCloneFlow) { + async function lookupCloneRepository(rawRepository: string): Promise { + if (addProjectCloneFlow?.step !== "repository") { return; } - if (!canCreateProjectInEnvironment(browseEnvironment?.connection.phase)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Environment unavailable", - description: `${browseEnvironment?.label ?? "The selected environment"} is not connected.`, - }), - ); + if (rawRepository.length === 0 || isRemoteProjectLookingUp) { return; } - if (addProjectCloneFlow.step === "repository") { - const rawRepository = query.trim(); - if (rawRepository.length === 0 || isRemoteProjectLookingUp) { - return; - } - - const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); - if (!provider) { - const destinationPath = getCloneDestinationPath( - getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(rawRepository), - ); - setAddProjectCloneFlow({ - step: "confirm", - environmentId: addProjectCloneFlow.environmentId, - source: addProjectCloneFlow.source, - repositoryInput: rawRepository, - repository: null, - remoteUrl: normalizePastedCloneUrl(rawRepository), - }); - setHighlightedItemValue(null); - setQuery(destinationPath); - setBrowseGeneration((generation) => generation + 1); - return; - } - - setIsRemoteProjectLookingUp(true); - const lookupGeneration = ++cloneLookupGeneration.current; - const lookupResult = await lookupRepository({ - environmentId: addProjectCloneFlow.environmentId, - input: { - provider, - repository: rawRepository, - }, - }); - if (lookupGeneration !== cloneLookupGeneration.current) return; - setIsRemoteProjectLookingUp(false); - if (lookupResult._tag === "Failure") { - if (!isAtomCommandInterrupted(lookupResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Repository lookup failed", - description: errorMessage(squashAtomCommandFailure(lookupResult)), - }), - ); - } - return; - } - const repository = lookupResult.value; + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + if (!provider) { const destinationPath = getCloneDestinationPath( getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(repository.nameWithOwner), + getCloneDirectoryName(rawRepository), ); setAddProjectCloneFlow({ step: "confirm", environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, repositoryInput: rawRepository, - repository, - remoteUrl: getDefaultCloneUrl(repository), + repository: null, + remoteUrl: normalizePastedCloneUrl(rawRepository), }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -2380,6 +2340,67 @@ function OpenCommandPaletteDialog(props: { return; } + setIsRemoteProjectLookingUp(true); + const lookupGeneration = ++cloneLookupGeneration.current; + const lookupResult = await lookupRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + provider, + repository: rawRepository, + }, + }); + if (lookupGeneration !== cloneLookupGeneration.current) return; + setIsRemoteProjectLookingUp(false); + if (lookupResult._tag === "Failure") { + if (!isAtomCommandInterrupted(lookupResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository lookup failed", + description: errorMessage(squashAtomCommandFailure(lookupResult)), + }), + ); + } + return; + } + const repository = lookupResult.value; + const destinationPath = getCloneDestinationPath( + getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + getCloneDirectoryName(repository.nameWithOwner), + ); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + remoteUrl: getDefaultCloneUrl(repository), + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + } + + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { + if (!addProjectCloneFlow) { + return; + } + if (!canCreateProjectInEnvironment(browseEnvironment?.connection.phase)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${browseEnvironment?.label ?? "The selected environment"} is not connected.`, + }), + ); + return; + } + + if (addProjectCloneFlow.step === "repository") { + await lookupCloneRepository(query.trim()); + return; + } + const rawDestination = (destinationPathInput ?? query).trim(); if (rawDestination.length === 0 || isRemoteProjectCloning) { return; @@ -2576,9 +2597,25 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const githubRepositoryItems: ReadonlyArray = ( + githubRepositorySearch.data ?? [] + ).map((repository) => ({ + kind: "action", + value: `github-repository:${repository.nameWithOwner}`, + searchTerms: [], + title: repository.nameWithOwner, + ...(repository.description ? { description: repository.description } : {}), + icon: , + keepOpen: true, + run: () => lookupCloneRepository(repository.nameWithOwner), + })); + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = + githubRepositoryItems.length > 0 + ? [{ value: "github-repositories", label: "Repositories", items: githubRepositoryItems }] + : []; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2688,7 +2725,13 @@ function OpenCommandPaletteDialog(props: { return; } - if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + const hasHighlightedRepository = + highlightedItemValue?.startsWith("github-repository:") ?? false; + if ( + addProjectCloneFlow?.step === "repository" && + event.key === "Enter" && + !hasHighlightedRepository + ) { event.preventDefault(); void submitAddProjectCloneFlow(); return; @@ -3014,7 +3057,12 @@ 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.", + : addProjectCloneFlow.source !== "github" + ? "Enter a repository path and press Enter to look it up." + : githubRepositorySearch.isPending + ? "Searching GitHub…" + : (githubRepositorySearch.error ?? + "Type to search GitHub, or enter owner/repo and press Enter."), } : addProjectCloneFlow?.step === "confirm" ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..8b3030fb2007 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -76,9 +76,11 @@ az login ## Clone or publish a project Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting -provider or paste a Git URL, then choose where to save it. The project opens right away while the -clone runs in the background: you can write your first prompt, and sending waits until the files -are in place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the +provider or paste a Git URL, then choose where to save it. For GitHub, type at least three +characters to search: your own repositories are listed first, followed by matches from all of +GitHub that your account can see. The project opens right away while the clone +runs in the background: you can write your first prompt, and sending waits until the files are in +place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the toast or from the banner above the composer. For a local Git repository without a remote, **Publish Repository** creates a hosted repository, diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index 39c7a9544549..6d67e21f640e 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -25,6 +25,10 @@ export function createSourceControlEnvironmentAtoms( label: "environment-data:source-control:repository", tag: WS_METHODS.sourceControlLookupRepository, }), + searchRepositories: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:search-repositories", + tag: WS_METHODS.sourceControlSearchRepositories, + }), cloneRepository: createEnvironmentRpcCommand(runtime, { label: "environment-data:source-control:clone-repository", tag: WS_METHODS.sourceControlCloneRepository, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..71f9ae8cfb24 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -271,6 +271,8 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -422,6 +424,7 @@ export const WS_METHODS = { // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlSearchRepositories: "sourceControl.searchRepositories", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", projectCloneStart: "projectClone.start", @@ -882,6 +885,12 @@ const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLook error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); +const WsSourceControlSearchRepositoriesRpc = Rpc.make(WS_METHODS.sourceControlSearchRepositories, { + payload: SourceControlRepositorySearchInput, + success: Schema.Array(SourceControlRepositorySearchResult), + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); + const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, @@ -1458,6 +1467,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsLabelCandidatesRpc, WsPullRequestsSetLabelsRpc, WsSourceControlLookupRepositoryRpc, + WsSourceControlSearchRepositoriesRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectCloneStartRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index b0d2bd4a75bd..7d8489279898 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -69,6 +69,18 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +export const SourceControlRepositorySearchInput = Schema.Struct({ + provider: SourceControlProviderKind, + query: TrimmedNonEmptyString, +}); +export type SourceControlRepositorySearchInput = typeof SourceControlRepositorySearchInput.Type; + +export const SourceControlRepositorySearchResult = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + description: Schema.NullOr(Schema.String), +}); +export type SourceControlRepositorySearchResult = typeof SourceControlRepositorySearchResult.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString), From 6a8076c4eb758c4d8d3ad567879ee725cce87d7a Mon Sep 17 00:00:00 2001 From: Marko Mitranic Date: Wed, 23 Sep 2026 07:35:12 +0000 Subject: [PATCH 2/3] fix(web): only search GitHub while the GitHub clone step is open Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/CommandPalette.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 885c94ee52e0..dbddf6dd573e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1036,12 +1036,14 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + const isGitHubRepositoryStep = + isRemoteProjectRepositoryStep && addProjectCloneFlow.source === "github"; const githubRepositorySearchQuery = useDebouncedValue( - isRemoteProjectRepositoryStep && addProjectCloneFlow.source === "github" ? query.trim() : "", + isGitHubRepositoryStep ? query.trim() : "", 500, ); const githubRepositorySearch = useEnvironmentQuery( - isRemoteProjectRepositoryStep && githubRepositorySearchQuery.length >= 3 + isGitHubRepositoryStep && githubRepositorySearchQuery.length >= 3 ? sourceControlEnvironment.searchRepositories({ environmentId: addProjectCloneFlow.environmentId, input: { provider: "github", query: githubRepositorySearchQuery }, From aed907e0cd3c740c3b83d2c4fd7dbae0a2252da3 Mon Sep 17 00:00:00 2001 From: Marko Mitranic Date: Wed, 23 Sep 2026 07:35:13 +0000 Subject: [PATCH 3/3] fix(server): pass GitHub search text after -- Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/sourceControl/GitHubCli.test.ts | 2 +- apps/server/src/sourceControl/GitHubCli.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 72930fd398e6..9f6075d556c7 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -524,7 +524,7 @@ describe("GitHubCli.layer", () => { processOutput( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify( - input.args[2] === "sleepy user:@me" + input.args.at(-1) === "sleepy user:@me" ? [repository("octocat/sleepytime")] : [repository("sleepy-project/sleepy"), repository("octocat/sleepytime")], ), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 62f951cfdbec..6b56b01047d9 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -671,11 +671,12 @@ export const make = Effect.gen(function* () { args: [ "search", "repos", - query, "--limit", String(limit), "--json", "fullName,description", + "--", + query, ], }).pipe( Effect.flatMap((result) =>