diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..03415aa6a27b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -11,6 +11,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -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"; @@ -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, @@ -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, @@ -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(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; @@ -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: { @@ -704,13 +762,18 @@ export function AddProjectRepositoryScreen(props: { return ( - {error ? : null} + {visibleError ? : null} {environment ? ( <> { + if (value.trim() !== normalizedRepositoryInput) { + setError(null); + } + setRepositoryInput(value); + }} autoCapitalize="none" autoCorrect={false} placeholder={ @@ -718,15 +781,45 @@ export function AddProjectRepositoryScreen(props: { ? "https://github.com/org/repo.git" : addProjectRemoteSourcePathHint(source) } - returnKeyType="next" + returnKeyType={source === "github" ? "done" : "next"} onSubmitEditing={() => void lookupRepository()} /> - void lookupRepository()} - loading={isSubmitting} - /> + {source === "github" ? null : ( + void lookupRepository()} + loading={isSubmitting} + /> + )} + {source === "github" && isRepositorySearchPending ? ( + + + + ) : null} + {repositories ? ( + <> + Repositories + + {repositories.length === 0 ? ( + + No repositories found. + + ) : ( + repositories.map((repository, index) => ( + } + isFirst={index === 0} + onPress={() => selectRepository(repository)} + /> + )) + )} + + + ) : null} ) : ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..b578dffc62a8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -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, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9e2cf15ecb72..0b7f3cb7e8cd 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -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({ diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c1..8110e08dd77a 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -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; + readonly global?: ReadonlyArray; +}): VcsProcess.VcsProcessOutput => + processOutput( + JSON.stringify({ + data: { + owner: { nodes: input.owner.map(repositorySearchResult) }, + global: { nodes: (input.global ?? []).map(repositorySearchResult) }, + }, + }), + ); + const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( @@ -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( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..04862f31f5b8 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -18,6 +18,28 @@ import { } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const SEARCH_REPOSITORIES_QUERY = ` + query SearchRepositories($ownerQuery: String!, $globalQuery: String!) { + owner: search(query: $ownerQuery, type: REPOSITORY, first: 20) { + nodes { + ... on Repository { + nameWithOwner + url + sshUrl + } + } + } + global: search(query: $globalQuery, type: REPOSITORY, first: 20) { + nodes { + ... on Repository { + nameWithOwner + url + sshUrl + } + } + } + } +`.trim(); const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -148,6 +170,19 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "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, @@ -158,6 +193,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -241,6 +277,11 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -276,6 +317,22 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const RawGitHubRepositorySearchResultSchema = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); +const decodeRawGitHubRepositorySearchResults = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + owner: Schema.Struct({ nodes: Schema.Array(RawGitHubRepositorySearchResultSchema) }), + global: Schema.Struct({ nodes: Schema.Array(RawGitHubRepositorySearchResultSchema) }), + }), + }), + ), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -339,6 +396,74 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const searchRepositories = Effect.fn("GitHubCli.searchRepositories")(function* (input: { + readonly cwd: string; + readonly query: string; + }) { + const query = input.query.trim(); + if (query.length === 0) { + return []; + } + + const slashIndex = query.indexOf("/"); + let ownerQuery: string; + let globalQuery: string; + let exactNameWithOwner: string | null = null; + if (slashIndex < 0) { + ownerQuery = `${query} in:name user:@me fork:true`; + globalQuery = `${query} in:name fork:false`; + } else { + const owner = query.slice(0, slashIndex).trim(); + const repositoryName = query.slice(slashIndex + 1).trim(); + if (owner.length === 0 || repositoryName.length === 0) { + return []; + } + ownerQuery = `${repositoryName} in:name user:${owner} fork:true`; + globalQuery = ownerQuery; + exactNameWithOwner = `${owner}/${repositoryName}`.toLowerCase(); + } + + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + "-f", + `query=${SEARCH_REPOSITORIES_QUERY}`, + "-f", + `ownerQuery=${ownerQuery}`, + "-f", + `globalQuery=${globalQuery}`, + ], + }); + const response = yield* decodeRawGitHubRepositorySearchResults(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ); + const repositories = new Map(); + for (const repository of [...response.data.owner.nodes, ...response.data.global.nodes]) { + if (!repositories.has(repository.nameWithOwner)) { + repositories.set(repository.nameWithOwner, repository); + } + } + return [...repositories.values()] + .sort((left, right) => { + if (exactNameWithOwner === null) { + return 0; + } + const leftIsExact = left.nameWithOwner.toLowerCase() === exactNameWithOwner; + const rightIsExact = right.nameWithOwner.toLowerCase() === exactNameWithOwner; + return Number(rightIsExact) - Number(leftIsExact); + }) + .slice(0, 20); + }); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => @@ -432,6 +557,7 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories, createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..d32365f55c5a 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -257,6 +257,21 @@ 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, + repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.query), + 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..f9615dd1a8de 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -111,6 +111,13 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + 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/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..63f02bfda319 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -117,6 +117,44 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("returns all repository search results in provider order", () => { + const calls: Array<{ cwd: string; query: string }> = []; + const provider = makeProvider({ + searchRepositories: (input) => + Effect.sync(() => { + calls.push(input); + return [ + CLONE_URLS, + { + nameWithOwner: "mattpocock/t3code", + url: "https://github.com/mattpocock/t3code", + sshUrl: "git@github.com:mattpocock/t3code.git", + }, + ]; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.searchRepositories({ + provider: "github", + query: "t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, [ + { provider: "github", ...CLONE_URLS }, + { + provider: "github", + nameWithOwner: "mattpocock/t3code", + url: "https://github.com/mattpocock/t3code", + sshUrl: "git@github.com:mattpocock/t3code.git", + }, + ]); + assert.deepStrictEqual(calls, [{ cwd: "/workspace", query: "t3code" }]); + }).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 1b46369e25c4..a0a7d32a1d4d 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"; @@ -30,6 +32,9 @@ export class SourceControlRepositoryService extends Context.Service< readonly lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Effect.Effect; + readonly searchRepositories: ( + input: SourceControlRepositorySearchInput, + ) => Effect.Effect; readonly cloneRepository: ( input: SourceControlCloneRepositoryInput, ) => Effect.Effect; @@ -126,6 +131,22 @@ 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); + const cwd = input.cwd ?? config.cwd; + const query = input.query.trim(); + const urls = provider.searchRepositories + ? yield* provider.searchRepositories({ cwd, query }) + : [yield* provider.getRepositoryCloneUrls({ cwd, repository: query })]; + return urls.map((repository) => toRepositoryInfo(providerKind, repository)); + }, + ); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -276,6 +297,8 @@ export const make = Effect.gen(function* () { ); return SourceControlRepositoryService.of({ + searchRepositories: (input) => + searchRepositories(input).pipe(mapRepositoryError("searchRepositories", 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 226c82cdb1ac..a6b805ecf3c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1886,6 +1886,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 c5ec3f095167..3fca1a29913c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,6 +7,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -77,7 +78,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; -import { useThreadSearch } from "../state/queries"; +import { useDebouncedValue, useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -169,6 +170,7 @@ import { import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const REPOSITORY_SEARCH_DEBOUNCE_MS = 100; function projectFavicon(project: Project) { return ( @@ -255,7 +257,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { case "github": - return "owner/repo"; + return "owner/repo or repository name"; case "gitlab": return "group/project"; case "bitbucket": @@ -291,6 +293,7 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { if (!flow) return null; if (flow.step === "confirm") return null; + if (flow.source === "github") return "Search repositories"; if (flow.source === "url") { return "Enter Git clone URL"; } @@ -642,6 +645,37 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); + const githubRepositorySearchEnvironmentId = + addProjectCloneFlow?.source === "github" && addProjectCloneFlow.step === "repository" + ? addProjectCloneFlow.environmentId + : null; + const normalizedRepositorySearchQuery = + githubRepositorySearchEnvironmentId === null ? "" : query.trim(); + const debouncedRepositorySearchQuery = useDebouncedValue( + normalizedRepositorySearchQuery, + REPOSITORY_SEARCH_DEBOUNCE_MS, + ); + const settledRepositorySearchQuery = + normalizedRepositorySearchQuery.length > 0 && + normalizedRepositorySearchQuery === debouncedRepositorySearchQuery + ? debouncedRepositorySearchQuery + : null; + const repositorySearch = useEnvironmentQuery( + githubRepositorySearchEnvironmentId !== null && settledRepositorySearchQuery !== null + ? sourceControlEnvironment.repositorySearch({ + environmentId: githubRepositorySearchEnvironmentId, + input: { + provider: "github", + query: settledRepositorySearchQuery, + }, + }) + : null, + ); + const repositorySearchResults = repositorySearch.data; + const isGitHubRepositorySearchPending = + normalizedRepositorySearchQuery.length > 0 && + (normalizedRepositorySearchQuery !== debouncedRepositorySearchQuery || + repositorySearch.isPending); const projectGroupingSettings = useMemo( () => selectProjectGroupingSettings(clientSettings), [clientSettings], @@ -1177,6 +1211,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + setIsRemoteProjectLookingUp(false); browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { @@ -1187,6 +1222,28 @@ function OpenCommandPaletteDialog(props: { setQuery(""); } + function backAddProjectCloneFlow(): void { + if (!addProjectCloneFlow) { + popView(); + return; + } + + setIsRemoteProjectLookingUp(false); + browseNavigation.invalidate(); + setHighlightedItemValue(null); + if (addProjectCloneFlow.step === "repository") { + popView(); + return; + } + setAddProjectCloneFlow({ + step: "repository", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + }); + setQuery(addProjectCloneFlow.repositoryInput); + setBrowseGeneration((generation) => generation + 1); + } + function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); setHighlightedItemValue(null); @@ -1231,7 +1288,11 @@ function OpenCommandPaletteDialog(props: { const startAddProjectClone = useCallback( (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow({ step: "repository", environmentId, source }); + setAddProjectCloneFlow({ + step: "repository", + environmentId, + source, + }); pushPaletteView({ addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), groups: [], @@ -1839,6 +1900,28 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } + function selectAddProjectRepository(repository: SourceControlRepositoryInfo): void { + if (addProjectCloneFlow?.step !== "repository" || addProjectCloneFlow.source !== "github") { + return; + } + setIsRemoteProjectLookingUp(false); + const destinationPath = getCloneDestinationPath( + getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + getCloneDirectoryName(repository.nameWithOwner), + ); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: query.trim(), + repository, + remoteUrl: getDefaultCloneUrl(repository), + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + } + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { if (!addProjectCloneFlow) { return; @@ -1880,6 +1963,10 @@ function OpenCommandPaletteDialog(props: { return; } + if (provider === "github" && !isGitHubRepositoryShorthand(rawRepository)) { + return; + } + setIsRemoteProjectLookingUp(true); const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, @@ -1920,6 +2007,10 @@ function OpenCommandPaletteDialog(props: { return; } + if (addProjectCloneFlow.step !== "confirm") { + return; + } + const rawDestination = (destinationPathInput ?? query).trim(); if (rawDestination.length === 0 || isRemoteProjectCloning) { return; @@ -2064,9 +2155,35 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + const repositorySearchGroups: CommandPaletteView["groups"] = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + repositorySearchResults !== null + ? [ + { + value: "repositories", + label: "Repositories", + items: repositorySearchResults.map((repository) => ({ + kind: "action" as const, + value: `repository:${repository.nameWithOwner}`, + searchTerms: [repository.nameWithOwner, repository.url], + title: repository.nameWithOwner, + description: repository.url, + icon: , + keepOpen: true, + run: async () => selectAddProjectRepository(repository), + })), + }, + ] + : []; let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = + addProjectCloneFlow.source !== "github" || + normalizedRepositorySearchQuery.length === 0 || + isGitHubRepositorySearchPending + ? [] + : repositorySearchGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2077,6 +2194,10 @@ function OpenCommandPaletteDialog(props: { remoteProjectInputPlaceholder(addProjectCloneFlow) ?? getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + const shouldAutoHighlightRepositoryResult = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + displayedGroups.some((group) => group.items.length > 0); const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = isBrowsing && @@ -2110,6 +2231,15 @@ function OpenCommandPaletteDialog(props: { query.trim().length > 0 && canCreateProjectInEnvironment(browseEnvironment?.connection.phase) && !isRemoteProjectPending; + const shouldOfferGitHubRepositoryLookup = + addProjectCloneFlow?.step === "repository" && + addProjectCloneFlow.source === "github" && + isGitHubRepositoryShorthand(query) && + !shouldAutoHighlightRepositoryResult && + !isGitHubRepositorySearchPending; + const shouldShowRemoteProjectAccessory = + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup); const fileManagerName = getLocalFileManagerName(navigator.platform); const canOpenProjectFromFileManager = isBrowsing && @@ -2168,7 +2298,11 @@ function OpenCommandPaletteDialog(props: { } } - if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + if ( + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup) && + event.key === "Enter" + ) { event.preventDefault(); void submitAddProjectCloneFlow(); return; @@ -2191,7 +2325,7 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); - popView(); + backAddProjectCloneFlow(); } } @@ -2332,7 +2466,8 @@ function OpenCommandPaletteDialog(props: { ]); const inputAccessory = - addProjectCloneFlow?.step === "repository" ? ( + addProjectCloneFlow?.step === "repository" && + (addProjectCloneFlow.source !== "github" || shouldOfferGitHubRepositoryLookup) ? ( ) : null; - const footerActionLabel = - addProjectCloneFlow?.step === "repository" - ? (remoteProjectButtonLabel ?? "Continue") - : !canSubmitBrowsePath || hasHighlightedBrowseItem - ? "Select" - : undefined; + const footerActionLabel = shouldShowRemoteProjectAccessory + ? (remoteProjectButtonLabel ?? "Continue") + : !canSubmitBrowsePath || hasHighlightedBrowseItem + ? "Select" + : undefined; const footerTrailing = canOpenProjectFromFileManager ? ( @@ -2500,7 +2637,17 @@ 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" + ? normalizedRepositorySearchQuery.length === 0 + ? "Start typing to search GitHub repositories." + : isGitHubRepositorySearchPending + ? "Searching repositories…" + : repositorySearch.error !== null + ? "Unable to search repositories. Try again." + : repositorySearchResults?.length === 0 + ? "No repositories found." + : "Start typing to search GitHub repositories." + : "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/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..67ea088a0e4e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -19,7 +19,8 @@ T3 Code works with the platforms your team already uses: - Open the Command Palette (`Cmd/Ctrl + K`) → **Add Project** - Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL** -- Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding +- For GitHub, start typing a repository name and choose from the matches; repositories owned by your signed-in account appear first +- For other providers, enter the repository path (`group/project`, `workspace/repository`, or `project/repository`) or paste a full Git URL, then pick a destination and start coding **Publish local projects to the cloud** diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index cbbd121959d5..75574a42c144 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -17,6 +17,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + isGitHubRepositoryShorthand, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -49,6 +50,8 @@ describe("add project shared logic", () => { }); it("routes owner/repository shorthand to GitHub over HTTPS", () => { + expect(isGitHubRepositoryShorthand("imputnet/helium")).toBe(true); + expect(isGitHubRepositoryShorthand("skills")).toBe(false); expect(normalizePastedCloneUrl("imputnet/helium")).toBe( "https://github.com/imputnet/helium.git", ); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 3f5fc3667f31..853a3021da8f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -88,7 +88,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { case "github": - return "owner/repo"; + return "owner/repo or repository name"; case "gitlab": return "group/project"; case "bitbucket": @@ -109,10 +109,14 @@ export function addProjectRemoteSourceProvider( const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +export function isGitHubRepositoryShorthand(input: string): boolean { + return GITHUB_REPOSITORY_SHORTHAND.test(input.trim()); +} + /** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */ export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); - if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed; + if (!isGitHubRepositoryShorthand(trimmed)) return trimmed; const repository = trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`; return `https://github.com/${repository}`; } diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..b5de039dc743 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, }), + repositorySearch: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repository-search", + tag: WS_METHODS.sourceControlSearchRepositories, + }), 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 e753596f3d33..ade2378e7077 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -100,6 +100,8 @@ import type { SourceControlPublishRepositoryResult, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, } from "./sourceControl.ts"; export interface ContextMenuItem { @@ -1311,6 +1313,9 @@ export interface EnvironmentApi { createUrl: (input: AssetCreateUrlInput) => Promise; }; sourceControl: { + searchRepositories: ( + input: SourceControlRepositorySearchInput, + ) => Promise; lookupRepository: ( input: SourceControlRepositoryLookupInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..f0bd7c810f41 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -203,6 +203,8 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -317,6 +319,7 @@ export const WS_METHODS = { // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlSearchRepositories: "sourceControl.searchRepositories", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -621,6 +624,15 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( }, ); +export const WsSourceControlSearchRepositoriesRpc = Rpc.make( + WS_METHODS.sourceControlSearchRepositories, + { + payload: SourceControlRepositorySearchInput, + success: SourceControlRepositorySearchResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), + }, +); + export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, @@ -1059,6 +1071,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, WsSourceControlLookupRepositoryRpc, + WsSourceControlSearchRepositoriesRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..01974acf6f94 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -64,6 +64,16 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +export const SourceControlRepositorySearchInput = Schema.Struct({ + provider: SourceControlProviderKind, + query: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositorySearchInput = typeof SourceControlRepositorySearchInput.Type; + +export const SourceControlRepositorySearchResult = Schema.Array(SourceControlRepositoryInfo); +export type SourceControlRepositorySearchResult = typeof SourceControlRepositorySearchResult.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString),