From 0d786de2c3c4be3bceb7ff6c9e40983a9a2bcf49 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:21:47 +0800 Subject: [PATCH 01/27] feat(contracts): add source control repository search schemas --- packages/contracts/src/sourceControl.test.ts | 97 ++++++++++++++++++++ packages/contracts/src/sourceControl.ts | 29 +++++- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/sourceControl.test.ts diff --git a/packages/contracts/src/sourceControl.test.ts b/packages/contracts/src/sourceControl.test.ts new file mode 100644 index 000000000000..35d054f318e1 --- /dev/null +++ b/packages/contracts/src/sourceControl.test.ts @@ -0,0 +1,97 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + SourceControlRepositorySearchInput, + SourceControlRepositorySearchOutput, + SourceControlRepositorySearchResult, +} from "./sourceControl.ts"; + +const decodeSearchResult = Schema.decodeUnknownSync(SourceControlRepositorySearchResult); +const decodeSearchInput = Schema.decodeUnknownSync(SourceControlRepositorySearchInput); +const decodeSearchOutput = Schema.decodeUnknownSync(SourceControlRepositorySearchOutput); + +describe("SourceControlRepositorySearchResult", () => { + it("decodes a repository the viewer owns", () => { + const parsed = decodeSearchResult({ + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git", + ownedByViewer: true, + description: "A minimal GUI for coding agents", + starCount: 1234, + isFork: false, + isPrivate: false, + }); + + expect(parsed.nameWithOwner).toBe("pingdotgg/t3code"); + expect(parsed.ownedByViewer).toBe(true); + expect(parsed.starCount).toBe(1234); + }); + + it("leaves the optional metadata undefined when the provider omits it", () => { + const parsed = decodeSearchResult({ + nameWithOwner: "octocat/hello-world", + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + ownedByViewer: false, + }); + + expect(parsed.description).toBeUndefined(); + expect(parsed.starCount).toBeUndefined(); + expect(parsed.isFork).toBeUndefined(); + expect(parsed.isPrivate).toBeUndefined(); + }); + + it("rejects a result without nameWithOwner", () => { + expect(() => + decodeSearchResult({ + url: "https://github.com/octocat/hello-world", + sshUrl: "git@github.com:octocat/hello-world.git", + ownedByViewer: false, + }), + ).toThrow(); + }); +}); + +describe("SourceControlRepositorySearchInput", () => { + it("decodes a query without a cwd", () => { + const parsed = decodeSearchInput({ provider: "github", query: "t3code" }); + + expect(parsed.provider).toBe("github"); + expect(parsed.query).toBe("t3code"); + expect(parsed.cwd).toBeUndefined(); + }); + + it("decodes a query scoped to a working directory", () => { + const parsed = decodeSearchInput({ provider: "github", query: "t3code", cwd: "/repo" }); + + expect(parsed.cwd).toBe("/repo"); + }); +}); + +describe("SourceControlRepositorySearchOutput", () => { + it("decodes an unsupported provider as data instead of an error", () => { + const parsed = decodeSearchOutput({ supported: false, results: [] }); + + expect(parsed.supported).toBe(false); + expect(parsed.results).toEqual([]); + }); + + it("decodes supported results", () => { + const parsed = decodeSearchOutput({ + supported: true, + results: [ + { + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git", + ownedByViewer: true, + }, + ], + }); + + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0]?.nameWithOwner).toBe("pingdotgg/t3code"); + }); +}); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..b30857e7c6e1 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ @@ -64,6 +64,33 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({ }); export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; +/** One repository a provider returned for a search query. Optional fields are provider metadata that not every provider reports. */ +export const SourceControlRepositorySearchResult = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + ownedByViewer: Schema.Boolean, + description: Schema.optional(Schema.String), + starCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), + isPrivate: Schema.optional(Schema.Boolean), +}); +export type SourceControlRepositorySearchResult = typeof SourceControlRepositorySearchResult.Type; + +export const SourceControlRepositorySearchInput = Schema.Struct({ + provider: SourceControlProviderKind, + query: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositorySearchInput = typeof SourceControlRepositorySearchInput.Type; + +/** `supported` is false when the provider cannot search, so callers render that as state instead of erroring on every keystroke. */ +export const SourceControlRepositorySearchOutput = Schema.Struct({ + supported: Schema.Boolean, + results: Schema.Array(SourceControlRepositorySearchResult), +}); +export type SourceControlRepositorySearchOutput = typeof SourceControlRepositorySearchOutput.Type; + export const SourceControlCloneRepositoryInput = Schema.Struct({ provider: Schema.optional(SourceControlProviderKind), repository: Schema.optional(TrimmedNonEmptyString), From a1aeaae7777d851e324e057bb34b41d853c6936a Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:24:33 +0800 Subject: [PATCH 02/27] feat(contracts): add the repository search RPC definition --- packages/contracts/src/rpc.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..f0197ed9f3d1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -190,6 +190,8 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchOutput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; @@ -299,6 +301,7 @@ export const WS_METHODS = { // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlSearchRepositories: "sourceControl.searchRepositories", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -603,6 +606,15 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make( }, ); +export const WsSourceControlSearchRepositoriesRpc = Rpc.make( + WS_METHODS.sourceControlSearchRepositories, + { + payload: SourceControlRepositorySearchInput, + success: SourceControlRepositorySearchOutput, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), + }, +); + export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, From 68ad1e13b14c239dcbbc352f5148bbe5df21eb7d Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:31:37 +0800 Subject: [PATCH 03/27] feat(server): add a repository search method to the source control providers Adds searchRepositories to the SourceControlProvider interface, the unregistered-provider fallback, and the per-method remote context binder, so every adapter answers the same shape. Every adapter returns { supported: false, results: [] } for now. GitLab, Bitbucket, and Azure DevOps keep that answer: this pass is GitHub only, and returning it as data rather than an error stops a search-as-you-type field raising a toast on every keystroke. GitHub's is a marked placeholder that a real gh search repos call replaces next. --- .../AzureDevOpsSourceControlProvider.test.ts | 19 ++++++++++++++++++- .../AzureDevOpsSourceControlProvider.ts | 3 +++ .../BitbucketSourceControlProvider.test.ts | 14 +++++++++++++- .../BitbucketSourceControlProvider.ts | 3 +++ .../GitHubSourceControlProvider.ts | 3 +++ .../GitLabSourceControlProvider.test.ts | 19 ++++++++++++++++++- .../GitLabSourceControlProvider.ts | 3 +++ .../sourceControl/SourceControlProvider.ts | 10 ++++++++++ .../SourceControlProviderRegistry.ts | 8 ++++++++ .../SourceControlRepositoryService.test.ts | 1 + 10 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e79912..836dfc7d3599 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -1,8 +1,9 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; @@ -132,3 +133,19 @@ it.effect("uses Azure CLI repository detection for default branch lookup", () => assert.strictEqual(cwdInput, "/repo"); }), ); + +it.effect("reports repository search as unsupported without running an Azure CLI command", () => + Effect.gen(function* () { + const run = vi.fn(); + const provider = yield* AzureDevOpsSourceControlProvider.make.pipe( + Effect.provide( + AzureDevOpsCli.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(run).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2f147452f9ec..1791ae4c4c74 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -173,6 +173,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. Azure DevOps answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => azure.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbdd2a..ae04d992e378 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -166,3 +166,15 @@ it.effect("uses Bitbucket API repository detection for default branch lookup", ( assert.strictEqual(cwdInput, "/repo"); }), ); + +it.effect("reports repository search as unsupported without calling the Bitbucket API", () => + Effect.gen(function* () { + const request = vi.fn(); + const provider = yield* makeProvider({ request }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(request).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 59fab76e5277..11a4fb22b7ce 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -125,6 +125,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. Bitbucket answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => bitbucket.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..2af626d476ff 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -257,6 +257,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Placeholder until the `gh search repos` call lands; GitHub search is not wired up yet. + // TODO: replace with the real GitHub CLI repository search. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => github.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665214..a0967014561e 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -1,9 +1,10 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitLabCli from "./GitLabCli.ts"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; @@ -223,3 +224,19 @@ selfhosted ], ); }); + +it.effect("reports repository search as unsupported without running a GitLab command", () => + Effect.gen(function* () { + const run = vi.fn(); + const provider = yield* GitLabSourceControlProvider.make.pipe( + Effect.provide( + GitLabCli.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(output, { supported: false, results: [] }); + expect(run).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f7..fd403d8c9e44 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -197,6 +197,9 @@ export const make = Effect.gen(function* () { }), ), ), + // Repository search is GitHub only for now. GitLab answers as data rather than + // failing, so a search-as-you-type caller renders "not supported" instead of an error. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => gitlab.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..adcbd10640a0 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -7,6 +7,7 @@ import type { SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, + SourceControlRepositorySearchOutput, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -111,6 +112,15 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + /** + * Answers `supported: false` rather than failing when the provider cannot search, so a + * search-as-you-type caller renders that as state instead of an error on every keystroke. + */ + readonly searchRepositories: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly query: string; + }) => Effect.Effect; 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..6c06db4117eb 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -98,6 +98,9 @@ function unsupportedProvider( repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), detail: `No ${kind} source control provider is registered.`, }), + // Search answers as data, never as an error: an unregistered provider must not raise a + // toast on every keystroke of a search-as-you-type field. + searchRepositories: () => Effect.succeed({ supported: false, results: [] }), createRepository: (input) => new SourceControlProviderError({ provider: kind, @@ -180,6 +183,11 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + searchRepositories: (input) => + provider.searchRepositories({ + ...input, + context: input.context ?? context, + }), 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 861da9a10e05..7dfff3259c99 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -36,6 +36,7 @@ function makeProvider( getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + searchRepositories: () => unsupported("searchRepositories"), createRepository: () => Effect.succeed(CLONE_URLS), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), From 93c4d974722844f562e80f4343c01e4f3c3f58b5 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:39:15 +0800 Subject: [PATCH 04/27] feat(server): search GitHub repositories through the gh CLI GitHubCli.searchRepositories runs `gh repo list` for the viewer's own repositories and `gh search repos` for public ones, then merges them. The two commands disagree on field names (stargazerCount vs stargazersCount) and search returns no ssh URL, so each has its own schema and the ssh URL is derived from the repository's URL host. The query is free user text and the first client input in this repo to reach gh argv. GitHubCli strips anything outside [A-Za-z0-9._/-], drops leading dashes so a query can never be read as a flag, and caps the result at 128 characters. --- apps/server/src/git/GitManager.test.ts | 3 + .../src/sourceControl/GitHubCli.test.ts | 189 ++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 187 +++++++++++++++++ 3 files changed, 379 insertions(+) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 01a9d43195a9..f72893a0f613 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -562,6 +562,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + // GitManager never searches repositories; the fake only has to satisfy + // the service interface. + 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..9512bd92ea27 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -403,4 +403,193 @@ describe("GitHubCli.layer", () => { assert.notInclude(error.message, "user ID"); }).pipe(Effect.provide(layer)), ); + + it.effect("searches owned repositories and public repositories with the documented argv", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + isPrivate: false, + nameWithOwner: "octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + stargazerCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + { + description: "", + isFork: true, + isPrivate: true, + nameWithOwner: "octocat/dotfiles", + sshUrl: "git@github.com:octocat/dotfiles.git", + stargazerCount: 0, + url: "https://github.com/octocat/dotfiles", + }, + ]), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Another take on codething", + forksCount: 7, + fullName: "acme/codething-tools", + isPrivate: false, + stargazersCount: 900, + url: "https://github.com/acme/codething-tools", + }, + { + description: "", + forksCount: 0, + fullName: "octocat/codething-mvp", + isPrivate: false, + stargazersCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + expect(mockRun).toHaveBeenCalledTimes(2); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "repo", + "list", + "--json", + "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", + "--limit", + "100", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "search", + "repos", + "codething", + "--json", + "fullName,url,stargazersCount,forksCount,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + + // Owned repositories keep gh's `stargazerCount`/`sshUrl`; search results + // report `stargazersCount` and carry no ssh URL at all. + assert.deepStrictEqual(results, [ + { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + ownedByViewer: true, + description: "Minimal GUI for coding agents", + starCount: 42, + isFork: false, + isPrivate: false, + }, + { + nameWithOwner: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + sshUrl: "git@github.com:acme/codething-tools.git", + ownedByViewer: false, + description: "Another take on codething", + starCount: 900, + isPrivate: false, + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("strips shell metacharacters and spaces from the query before it reaches argv", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ + cwd: "/repo", + query: "foo; rm -rf ~ && echo `id`", + }); + + const searchArgs = mockRun.mock.calls[1]?.[0]?.args; + assert.deepStrictEqual(searchArgs, [ + "search", + "repos", + "foorm-rfechoid", + "--json", + "fullName,url,stargazersCount,forksCount,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("never lets a query become a gh flag", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "--limit 9999" }); + + assert.strictEqual(mockRun.mock.calls[1]?.[0]?.args?.[2], "limit9999"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("caps the query length", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "a".repeat(500) }); + + assert.strictEqual(mockRun.mock.calls[1]?.[0]?.args?.[2], "a".repeat(128)); + }).pipe(Effect.provide(layer)), + ); + + it.effect("runs no gh command when the query sanitizes to nothing", () => + Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "!!! ???" }); + + assert.deepStrictEqual(results, []); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("fails with a decode error when gh returns unusable search JSON", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .searchRepositories({ cwd: "/repo", query: "codething" }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubRepositorySearchDecodeError"); + assert.strictEqual(error.cwd, "/repo"); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..f599c88a2540 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -6,7 +6,9 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, + type SourceControlRepositorySearchResult, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; @@ -148,6 +150,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 +173,7 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubRepositorySearchDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -241,6 +257,16 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + /** + * Repositories matching a free-text query: the viewer's own repositories + * that contain the query, then public repositories GitHub search returns. + * The query is sanitized here, so no caller can widen what reaches argv. + */ + readonly searchRepositories: (input: { + readonly cwd: string; + readonly query: string; + }) => Effect.Effect, GitHubCliError>; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -323,6 +349,100 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } +/** Longest query we hand to `gh`. Repository names are far shorter than this. */ +const SEARCH_QUERY_MAX_LENGTH = 128; + +/** + * The search query is free user text, and the only client-supplied value in + * this file that reaches `gh` argv. Keep it to characters that can appear in an + * owner or repository name, drop leading dashes so it can never be read as a + * flag, and cap the length. Applied inside the service so callers cannot skip + * it. + */ +function sanitizeSearchQuery(query: string): string { + return query + .replace(/[^A-Za-z0-9._/-]/g, "") + .replace(/^-+/, "") + .slice(0, SEARCH_QUERY_MAX_LENGTH); +} + +/** `gh repo list` reports stars as `stargazerCount` and ships an ssh URL. */ +const RawGitHubOwnedRepositoriesSchema = Schema.Array( + Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, + stargazerCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), + isPrivate: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeRawGitHubOwnedRepositories = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubOwnedRepositoriesSchema), +); + +/** + * `gh search repos` uses a different vocabulary from `gh repo list`: the name + * is `fullName`, stars are `stargazersCount`, and there is no ssh URL, so we + * derive one. `forksCount` is requested for parity with the search UI but + * counts a repository's forks, which is not the `isFork` flag, so search + * results carry no fork flag. + */ +const RawGitHubSearchedRepositoriesSchema = Schema.Array( + Schema.Struct({ + fullName: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + stargazersCount: Schema.optional(NonNegativeInt), + isPrivate: Schema.optional(Schema.Boolean), + description: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeRawGitHubSearchedRepositories = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubSearchedRepositoriesSchema), +); + +function deriveSshUrl(nameWithOwner: string, url: string): string { + try { + return `git@${new URL(url).host}:${nameWithOwner}.git`; + } catch { + return `git@github.com:${nameWithOwner}.git`; + } +} + +function optionalDescription(value: string | null | undefined) { + return value !== undefined && value !== null ? { description: value } : {}; +} + +function normalizeOwnedRepository( + raw: Schema.Schema.Type[number], +): SourceControlRepositorySearchResult { + return { + nameWithOwner: raw.nameWithOwner, + url: raw.url, + sshUrl: raw.sshUrl, + ownedByViewer: true, + ...optionalDescription(raw.description), + ...(raw.stargazerCount !== undefined ? { starCount: raw.stargazerCount } : {}), + ...(raw.isFork !== undefined ? { isFork: raw.isFork } : {}), + ...(raw.isPrivate !== undefined ? { isPrivate: raw.isPrivate } : {}), + }; +} + +function normalizeSearchedRepository( + raw: Schema.Schema.Type[number], +): SourceControlRepositorySearchResult { + return { + nameWithOwner: raw.fullName, + url: raw.url, + sshUrl: deriveSshUrl(raw.fullName, raw.url), + ownedByViewer: false, + ...optionalDescription(raw.description), + ...(raw.stargazersCount !== undefined ? { starCount: raw.stargazersCount } : {}), + ...(raw.isPrivate !== undefined ? { isPrivate: raw.isPrivate } : {}), + }; +} + export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -432,6 +552,73 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + searchRepositories: (input) => + Effect.gen(function* () { + const query = sanitizeSearchQuery(input.query); + if (query.length === 0) { + return []; + } + + const ownedOutput = yield* execute({ + cwd: input.cwd, + args: [ + "repo", + "list", + "--json", + "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", + "--limit", + "100", + ], + }); + const owned = yield* decodeRawGitHubOwnedRepositories(ownedOutput.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }), + ), + ); + + const searchOutput = yield* execute({ + cwd: input.cwd, + args: [ + "search", + "repos", + query, + "--json", + "fullName,url,stargazersCount,forksCount,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ], + }); + const searched = yield* decodeRawGitHubSearchedRepositories( + searchOutput.stdout.trim(), + ).pipe( + Effect.mapError( + (cause) => + new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }), + ), + ); + + // `gh repo list` takes no query, so the viewer's repositories are + // matched here. They come first, and a repository the viewer owns is + // never repeated by the public search below it. + const needle = query.toLowerCase(); + const results = owned + .filter((raw) => raw.nameWithOwner.toLowerCase().includes(needle)) + .map(normalizeOwnedRepository); + const seen = new Set(results.map((result) => result.nameWithOwner)); + + for (const raw of searched) { + if (seen.has(raw.fullName)) { + continue; + } + seen.add(raw.fullName); + results.push(normalizeSearchedRepository(raw)); + } + + return results; + }), createRepository: (input) => execute({ cwd: input.cwd, From c76042fe782e6668bb847156530c298d28e73687 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:46:09 +0800 Subject: [PATCH 05/27] feat(server): return ranked GitHub search results from the provider The GitHub adapter's searchRepositories was a placeholder that always answered supported: false. It now calls the GitHub CLI search and returns supported: true with the results ranked own repositories first, prefix matches ahead of substring matches, then most stars. Results are capped at 20 rows and descriptions trimmed to 160 characters, because this payload is rebuilt on every keystroke. --- .../GitHubSourceControlProvider.test.ts | 117 ++++++++++++++++++ .../GitHubSourceControlProvider.ts | 86 ++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6bbc..27d71a734139 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { SourceControlRepositorySearchResult } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -207,6 +208,122 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }), ); +const SEARCH_DESCRIPTION_CAP = 160; + +function searchResult( + overrides: Partial & { readonly nameWithOwner: string }, +): SourceControlRepositorySearchResult { + return { + url: `https://github.com/${overrides.nameWithOwner}`, + sshUrl: `git@github.com:${overrides.nameWithOwner}.git`, + ownedByViewer: false, + starCount: 0, + ...overrides, + }; +} + +it.effect("ranks, caps, and trims the repository search results it returns", () => + Effect.gen(function* () { + // Every adjacent pair below is decided by a different rule, so dropping or + // reordering any one of them changes this expectation. + const owned = searchResult({ + nameWithOwner: "mark/t3code-tools", + ownedByViewer: true, + starCount: 1, + description: "a".repeat(400), + }); + const ownedSubstring = searchResult({ + nameWithOwner: "mark/awesome-t3code", + ownedByViewer: true, + starCount: 9000, + }); + const prefixPopular = searchResult({ + nameWithOwner: "pingdotgg/t3code", + starCount: 5000, + description: "Short and untouched.", + }); + const prefixQuiet = searchResult({ nameWithOwner: "forks/t3code-mirror", starCount: 100 }); + const substringPopular = searchResult({ nameWithOwner: "legacy/old-t3code", starCount: 4000 }); + const filler = Array.from({ length: 20 }, (_unused, index) => + searchResult({ nameWithOwner: `filler/pack-t3code-${index}` }), + ); + + let searchInput: { readonly cwd: string; readonly query: string } | null = null; + const provider = yield* makeProvider({ + searchRepositories: (input) => { + searchInput = input; + return Effect.succeed([ + ...filler, + substringPopular, + prefixPopular, + ownedSubstring, + prefixQuiet, + owned, + ]); + }, + }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3code" }); + + assert.deepStrictEqual(searchInput, { cwd: "/repo", query: "t3code" }); + assert.strictEqual(output.supported, true); + assert.strictEqual(output.results.length, 20); + assert.deepStrictEqual( + output.results.slice(0, 5).map((result) => result.nameWithOwner), + [ + "mark/t3code-tools", + "mark/awesome-t3code", + "pingdotgg/t3code", + "forks/t3code-mirror", + "legacy/old-t3code", + ], + ); + assert.strictEqual(output.results[0]?.description, "a".repeat(SEARCH_DESCRIPTION_CAP)); + assert.strictEqual(output.results[2]?.description, "Short and untouched."); + }), +); + +it.effect("redacts search queries in provider errors while keeping the CLI cause", () => + Effect.gen(function* () { + const cause = new GitHubCli.GitHubRepositorySearchDecodeError({ + command: "gh", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + searchRepositories: () => Effect.fail(cause), + }); + + const error = yield* provider + .searchRepositories({ + cwd: "/repo", + query: "https://user:secret@github.com/pingdotgg/t3code?token=secret", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "github", + operation: "searchRepositories", + command: "gh", + cwd: "/repo", + repository: "https://github.com/pingdotgg/t3code", + detail: "GitHub CLI returned invalid repository search JSON.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 2af626d476ff..62dc1cb08f3d 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -6,6 +6,7 @@ import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState, + type SourceControlRepositorySearchResult, } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -42,6 +43,69 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +/** One dropdown's worth of rows. Ranking already puts the useful ones first. */ +const MAX_SEARCH_RESULTS = 20; + +/** + * GitHub descriptions run to 350 characters and this payload is rebuilt on every + * keystroke, so descriptions are trimmed to about one list row before they cross + * the socket. Clients that want the full text read the repository itself. + */ +const MAX_SEARCH_RESULT_DESCRIPTION_LENGTH = 160; + +/** True when the query starts the repository name, or the whole `owner/name`. */ +function matchesSearchPrefix(result: SourceControlRepositorySearchResult, query: string): boolean { + if (query.length === 0) { + return false; + } + const nameWithOwner = result.nameWithOwner.toLowerCase(); + const name = nameWithOwner.slice(nameWithOwner.lastIndexOf("/") + 1); + return nameWithOwner.startsWith(query) || name.startsWith(query); +} + +/** Own repositories first, then prefix matches over substring matches, then most stars. */ +function compareSearchResults(query: string) { + return ( + left: SourceControlRepositorySearchResult, + right: SourceControlRepositorySearchResult, + ) => { + if (left.ownedByViewer !== right.ownedByViewer) { + return left.ownedByViewer ? -1 : 1; + } + const leftPrefix = matchesSearchPrefix(left, query); + const rightPrefix = matchesSearchPrefix(right, query); + if (leftPrefix !== rightPrefix) { + return leftPrefix ? -1 : 1; + } + return (right.starCount ?? 0) - (left.starCount ?? 0); + }; +} + +function trimSearchResultDescription( + result: SourceControlRepositorySearchResult, +): SourceControlRepositorySearchResult { + if ( + result.description === undefined || + result.description.length <= MAX_SEARCH_RESULT_DESCRIPTION_LENGTH + ) { + return result; + } + return { + ...result, + description: result.description.slice(0, MAX_SEARCH_RESULT_DESCRIPTION_LENGTH).trimEnd(), + }; +} + +function rankSearchResults( + results: ReadonlyArray, + query: string, +): ReadonlyArray { + return [...results] + .sort(compareSearchResults(query.trim().toLowerCase())) + .slice(0, MAX_SEARCH_RESULTS) + .map(trimSearchResultDescription); +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -257,9 +321,25 @@ export const make = Effect.gen(function* () { }), ), ), - // Placeholder until the `gh search repos` call lands; GitHub search is not wired up yet. - // TODO: replace with the real GitHub CLI repository search. - searchRepositories: () => Effect.succeed({ supported: false, results: [] }), + searchRepositories: (input) => + github.searchRepositories({ cwd: input.cwd, query: input.query }).pipe( + Effect.map((results) => ({ + supported: true, + results: rankSearchResults(results, input.query), + })), + 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( From 83e846f6edc87a64a46d423d4d37fe40cb5e1296 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:03:00 +0800 Subject: [PATCH 06/27] perf(server): cache and rate limit GitHub repository search Search-as-you-type spent two gh spawns per keystroke, and GitHub allows only 30 search requests a minute against 5000 an hour for the core API. The viewer's own repositories are now listed at most once a minute per working directory and matched locally, the global search runs only for queries of three characters or more that local matches do not already fill, and its rows are cached for 30 seconds per query. Both fetches go through SourceControlRateLimit; an open circuit serves cached rows or an empty list rather than raising a toast per keystroke. Also swaps forksCount for isFork in the gh search repos argv. The contract field is whether a repository is a fork, not how many forks it has, so the requested field never decoded onto it. --- .../src/sourceControl/GitHubCli.test.ts | 160 ++++++++++- apps/server/src/sourceControl/GitHubCli.ts | 252 ++++++++++++++---- .../GitHubSourceControlProvider.test.ts | 29 +- 3 files changed, 383 insertions(+), 58 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 9512bd92ea27..e15423ec00b4 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -5,8 +5,11 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as TestClock from "effect/testing/TestClock"; + import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), @@ -26,6 +29,26 @@ const layer = GitHubCli.layer.pipe( ), ); +/** + * Repository search reads the clock and the GitHub circuit, so its tests build the + * CLI over a rate limiter they can pause rather than over `GitHubCli.layer`, which + * provides one of its own. + */ +const searchLayer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provideMerge(SourceControlRateLimit.layer), +); + +/** The gh subcommand of every spawn so far, in order. */ +const spawnedSubcommands = () => + mockRun.mock.calls.map((call) => call[0].args.slice(0, 2).join(" ")); + +const ownedRepository = (nameWithOwner: string) => ({ + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, +}); + afterEach(() => { mockRun.mockReset(); }); @@ -440,7 +463,7 @@ describe("GitHubCli.layer", () => { JSON.stringify([ { description: "Another take on codething", - forksCount: 7, + isFork: true, fullName: "acme/codething-tools", isPrivate: false, stargazersCount: 900, @@ -448,7 +471,7 @@ describe("GitHubCli.layer", () => { }, { description: "", - forksCount: 0, + isFork: false, fullName: "octocat/codething-mvp", isPrivate: false, stargazersCount: 42, @@ -485,7 +508,7 @@ describe("GitHubCli.layer", () => { "repos", "codething", "--json", - "fullName,url,stargazersCount,forksCount,description,isPrivate", + "fullName,url,stargazersCount,isFork,description,isPrivate", "--limit", "20", "--sort", @@ -515,6 +538,7 @@ describe("GitHubCli.layer", () => { ownedByViewer: false, description: "Another take on codething", starCount: 900, + isFork: true, isPrivate: false, }, ]); @@ -537,7 +561,7 @@ describe("GitHubCli.layer", () => { "repos", "foorm-rfechoid", "--json", - "fullName,url,stargazersCount,forksCount,description,isPrivate", + "fullName,url,stargazersCount,isFork,description,isPrivate", "--limit", "20", "--sort", @@ -592,4 +616,132 @@ describe("GitHubCli.layer", () => { assert.strictEqual(error.cwd, "/repo"); }).pipe(Effect.provide(layer)), ); + it.effect("reuses the owned repository listing for a minute of typing", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + yield* TestClock.adjust("59 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethings" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + + yield* TestClock.adjust("2 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethingy" }); + + assert.deepStrictEqual(spawnedSubcommands(), [ + "repo list", + "search repos", + "search repos", + "repo list", + "search repos", + ]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("serves a repeated query from the search cache", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + yield* TestClock.adjust("29 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + + yield* TestClock.adjust("2 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("never spends a search request on a two-character query", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "co" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list"]); + + yield* gh.searchRepositories({ cwd: "/repo", query: "cod" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("asks GitHub only when the viewer's own repositories do not fill the list", () => + Effect.gen(function* () { + const ownedFor = (count: number) => + JSON.stringify( + Array.from({ length: count }, (_, index) => + ownedRepository(`octocat/codething-${index}`), + ), + ); + mockRun.mockImplementation((input) => + Effect.succeed( + processOutput( + input.args[0] === "search" ? "[]" : ownedFor(input.cwd === "/five" ? 5 : 4), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/five", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list"]); + + yield* gh.searchRepositories({ cwd: "/four", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("runs no gh command while the GitHub circuit is open", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, []); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("opens the circuit when gh reports a GitHub rate limit", () => + Effect.gen(function* () { + mockRun.mockReturnValue( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .searchRepositories({ cwd: "/repo", query: "codething" }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const paused = yield* Effect.flip(limits.check({ provider: "github", host: "github.com" })); + assert.strictEqual(paused._tag, "SourceControlRateLimitPausedError"); + }).pipe(Effect.provide(searchLayer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index f599c88a2540..3b1b7aa1a4ec 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,7 +1,9 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -18,6 +20,7 @@ import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, } from "./gitHubPullRequests.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -261,6 +264,11 @@ export class GitHubCli extends Context.Service< * Repositories matching a free-text query: the viewer's own repositories * that contain the query, then public repositories GitHub search returns. * The query is sanitized here, so no caller can widen what reaches argv. + * + * Built for a search-as-you-type field: the owned listing is cached per + * working directory, the global search runs only when it can earn its + * request, and both go through the GitHub circuit breaker. A paused circuit + * yields whatever is cached, or nothing, rather than an error. */ readonly searchRepositories: (input: { readonly cwd: string; @@ -352,6 +360,57 @@ function deriveRepositoryCloneUrlsFromCreateOutput( /** Longest query we hand to `gh`. Repository names are far shorter than this. */ const SEARCH_QUERY_MAX_LENGTH = 128; +/** + * The viewer's own repositories change on the scale of days and are matched + * locally, so one listing covers a whole typing session. + */ +const OWNED_REPOSITORIES_TTL_MS = 60_000; + +/** + * Search rows are cached per sanitized query: long enough to absorb backspacing + * and retyping, short enough that a repository created mid-session turns up. + */ +const SEARCHED_REPOSITORIES_TTL_MS = 30_000; + +/** + * GitHub allows 30 search requests a minute, far tighter than the 5000 an hour + * the core API allows, so a query has to say something before it costs one. + */ +const MIN_GLOBAL_SEARCH_QUERY_LENGTH = 3; + +/** + * Local matches from this count up already fill the visible part of the + * dropdown, so a global search would only add rows below the fold. + */ +const SUFFICIENT_LOCAL_MATCHES = 5; + +/** A fast typist produces one cache entry per keystroke; bound both caches. */ +const SEARCH_CACHE_CAPACITY = 64; + +/** `gh` talks to github.com, and the circuit is keyed by provider and host. */ +const GITHUB_RATE_LIMIT_KEY = { provider: "github", host: "github.com" } as const; + +interface SearchCacheEntry { + readonly fetchedAt: number; + readonly value: A; +} + +/** Bounded insert-ordered map. The oldest fetch is evicted first. */ +function storeCacheEntry( + current: ReadonlyMap>, + key: string, + entry: SearchCacheEntry, +): ReadonlyMap> { + const next = new Map(current); + next.delete(key); + next.set(key, entry); + for (const oldest of next.keys()) { + if (next.size <= SEARCH_CACHE_CAPACITY) break; + next.delete(oldest); + } + return next; +} + /** * The search query is free user text, and the only client-supplied value in * this file that reaches `gh` argv. Keep it to characters that can appear in an @@ -381,19 +440,19 @@ const RawGitHubOwnedRepositoriesSchema = Schema.Array( const decodeRawGitHubOwnedRepositories = Schema.decodeEffect( Schema.fromJsonString(RawGitHubOwnedRepositoriesSchema), ); +type RawOwnedRepository = Schema.Schema.Type[number]; /** * `gh search repos` uses a different vocabulary from `gh repo list`: the name * is `fullName`, stars are `stargazersCount`, and there is no ssh URL, so we - * derive one. `forksCount` is requested for parity with the search UI but - * counts a repository's forks, which is not the `isFork` flag, so search - * results carry no fork flag. + * derive one. `isFork` means the same thing in both. */ const RawGitHubSearchedRepositoriesSchema = Schema.Array( Schema.Struct({ fullName: TrimmedNonEmptyString, url: TrimmedNonEmptyString, stargazersCount: Schema.optional(NonNegativeInt), + isFork: Schema.optional(Schema.Boolean), isPrivate: Schema.optional(Schema.Boolean), description: Schema.optional(Schema.NullOr(Schema.String)), }), @@ -401,6 +460,7 @@ const RawGitHubSearchedRepositoriesSchema = Schema.Array( const decodeRawGitHubSearchedRepositories = Schema.decodeEffect( Schema.fromJsonString(RawGitHubSearchedRepositoriesSchema), ); +type RawSearchedRepository = Schema.Schema.Type[number]; function deriveSshUrl(nameWithOwner: string, url: string): string { try { @@ -414,9 +474,7 @@ function optionalDescription(value: string | null | undefined) { return value !== undefined && value !== null ? { description: value } : {}; } -function normalizeOwnedRepository( - raw: Schema.Schema.Type[number], -): SourceControlRepositorySearchResult { +function normalizeOwnedRepository(raw: RawOwnedRepository): SourceControlRepositorySearchResult { return { nameWithOwner: raw.nameWithOwner, url: raw.url, @@ -430,7 +488,7 @@ function normalizeOwnedRepository( } function normalizeSearchedRepository( - raw: Schema.Schema.Type[number], + raw: RawSearchedRepository, ): SourceControlRepositorySearchResult { return { nameWithOwner: raw.fullName, @@ -439,12 +497,14 @@ function normalizeSearchedRepository( ownedByViewer: false, ...optionalDescription(raw.description), ...(raw.stargazersCount !== undefined ? { starCount: raw.stargazersCount } : {}), + ...(raw.isFork !== undefined ? { isFork: raw.isFork } : {}), ...(raw.isPrivate !== undefined ? { isPrivate: raw.isPrivate } : {}), }; } export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const execute: GitHubCli["Service"]["execute"] = (input) => process @@ -459,6 +519,116 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const ownedRepositoriesCache = yield* Ref.make< + ReadonlyMap>> + >(new Map()); + const searchedRepositoriesCache = yield* Ref.make< + ReadonlyMap>> + >(new Map()); + + /** + * One gh request under the GitHub circuit breaker. `null` means the circuit is + * open and nothing ran, which is the caller's cue to serve whatever it already + * has. `request` is a thunk so an open circuit builds no command at all. + */ + const guarded = (request: () => Effect.Effect) => + limits.check(GITHUB_RATE_LIMIT_KEY).pipe( + Effect.flatMap((lease) => + request().pipe( + Effect.tap(() => limits.recordSuccess({ ...GITHUB_RATE_LIMIT_KEY, lease })), + Effect.tapError((error) => + error._tag === "GitHubCliRateLimitError" + ? limits.recordRateLimit({ ...GITHUB_RATE_LIMIT_KEY, lease }) + : Effect.void, + ), + ), + ), + Effect.catchTag("SourceControlRateLimitPausedError", () => Effect.succeed(null)), + ); + + const searchDecodeError = (cwd: string) => (cause: unknown) => + new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }); + + /** The viewer's repositories, at most one `gh repo list` per minute per cwd. */ + const ownedRepositories = (cwd: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = (yield* Ref.get(ownedRepositoriesCache)).get(cwd); + if (cached !== undefined && now - cached.fetchedAt < OWNED_REPOSITORIES_TTL_MS) { + return cached.value; + } + + const fetched = yield* guarded(() => + execute({ + cwd, + args: [ + "repo", + "list", + "--json", + "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", + "--limit", + "100", + ], + }).pipe( + Effect.flatMap((output) => + decodeRawGitHubOwnedRepositories(output.stdout.trim()).pipe( + Effect.mapError(searchDecodeError(cwd)), + ), + ), + ), + ); + if (fetched === null) { + return cached?.value ?? []; + } + + yield* Ref.update(ownedRepositoriesCache, (current) => + storeCacheEntry(current, cwd, { fetchedAt: now, value: fetched }), + ); + return fetched; + }); + + /** Public repositories for one sanitized query, cached for a few keystrokes. */ + const searchedRepositories = (cwd: string, query: string) => + Effect.gen(function* () { + const key = `${cwd}\u0000${query}`; + const now = yield* Clock.currentTimeMillis; + const cached = (yield* Ref.get(searchedRepositoriesCache)).get(key); + if (cached !== undefined && now - cached.fetchedAt < SEARCHED_REPOSITORIES_TTL_MS) { + return cached.value; + } + + const fetched = yield* guarded(() => + execute({ + cwd, + args: [ + "search", + "repos", + query, + "--json", + "fullName,url,stargazersCount,isFork,description,isPrivate", + "--limit", + "20", + "--sort", + "stars", + ], + }).pipe( + Effect.flatMap((output) => + decodeRawGitHubSearchedRepositories(output.stdout.trim()).pipe( + Effect.mapError(searchDecodeError(cwd)), + ), + ), + ), + ); + if (fetched === null) { + return cached?.value ?? []; + } + + yield* Ref.update(searchedRepositoriesCache, (current) => + storeCacheEntry(current, key, { fetchedAt: now, value: fetched }), + ); + return fetched; + }); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => @@ -559,54 +729,22 @@ export const make = Effect.gen(function* () { return []; } - const ownedOutput = yield* execute({ - cwd: input.cwd, - args: [ - "repo", - "list", - "--json", - "nameWithOwner,url,sshUrl,stargazerCount,isFork,isPrivate,description", - "--limit", - "100", - ], - }); - const owned = yield* decodeRawGitHubOwnedRepositories(ownedOutput.stdout.trim()).pipe( - Effect.mapError( - (cause) => - new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }), - ), + // `gh repo list` takes no query, so the viewer's repositories are matched + // here against the cached listing. That is the common keystroke: no spawn. + const needle = query.toLowerCase(); + const localMatches = (yield* ownedRepositories(input.cwd)).filter((raw) => + raw.nameWithOwner.toLowerCase().includes(needle), ); - const searchOutput = yield* execute({ - cwd: input.cwd, - args: [ - "search", - "repos", - query, - "--json", - "fullName,url,stargazersCount,forksCount,description,isPrivate", - "--limit", - "20", - "--sort", - "stars", - ], - }); - const searched = yield* decodeRawGitHubSearchedRepositories( - searchOutput.stdout.trim(), - ).pipe( - Effect.mapError( - (cause) => - new GitHubRepositorySearchDecodeError({ command: "gh", cwd: input.cwd, cause }), - ), - ); + const searched = + query.length >= MIN_GLOBAL_SEARCH_QUERY_LENGTH && + localMatches.length < SUFFICIENT_LOCAL_MATCHES + ? yield* searchedRepositories(input.cwd, query) + : []; - // `gh repo list` takes no query, so the viewer's repositories are - // matched here. They come first, and a repository the viewer owns is - // never repeated by the public search below it. - const needle = query.toLowerCase(); - const results = owned - .filter((raw) => raw.nameWithOwner.toLowerCase().includes(needle)) - .map(normalizeOwnedRepository); + // Owned repositories come first, and one the viewer owns is never + // repeated by the public search below it. + const results = localMatches.map(normalizeOwnedRepository); const seen = new Set(results.map((result) => result.nameWithOwner)); for (const raw of searched) { @@ -662,4 +800,12 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make); +/** + * The circuit breaker is private to this layer. GitHub bills the search endpoint + * (30 requests a minute) separately from the core quota the pull-request + * subsystem tracks with its own `SourceControlRateLimit`, so the two circuits are + * deliberately independent rather than one pausing the other. + */ +export const layer = Layer.effect(GitHubCli, make).pipe( + Layer.provide(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 27d71a734139..4b27243a2e69 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it, vi } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,6 +10,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const processResult = ( stdout: string, @@ -324,6 +325,32 @@ it.effect("redacts search queries in provider errors while keeping the CLI cause }), ); +// Search-as-you-type would raise one toast per keystroke if a paused circuit failed, +// so the whole path down to the process boundary has to answer with data instead. +it.effect("answers with empty results instead of an error while the GitHub circuit is open", () => + Effect.gen(function* () { + const run = vi.fn(); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const provider = yield* GitHubSourceControlProvider.make.pipe( + Effect.provide( + Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provide(Layer.succeed(SourceControlRateLimit.SourceControlRateLimit, limits)), + ), + ), + ); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(output, { supported: true, results: [] }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( From 043140133851d4b833cc406d7ea9468c884a319a Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:07:29 +0800 Subject: [PATCH 07/27] feat(server): expose repository search over the websocket API Repository search reached the provider boundary but had no way in from a client. Add searchRepositories to SourceControlRepositoryService, register the existing search RPC in WsRpcGroup, give it orchestration:read so standard pairing clients can call it, and wire the websocket handler. --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../SourceControlRepositoryService.test.ts | 27 +++++++++++++++++++ .../SourceControlRepositoryService.ts | 21 +++++++++++++++ apps/server/src/ws.ts | 8 ++++++ packages/contracts/src/rpc.ts | 1 + 5 files changed, 58 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..84b379f6b952 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/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 7dfff3259c99..39fd21a6c4b0 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -118,6 +118,33 @@ it.effect("looks up repositories through the requested provider without search", }).pipe(Effect.provide(makeLayer({ provider }))); }); +it.effect("searches repositories through the requested provider", () => { + const calls: Array<{ cwd: string; query: string }> = []; + const searchResult = { + supported: true, + results: [{ ...CLONE_URLS, ownedByViewer: true }], + }; + const provider = makeProvider({ + searchRepositories: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, query: input.query }); + return searchResult; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.searchRepositories({ + provider: "github", + query: "t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, searchResult); + 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..400e6ccb9a81 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 SourceControlRepositorySearchOutput, } 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,20 @@ 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); + return yield* provider.searchRepositories({ + cwd: input.cwd ?? config.cwd, + query: input.query.trim(), + }); + }, + ); + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( function* (destinationPath: string) { const trimmed = destinationPath.trim(); @@ -278,6 +297,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)), cloneRepository: (input) => cloneRepository(input).pipe( mapRepositoryError("cloneRepository", input.provider ?? "unknown"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..a61c8ffe002b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1817,6 +1817,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/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f0197ed9f3d1..d0f3488eadba 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1036,6 +1036,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, WsSourceControlLookupRepositoryRpc, + WsSourceControlSearchRepositoriesRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, From 02e15a1c2841547521668940eb0cd141e0208780 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:12:55 +0800 Subject: [PATCH 08/27] feat(client-runtime): add a repository search query atom family --- .../client-runtime/src/state/sourceControl.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index c1598b49eaeb..90abb4668bf7 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -1,5 +1,9 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; +import { + WS_METHODS, + type SourceControlRepositorySearchInput, + type SourceControlRepositorySearchOutput, +} from "@t3tools/contracts"; +import { Atom, type AsyncResult } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, @@ -24,6 +28,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, @@ -46,3 +54,27 @@ export function createSourceControlEnvironmentAtoms( }), }; } + +type AssertTrue = T; +type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type RepositorySearchAtomFamily = ReturnType< + typeof createSourceControlEnvironmentAtoms +>["repositorySearch"]; +type RepositorySearchValue = + ReturnType extends Atom.Atom< + AsyncResult.AsyncResult + > + ? A + : never; + +/** + * Compile-time proof that `repositorySearch` carries the repository search contract, so + * dropping the key or pointing it at another RPC tag fails here instead of in web or mobile. + */ +type _RepositorySearchTakesSearchInput = AssertTrue< + Exact[0]["input"], SourceControlRepositorySearchInput> +>; +type _RepositorySearchYieldsSearchOutput = AssertTrue< + Exact +>; From f14a3c351348da8287b12ae77f0f00be8dbf0796 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:23:03 +0800 Subject: [PATCH 09/27] feat(web): search GitHub repositories from the add-project flow Cloning a project required typing an exact owner/repo path, so a typo or a half-remembered name produced a lookup failure and no suggestions. The repository step now subscribes to the repository search query as the user types, debounced at 200ms with a two character minimum, and lists results in a "Your repositories" group and a provider-named group. Selecting a row skips the lookup round trip, since a result already carries the clone URLs. Enter still runs the exact-path lookup unless a repository row is highlighted, mirroring how a highlighted browse row takes Enter in the destination step. A provider that cannot search and a paused rate-limit circuit both render as empty-state text rather than an error. --- .../web/src/components/CommandPalette.test.ts | 93 ++++++++ apps/web/src/components/CommandPalette.tsx | 205 +++++++++++++++--- apps/web/src/state/queries.ts | 59 +++++ 3 files changed, 332 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/CommandPalette.test.ts diff --git a/apps/web/src/components/CommandPalette.test.ts b/apps/web/src/components/CommandPalette.test.ts new file mode 100644 index 000000000000..1863c058a4b5 --- /dev/null +++ b/apps/web/src/components/CommandPalette.test.ts @@ -0,0 +1,93 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + repositoryResultItemValue, + repositoryStepEmptyState, + repositoryStepEnterAction, +} from "./CommandPalette"; + +const environmentId = EnvironmentId.make("environment-a"); + +describe("repositoryStepEnterAction", () => { + it("selects the highlighted repository instead of looking up the raw text", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), + hasPrimaryModifier: false, + }), + ).toBe("select-highlighted-repository"); + }); + + it("looks up the typed path when no repository row is highlighted", () => { + expect( + repositoryStepEnterAction({ highlightedItemValue: null, hasPrimaryModifier: false }), + ).toBe("lookup-typed-repository"); + expect( + repositoryStepEnterAction({ + highlightedItemValue: "browse:/home/user/projects", + hasPrimaryModifier: false, + }), + ).toBe("lookup-typed-repository"); + }); + + it("keeps the exact-path lookup reachable with the primary modifier", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), + hasPrimaryModifier: true, + }), + ).toBe("lookup-typed-repository"); + }); +}); + +describe("repositoryResultItemValue", () => { + it("keys a row by environment and repository so identical names across environments stay distinct", () => { + expect(repositoryResultItemValue(environmentId, "t3dotgg/t3code")).toBe( + "repo:environment-a:t3dotgg/t3code", + ); + expect( + repositoryResultItemValue(EnvironmentId.make("environment-b"), "t3dotgg/t3code"), + ).not.toBe(repositoryResultItemValue(environmentId, "t3dotgg/t3code")); + }); +}); + +describe("repositoryStepEmptyState", () => { + const settled = { supported: true, error: null, isPending: false, canSearch: true } as const; + + it("shows the ordinary empty state when a supported provider returns nothing", () => { + expect(repositoryStepEmptyState({ source: "github", search: settled })).toBe( + "No repositories match. Press Enter to look up the exact path.", + ); + }); + + it("points at the exact-path input when the provider cannot search", () => { + expect( + repositoryStepEmptyState({ source: "gitlab", search: { ...settled, supported: false } }), + ).toBe("Search is unavailable for GitLab. Enter group/project and press Enter."); + }); + + it("reports the search failing the same way, since the way out is the same", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, error: "gh exited 1" } }), + ).toBe("Search is unavailable for GitHub. Enter owner/repo and press Enter."); + }); + + it("expresses loading as a string, because the palette has no spinner", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, isPending: true } }), + ).toBe("Searching repositories…"); + }); + + it("prompts for a path before the query is long enough to search", () => { + expect( + repositoryStepEmptyState({ source: "github", search: { ...settled, canSearch: false } }), + ).toBe("Enter a repository path and press Enter to look it up."); + }); + + it("leaves the Git URL source alone", () => { + expect(repositoryStepEmptyState({ source: "url", search: settled })).toBe( + "Enter a Git clone URL and press Enter to continue.", + ); + }); +}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..aac873c36bca 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -30,6 +30,7 @@ import { type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, + type SourceControlRepositorySearchResult, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -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 { useRepositorySearch, useThreadSearch, type RepositorySearchView } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -119,6 +120,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteGroup, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -273,6 +275,59 @@ function remoteProjectSourceProvider( return source === "url" ? null : source; } +const REPOSITORY_RESULT_VALUE_PREFIX = "repo:"; + +/** Row key for one searched repository. Environment-scoped so the same name in two environments stays distinct. */ +export function repositoryResultItemValue( + environmentId: EnvironmentId, + nameWithOwner: string, +): string { + return `${REPOSITORY_RESULT_VALUE_PREFIX}${environmentId}:${nameWithOwner}`; +} + +function isRepositoryResultValue(value: string | null): boolean { + return value?.startsWith(REPOSITORY_RESULT_VALUE_PREFIX) ?? false; +} + +/** + * Enter in the repository step runs the highlighted search result, or looks up the typed path when + * no result is highlighted. Mirrors how a highlighted browse row takes Enter from the path submit, + * including the primary modifier as the escape hatch back to the exact-path lookup. + */ +export function repositoryStepEnterAction(input: { + readonly highlightedItemValue: string | null; + readonly hasPrimaryModifier: boolean; +}): "select-highlighted-repository" | "lookup-typed-repository" { + return isRepositoryResultValue(input.highlightedItemValue) && !input.hasPrimaryModifier + ? "select-highlighted-repository" + : "lookup-typed-repository"; +} + +/** + * The repository step has no spinner, so every state of the search reads as an empty-state string. + * Both empty successes stay here rather than becoming errors: a paused rate-limit circuit answers + * `supported: true` with no results and gets the ordinary empty state, while a provider that cannot + * search answers `supported: false` and gets the affordance pointing back at the exact-path input. + */ +export function repositoryStepEmptyState(input: { + readonly source: AddProjectRemoteSource; + readonly search: Pick; +}): string { + if (input.source === "url") { + return "Enter a Git clone URL and press Enter to continue."; + } + if (!input.search.supported || input.search.error !== null) { + return `Search is unavailable for ${remoteProjectSourceLabel(input.source)}. Enter ${remoteProjectSourcePathHint(input.source)} and press Enter.`; + } + if (input.search.isPending) { + return "Searching repositories…"; + } + if (!input.search.canSearch) { + return "Enter a repository path and press Enter to look it up."; + } + return "No repositories match. Press Enter to look up the exact path."; +} + function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { switch (source) { case "github": @@ -826,6 +881,16 @@ function OpenCommandPaletteDialog(props: { input: {}, }), ); + const repositorySearchFlow = + addProjectCloneFlow?.step === "repository" ? addProjectCloneFlow : null; + const repositorySearch = useRepositorySearch({ + environmentId: repositorySearchFlow?.environmentId ?? null, + provider: + repositorySearchFlow === null + ? null + : remoteProjectSourceProvider(repositorySearchFlow.source), + query: repositorySearchFlow === null ? "" : query, + }); const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( browseEnvironment?.serverConfig?.environment.platform.os, ); @@ -1839,6 +1904,52 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } + function enterCloneDestinationStep(input: { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo; + }): void { + setAddProjectCloneFlow({ + step: "confirm", + environmentId: input.environmentId, + source: input.source, + repositoryInput: input.repositoryInput, + repository: input.repository, + remoteUrl: getDefaultCloneUrl(input.repository), + }); + setHighlightedItemValue(null); + setQuery( + getCloneDestinationPath( + getDefaultCloneParentPath(input.environmentId), + getCloneDirectoryName(input.repository.nameWithOwner), + ), + ); + setBrowseGeneration((generation) => generation + 1); + } + + /** A searched row already carries the clone URLs, so selecting one skips the lookup round trip. */ + function selectSearchedRepository(result: SourceControlRepositorySearchResult): void { + if (addProjectCloneFlow?.step !== "repository") { + return; + } + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + if (provider === null) { + return; + } + enterCloneDestinationStep({ + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: result.nameWithOwner, + repository: { + provider, + nameWithOwner: result.nameWithOwner, + url: result.url, + sshUrl: result.sshUrl, + }, + }); + } + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { if (!addProjectCloneFlow) { return; @@ -1901,22 +2012,12 @@ function OpenCommandPaletteDialog(props: { } return; } - const repository = lookupResult.value; - const destinationPath = getCloneDestinationPath( - getDefaultCloneParentPath(addProjectCloneFlow.environmentId), - getCloneDirectoryName(repository.nameWithOwner), - ); - setAddProjectCloneFlow({ - step: "confirm", + enterCloneDestinationStep({ environmentId: addProjectCloneFlow.environmentId, source: addProjectCloneFlow.source, repositoryInput: rawRepository, - repository, - remoteUrl: getDefaultCloneUrl(repository), + repository: lookupResult.value, }); - setHighlightedItemValue(null); - setQuery(destinationPath); - setBrowseGeneration((generation) => generation + 1); return; } @@ -2064,9 +2165,51 @@ function OpenCommandPaletteDialog(props: { }; }, [addProjectCloneFlow]); + // Ranking, the 20-result cap, and description truncation all happen server-side; the only client + // decision left is which of the two groups a row belongs to. + const repositorySearchGroups: CommandPaletteGroup[] = []; + if (repositorySearchFlow !== null && repositorySearch.results.length > 0) { + const ownedItems: CommandPaletteActionItem[] = []; + const otherItems: CommandPaletteActionItem[] = []; + for (const result of repositorySearch.results) { + const item: CommandPaletteActionItem = { + kind: "action", + value: repositoryResultItemValue(repositorySearchFlow.environmentId, result.nameWithOwner), + searchTerms: [result.nameWithOwner], + title: result.nameWithOwner, + ...(result.description ? { description: result.description } : {}), + icon: remoteProjectSourceIcon(repositorySearchFlow.source, ITEM_ICON_CLASS), + keepOpen: true, + run: async () => { + selectSearchedRepository(result); + }, + }; + (result.ownedByViewer ? ownedItems : otherItems).push(item); + } + if (ownedItems.length > 0) { + repositorySearchGroups.push({ + value: "repositories:owned", + label: "Your repositories", + items: ownedItems, + }); + } + if (otherItems.length > 0) { + repositorySearchGroups.push({ + value: "repositories:other", + label: remoteProjectSourceLabel(repositorySearchFlow.source), + items: otherItems, + }); + } + } + + const repositoryStepEmptyStateMessage = + repositorySearchFlow === null + ? null + : repositoryStepEmptyState({ source: repositorySearchFlow.source, search: repositorySearch }); + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { - displayedGroups = []; + displayedGroups = repositorySearchGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -2078,6 +2221,7 @@ function OpenCommandPaletteDialog(props: { getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; + const hasHighlightedRepositoryItem = isRepositoryResultValue(highlightedItemValue); const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject && @@ -2105,6 +2249,9 @@ function OpenCommandPaletteDialog(props: { : "Lookup" : null; const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; + const remoteProjectShortcutLabel = hasHighlightedRepositoryItem + ? `${submitModifierLabel} Enter` + : "Enter"; const canSubmitRemoteProjectFlow = addProjectCloneFlow?.step === "repository" && query.trim().length > 0 && @@ -2169,6 +2316,15 @@ function OpenCommandPaletteDialog(props: { } if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + if ( + repositoryStepEnterAction({ + highlightedItemValue, + hasPrimaryModifier: isPrimaryModifierPressed(event), + }) === "select-highlighted-repository" + ) { + // Leave the event alone so the highlighted row runs itself. + return; + } event.preventDefault(); void submitAddProjectCloneFlow(); return; @@ -2341,7 +2497,7 @@ function OpenCommandPaletteDialog(props: { size="xs" tabIndex={-1} className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" - aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} + aria-label={`${remoteProjectButtonLabel ?? "Continue"} (${remoteProjectShortcutLabel})`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { event.preventDefault(); @@ -2354,10 +2510,12 @@ function OpenCommandPaletteDialog(props: { > {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - Enter + {remoteProjectShortcutLabel} - {remoteProjectButtonLabel ?? "Continue"} (Enter) + + {remoteProjectButtonLabel ?? "Continue"} ({remoteProjectShortcutLabel}) + ) : isBrowsing ? ( @@ -2408,7 +2566,9 @@ function OpenCommandPaletteDialog(props: { const footerActionLabel = addProjectCloneFlow?.step === "repository" - ? (remoteProjectButtonLabel ?? "Continue") + ? hasHighlightedRepositoryItem + ? "Select" + : (remoteProjectButtonLabel ?? "Continue") : !canSubmitBrowsePath || hasHighlightedBrowseItem ? "Select" : undefined; @@ -2495,13 +2655,8 @@ function OpenCommandPaletteDialog(props: { isActionsOnly={isActionsOnly} keybindings={keybindings} onExecuteItem={executeItem} - {...(addProjectCloneFlow?.step === "repository" - ? { - 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.", - } + {...(repositoryStepEmptyStateMessage !== null + ? { emptyStateMessage: repositoryStepEmptyStateMessage } : addProjectCloneFlow?.step === "confirm" ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } : relativePathNeedsActiveProject diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..2fe8138c24ab 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -14,6 +14,8 @@ import type { OrchestrationThread, ProjectContentMatch, ProjectEntryKind, + SourceControlProviderKind, + SourceControlRepositorySearchResult, ThreadId, VcsListRefsResult, VcsRef, @@ -28,6 +30,7 @@ import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; @@ -36,11 +39,15 @@ const COMPOSER_PATH_SEARCH_LIMIT = 80; const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; +const REPOSITORY_SEARCH_DEBOUNCE_MS = 200; +const REPOSITORY_SEARCH_MIN_QUERY_LENGTH = 2; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const EMPTY_REPOSITORY_SEARCH_RESULTS: ReadonlyArray = + Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, @@ -103,6 +110,58 @@ export function useThreadSearch( }; } +export interface RepositorySearchTarget { + readonly environmentId: EnvironmentId | null; + readonly provider: SourceControlProviderKind | null; + readonly query: string; +} + +export interface RepositorySearchView { + readonly results: ReadonlyArray; + /** False only once a provider answers that it cannot search, so callers show an affordance instead of an error. */ + readonly supported: boolean; + readonly error: string | null; + readonly isPending: boolean; + /** True while the query is long enough to search, whether or not results have arrived. */ + readonly canSearch: boolean; +} + +/** + * Debounced repository search for the add-project flow. Results blank while the query settles, + * because a previous owner prefix's matches actively mislead, and each settled query subscribes to + * its own atom so a late response cannot overwrite a newer one. + */ +export function useRepositorySearch(target: RepositorySearchTarget): RepositorySearchView { + const normalizedQuery = target.query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, REPOSITORY_SEARCH_DEBOUNCE_MS); + const environmentId = target.environmentId; + const provider = target.provider; + const canSearch = + environmentId !== null && + provider !== null && + normalizedQuery.length >= REPOSITORY_SEARCH_MIN_QUERY_LENGTH; + const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; + const settledQuery = canSearch && !isDebouncing ? debouncedQuery : null; + const result = useEnvironmentQuery( + settledQuery === null || environmentId === null || provider === null + ? null + : sourceControlEnvironment.repositorySearch({ + environmentId, + input: { provider, query: settledQuery }, + }), + ); + + return { + results: isDebouncing + ? EMPTY_REPOSITORY_SEARCH_RESULTS + : (result.data?.results ?? EMPTY_REPOSITORY_SEARCH_RESULTS), + supported: result.data?.supported ?? true, + error: result.error, + isPending: canSearch && (isDebouncing || result.isPending), + canSearch, + }; +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, From b30f72e87366fb767c5d11daba9cce10dfdf75c3 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:34:43 +0800 Subject: [PATCH 10/27] feat(mobile): search GitHub repositories when adding a project Adding a cloned project on mobile required typing an exact owner/repo path, so a typo or a half-remembered name produced a lookup failure and no suggestions. The repository step now subscribes to the repository search query as the user types, debounced at 200ms with a two character minimum, and lists results in a "Your repositories" group and a provider-named group. Selecting a row pushes straight to the destination step, since a result already carries both clone URLs. Submitting the input still runs the exact-path lookup. A provider that cannot search and a paused rate-limit circuit both render as one line of text rather than an error. --- .../projects/AddProjectScreen.logic.test.ts | 125 +++++++++++++++++- .../projects/AddProjectScreen.logic.ts | 117 +++++++++++++++- .../features/projects/AddProjectScreen.tsx | 97 +++++++++++++- 3 files changed, 335 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts index 3464bfda2604..9443cb082615 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts @@ -2,7 +2,13 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { + buildRepositorySearchTarget, + buildSearchedRepositoryDestination, + groupRepositorySearchResults, + repositorySearchEmptyState, + resolveAddProjectEnvironment, +} from "./AddProjectScreen.logic"; const ENVIRONMENT_A = EnvironmentId.make("environment-a"); const ENVIRONMENT_B = EnvironmentId.make("environment-b"); @@ -39,3 +45,120 @@ describe("resolveAddProjectEnvironment", () => { ).toBe(ENVIRONMENT_B); }); }); + +describe("buildRepositorySearchTarget", () => { + const settled = { + environmentId: ENVIRONMENT_A, + provider: "github" as const, + }; + + it("fires no request below the minimum query length", () => { + expect(buildRepositorySearchTarget({ ...settled, query: "t", debouncedQuery: "t" })).toBeNull(); + }); + + it("subscribes once the query is long enough and has settled", () => { + expect( + buildRepositorySearchTarget({ ...settled, query: " t3 ", debouncedQuery: "t3" }), + ).toEqual({ + environmentId: ENVIRONMENT_A, + input: { provider: "github", query: "t3" }, + }); + }); + + it("blanks results while the query is still settling", () => { + expect( + buildRepositorySearchTarget({ ...settled, query: "t3co", debouncedQuery: "t3" }), + ).toBeNull(); + }); +}); + +describe("buildSearchedRepositoryDestination", () => { + it("navigates with the selected repository instead of looking it up again", () => { + expect( + buildSearchedRepositoryDestination({ + environmentId: ENVIRONMENT_A, + source: "github", + result: { + nameWithOwner: "t3dotgg/t3code", + url: "https://github.com/t3dotgg/t3code", + sshUrl: "git@github.com:t3dotgg/t3code.git", + ownedByViewer: true, + }, + }), + ).toEqual({ + environmentId: ENVIRONMENT_A, + source: "github", + remoteUrl: "https://github.com/t3dotgg/t3code", + repositoryTitle: "t3dotgg/t3code", + repositoryName: "t3code", + }); + }); +}); + +describe("groupRepositorySearchResults", () => { + function result(nameWithOwner: string, ownedByViewer: boolean) { + return { + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, + ownedByViewer, + }; + } + + it("keeps the server ranking within a group and drops groups with no rows", () => { + expect( + groupRepositorySearchResults( + [result("me/one", true), result("other/two", false), result("me/three", true)], + "github", + ), + ).toEqual([ + { + key: "owned", + label: "Your repositories", + results: [result("me/one", true), result("me/three", true)], + }, + { key: "other", label: "GitHub", results: [result("other/two", false)] }, + ]); + expect(groupRepositorySearchResults([result("me/one", true)], "github")).toEqual([ + { key: "owned", label: "Your repositories", results: [result("me/one", true)] }, + ]); + }); +}); + +describe("repositorySearchEmptyState", () => { + const settled = { supported: true, error: null, isPending: false, canSearch: true } as const; + + it("shows the ordinary empty state when a supported provider returns nothing", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled })).toBe( + "No repositories match. Press Enter to look up the exact path.", + ); + }); + + it("points at the exact-path input when the provider cannot search", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, supported: false })).toBe( + "Search is unavailable for GitHub. Enter owner/repo and press Enter.", + ); + }); + + it("reports a failed search the same way, since the way out is the same", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, error: "gh exited 1" })).toBe( + "Search is unavailable for GitHub. Enter owner/repo and press Enter.", + ); + }); + + it("expresses loading as a string, because the list has no spinner", () => { + expect(repositorySearchEmptyState({ source: "github", ...settled, isPending: true })).toBe( + "Searching repositories…", + ); + }); + + it("says nothing before the query is long enough, since the placeholder already prompts", () => { + expect( + repositorySearchEmptyState({ source: "github", ...settled, canSearch: false }), + ).toBeNull(); + }); + + it("leaves the Git URL source alone", () => { + expect(repositorySearchEmptyState({ source: "url", ...settled })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.logic.ts b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts index b208a1719aba..f318228bd22b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.logic.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.logic.ts @@ -1,6 +1,18 @@ -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + addProjectRemoteSourceLabel, + addProjectRemoteSourcePathHint, + canCreateProjectInEnvironment, + getCloneDirectoryName, + getDefaultCloneUrl, + type AddProjectRemoteProviderKind, + type AddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { + EnvironmentId, + SourceControlRepositorySearchInput, + SourceControlRepositorySearchResult, +} from "@t3tools/contracts"; export function resolveAddProjectEnvironment< T extends { @@ -24,3 +36,104 @@ export function resolveAddProjectEnvironment< ) ?? null ); } + +export const REPOSITORY_SEARCH_DEBOUNCE_MS = 200; +export const REPOSITORY_SEARCH_MIN_QUERY_LENGTH = 2; + +/** + * Target for the repository search query, or null to leave it unsubscribed. Null below the minimum + * length keeps short prefixes off the wire, and null while the typed query still differs from the + * debounced one blanks the list rather than leaving a previous owner prefix's matches on screen. + */ +export function buildRepositorySearchTarget(input: { + readonly environmentId: EnvironmentId | null; + readonly provider: AddProjectRemoteProviderKind | null; + readonly query: string; + readonly debouncedQuery: string; +}): { + readonly environmentId: EnvironmentId; + readonly input: SourceControlRepositorySearchInput; +} | null { + const query = input.query.trim(); + if (input.environmentId === null || input.provider === null) return null; + if (query.length < REPOSITORY_SEARCH_MIN_QUERY_LENGTH) return null; + if (query !== input.debouncedQuery.trim()) return null; + return { + environmentId: input.environmentId, + input: { provider: input.provider, query }, + }; +} + +/** A searched row already carries both clone URLs, so selecting one skips the lookup round trip. */ +export function buildSearchedRepositoryDestination(input: { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteProviderKind; + readonly result: SourceControlRepositorySearchResult; +}): { + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteProviderKind; + readonly remoteUrl: string; + readonly repositoryTitle: string; + readonly repositoryName: string; +} { + return { + environmentId: input.environmentId, + source: input.source, + remoteUrl: getDefaultCloneUrl({ + provider: input.source, + url: input.result.url, + sshUrl: input.result.sshUrl, + }), + repositoryTitle: input.result.nameWithOwner, + repositoryName: getCloneDirectoryName(input.result.nameWithOwner), + }; +} + +export interface RepositorySearchGroup { + readonly key: string; + readonly label: string; + readonly results: ReadonlyArray; +} + +/** + * Splits results into the two rendered groups. Ranking, the 20-result cap, and description + * truncation all happen server-side, so group membership is the only client decision left. + */ +export function groupRepositorySearchResults( + results: ReadonlyArray, + source: AddProjectRemoteProviderKind, +): ReadonlyArray { + const owned = results.filter((result) => result.ownedByViewer); + const others = results.filter((result) => !result.ownedByViewer); + const groups: RepositorySearchGroup[] = []; + if (owned.length > 0) { + groups.push({ key: "owned", label: "Your repositories", results: owned }); + } + if (others.length > 0) { + groups.push({ key: "other", label: addProjectRemoteSourceLabel(source), results: others }); + } + return groups; +} + +/** + * The one line rendered under the input when the search has no rows to show. Both empty successes + * stay here rather than becoming errors: a paused rate-limit circuit answers `supported: true` with + * no results and gets the ordinary empty state, while a provider that cannot search answers + * `supported: false` and gets the affordance pointing back at the exact-path input. Loading is a + * string because this screen has no spinner for the list. + */ +export function repositorySearchEmptyState(input: { + readonly source: AddProjectRemoteSource; + readonly supported: boolean; + readonly error: string | null; + readonly isPending: boolean; + readonly canSearch: boolean; +}): string | null { + if (input.source === "url") return null; + if (!input.canSearch) return null; + if (!input.supported || input.error !== null) { + return `Search is unavailable for ${addProjectRemoteSourceLabel(input.source)}. Enter ${addProjectRemoteSourcePathHint(input.source)} and press Enter.`; + } + if (input.isPending) return "Searching repositories…"; + return "No repositories match. Press Enter to look up the exact path."; +} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..3181389e097c 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -47,6 +47,7 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useDebouncedValue } from "../../state/queries"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; @@ -60,7 +61,15 @@ import { useRemoteEnvironmentRuntime, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { + buildRepositorySearchTarget, + buildSearchedRepositoryDestination, + groupRepositorySearchResults, + repositorySearchEmptyState, + resolveAddProjectEnvironment, + REPOSITORY_SEARCH_DEBOUNCE_MS, + REPOSITORY_SEARCH_MIN_QUERY_LENGTH, +} from "./AddProjectScreen.logic"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -644,6 +653,87 @@ function useEnvironmentFromParam( return resolveAddProjectEnvironment(environmentOptions, environmentId); } +function RepositorySearchResults(props: { + readonly environment: EnvironmentOption; + readonly source: AddProjectRemoteSource; + readonly query: string; +}) { + const navigation = useNavigation(); + const iconColor = useThemeColor("--color-icon"); + const provider = addProjectRemoteSourceProvider(props.source); + const debouncedQuery = useDebouncedValue(props.query, REPOSITORY_SEARCH_DEBOUNCE_MS); + const searchTarget = useMemo( + () => + buildRepositorySearchTarget({ + environmentId: props.environment.environmentId, + provider, + query: props.query, + debouncedQuery, + }), + [debouncedQuery, props.environment.environmentId, props.query, provider], + ); + // A null target leaves the query unsubscribed, so a short prefix never reaches the wire and a + // still-settling query renders blank instead of keeping the previous prefix's matches on screen. + const searchState = useEnvironmentQuery( + searchTarget === null ? null : sourceControlEnvironment.repositorySearch(searchTarget), + ); + const canSearch = + provider !== null && props.query.trim().length >= REPOSITORY_SEARCH_MIN_QUERY_LENGTH; + const groups = useMemo( + () => + provider === null + ? [] + : groupRepositorySearchResults(searchState.data?.results ?? [], provider), + [provider, searchState.data?.results], + ); + const emptyStateMessage = repositorySearchEmptyState({ + source: props.source, + supported: searchState.data?.supported ?? true, + error: searchState.error, + isPending: canSearch && (searchTarget === null || searchState.isPending), + canSearch, + }); + + if (provider === null || groups.length === 0) { + return emptyStateMessage === null ? null : ( + {emptyStateMessage} + ); + } + + return ( + <> + {groups.map((group) => ( + + {group.label} + + {group.results.map((result, index) => ( + } + isFirst={index === 0} + onPress={() => { + navigation.dispatch( + StackActions.push( + "AddProjectDestination", + buildSearchedRepositoryDestination({ + environmentId: props.environment.environmentId, + source: provider, + result, + }), + ), + ); + }} + /> + ))} + + + ))} + + ); +} + export function AddProjectRepositoryScreen(props: { readonly environmentId?: string | string[]; readonly source?: string | string[]; @@ -727,6 +817,11 @@ export function AddProjectRepositoryScreen(props: { onPress={() => void lookupRepository()} loading={isSubmitting} /> + ) : ( From 4510fed7d9b2ce2431c880cc70269279c98191f1 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:38:25 +0800 Subject: [PATCH 11/27] docs(user): describe GitHub repository search when adding a project The clone flow documented exact-path entry only. The repository step now searches GitHub by name, so describe that, and say plainly that GitLab, Bitbucket, and Azure DevOps still take an exact path. --- docs/user/source-control.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..2d94c12b879b 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -21,6 +21,16 @@ T3 Code works with the platforms your team already uses: - 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 +**Search GitHub by name** + +- On the repository step, type at least two characters and T3 Code searches GitHub as you type +- **Your repositories** are listed first, then other matches under **GitHub** +- Each row shows the repository name and its description. Pick one to go straight to choosing a destination +- Typing an exact `owner/repo` path and pressing Enter still works, on every provider +- On the web, arrow keys move through the results and Enter picks the highlighted one. With nothing highlighted, Enter looks up the path you typed +- On mobile, tap a result to pick it +- GitLab, Bitbucket, and Azure DevOps cannot be searched yet. They say so on the repository step, and still take an exact path + **Publish local projects to the cloud** - Have a local Git repository without a remote? From a54b8f02f091ee8b671831bfed073303ee4fad71 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:47 +0800 Subject: [PATCH 12/27] fix(server): share one source control discovery service across clients --- apps/server/src/server.ts | 11 +++++++++++ apps/server/src/ws.ts | 34 +++++++--------------------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..171fdc582c70 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -78,6 +78,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -444,6 +445,15 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(VcsProcess.layer), ); +// The websocket route resolves this once and hands the same instance to every +// connection, so provider CLI caches and rate-limit circuits are shared by all +// clients (GitHub's search quota is 30 requests a minute). +const SourceControlDiscoveryLive = SourceControlDiscovery.layer.pipe( + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -464,6 +474,7 @@ export const makeRoutesLayer = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), + Layer.provide(SourceControlDiscoveryLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a61c8ffe002b..fd22d1677d0f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -114,15 +114,6 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; -import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; -import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; -import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; -import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; @@ -2391,6 +2382,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; return HttpRouter.add( "GET", "/ws", @@ -2421,25 +2413,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + // Built once at server lifetime like PullRequestService above: every + // connection shares the same provider CLI caches and rate-limit + // circuits instead of minting fresh ones per client. Layer.provide( - SourceControlDiscovery.layer.pipe( - Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), - ), - ), - ), - Layer.provide(VcsProcess.layer), + Layer.succeed( + SourceControlDiscovery.SourceControlDiscovery, + sourceControlDiscovery, ), ), ), From fa1abdbaf104ea0a2691d05f8a434c8c446da7b8 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:48 +0800 Subject: [PATCH 13/27] fix(server): harden repository search caching and failure handling --- .../src/sourceControl/GitHubCli.test.ts | 92 +++++++++++++++++-- apps/server/src/sourceControl/GitHubCli.ts | 74 ++++++++++++--- .../GitHubSourceControlProvider.test.ts | 23 +++++ .../GitHubSourceControlProvider.ts | 3 +- 4 files changed, 170 insertions(+), 22 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index e15423ec00b4..80eddca42b6d 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,5 +1,7 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -602,18 +604,16 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); - it.effect("fails with a decode error when gh returns unusable search JSON", () => + it.effect("degrades to local matches when gh returns unusable search JSON", () => Effect.gen(function* () { mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not json"))); const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh - .searchRepositories({ cwd: "/repo", query: "codething" }) - .pipe(Effect.flip); + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); - assert.strictEqual(error._tag, "GitHubRepositorySearchDecodeError"); - assert.strictEqual(error.cwd, "/repo"); + expect(mockRun).toHaveBeenCalledTimes(2); + assert.deepStrictEqual(results, []); }).pipe(Effect.provide(layer)), ); it.effect("reuses the owned repository listing for a minute of typing", () => @@ -658,6 +658,54 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(searchLayer)), ); + it.effect("shares one gh request between concurrent identical searches", () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + mockRun.mockImplementation(() => + Deferred.await(release).pipe(Effect.as(processOutput("[]"))), + ); + + const gh = yield* GitHubCli.GitHubCli; + const search = gh.searchRepositories({ cwd: "/repo", query: "codething" }); + const first = yield* Effect.forkChild(search, { startImmediately: true }); + const second = yield* Effect.forkChild(search, { startImmediately: true }); + + // Both callers are in flight, yet only one repo listing has spawned. + expect(mockRun).toHaveBeenCalledTimes(1); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + + it.effect("ages a cached listing from fetch completion, not fetch start", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.sleep("30 seconds").pipe(Effect.as(processOutput("[]"))) + : Effect.succeed(processOutput("[]")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const first = yield* Effect.forkChild( + gh.searchRepositories({ cwd: "/repo", query: "codething" }), + { startImmediately: true }, + ); + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(first); + + // 45 seconds after the listing landed, 75 after it was asked for. A + // listing stamped at fetch start would wrongly count as expired here. + yield* TestClock.adjust("45 seconds"); + yield* gh.searchRepositories({ cwd: "/repo", query: "codethings" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + }).pipe(Effect.provide(searchLayer)), + ); + it.effect("never spends a search request on a two-character query", () => Effect.gen(function* () { mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); @@ -700,6 +748,38 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(searchLayer)), ); + it.effect("keeps local matches when the global search fails", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed( + processOutput(JSON.stringify([ownedRepository("octocat/codething-mvp")])), + ) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["octocat/codething-mvp"], + ); + }).pipe(Effect.provide(searchLayer)), + ); + it.effect("runs no gh command while the GitHub circuit is open", () => Effect.gen(function* () { mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 3b1b7aa1a4ec..a7a092020599 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,5 +1,7 @@ +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; @@ -387,6 +389,9 @@ const SUFFICIENT_LOCAL_MATCHES = 5; /** A fast typist produces one cache entry per keystroke; bound both caches. */ const SEARCH_CACHE_CAPACITY = 64; +/** Joins cwd and query into one cache key. NUL cannot appear in either part. */ +const SEARCH_KEY_SEPARATOR = String.fromCharCode(0); + /** `gh` talks to github.com, and the circuit is keyed by provider and host. */ const GITHUB_RATE_LIMIT_KEY = { provider: "github", host: "github.com" } as const; @@ -416,9 +421,10 @@ function storeCacheEntry( * this file that reaches `gh` argv. Keep it to characters that can appear in an * owner or repository name, drop leading dashes so it can never be read as a * flag, and cap the length. Applied inside the service so callers cannot skip - * it. + * it. Exported so result ranking can normalize with the same rule the search + * actually ran under. */ -function sanitizeSearchQuery(query: string): string { +export function sanitizeSearchQuery(query: string): string { return query .replace(/[^A-Za-z0-9._/-]/g, "") .replace(/^-+/, "") @@ -543,18 +549,19 @@ export const make = Effect.gen(function* () { ), ), ), - Effect.catchTag("SourceControlRateLimitPausedError", () => Effect.succeed(null)), + Effect.catchTags({ SourceControlRateLimitPausedError: () => Effect.succeed(null) }), ); - const searchDecodeError = (cwd: string) => (cause: unknown) => - new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }); + /** Fresh means fetched within the TTL. A clock that stepped backward reads as stale. */ + const isFreshAt = (fetchedAt: number, now: number, ttlMs: number) => + now >= fetchedAt && now - fetchedAt < ttlMs; /** The viewer's repositories, at most one `gh repo list` per minute per cwd. */ - const ownedRepositories = (cwd: string) => + const fetchOwnedRepositories = (cwd: string) => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const cached = (yield* Ref.get(ownedRepositoriesCache)).get(cwd); - if (cached !== undefined && now - cached.fetchedAt < OWNED_REPOSITORIES_TTL_MS) { + if (cached !== undefined && isFreshAt(cached.fetchedAt, now, OWNED_REPOSITORIES_TTL_MS)) { return cached.value; } @@ -572,7 +579,9 @@ export const make = Effect.gen(function* () { }).pipe( Effect.flatMap((output) => decodeRawGitHubOwnedRepositories(output.stdout.trim()).pipe( - Effect.mapError(searchDecodeError(cwd)), + Effect.mapError( + (cause) => new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }), + ), ), ), ), @@ -581,19 +590,23 @@ export const make = Effect.gen(function* () { return cached?.value ?? []; } + // Stamp completion, not start: a slow fetch must not age its own entry. + const fetchedAt = yield* Clock.currentTimeMillis; yield* Ref.update(ownedRepositoriesCache, (current) => - storeCacheEntry(current, cwd, { fetchedAt: now, value: fetched }), + storeCacheEntry(current, cwd, { fetchedAt, value: fetched }), ); return fetched; }); /** Public repositories for one sanitized query, cached for a few keystrokes. */ - const searchedRepositories = (cwd: string, query: string) => + const fetchSearchedRepositories = (key: string) => Effect.gen(function* () { - const key = `${cwd}\u0000${query}`; + const separator = key.indexOf(SEARCH_KEY_SEPARATOR); + const cwd = key.slice(0, separator); + const query = key.slice(separator + 1); const now = yield* Clock.currentTimeMillis; const cached = (yield* Ref.get(searchedRepositoriesCache)).get(key); - if (cached !== undefined && now - cached.fetchedAt < SEARCHED_REPOSITORIES_TTL_MS) { + if (cached !== undefined && isFreshAt(cached.fetchedAt, now, SEARCHED_REPOSITORIES_TTL_MS)) { return cached.value; } @@ -614,7 +627,9 @@ export const make = Effect.gen(function* () { }).pipe( Effect.flatMap((output) => decodeRawGitHubSearchedRepositories(output.stdout.trim()).pipe( - Effect.mapError(searchDecodeError(cwd)), + Effect.mapError( + (cause) => new GitHubRepositorySearchDecodeError({ command: "gh", cwd, cause }), + ), ), ), ), @@ -623,12 +638,33 @@ export const make = Effect.gen(function* () { return cached?.value ?? []; } + // Stamp completion, not start: a slow fetch must not age its own entry. + const fetchedAt = yield* Clock.currentTimeMillis; yield* Ref.update(searchedRepositoriesCache, (current) => - storeCacheEntry(current, key, { fetchedAt: now, value: fetched }), + storeCacheEntry(current, key, { fetchedAt, value: fetched }), ); return fetched; }); + /** + * Zero time-to-live makes these caches pure in-flight shares: concurrent + * callers for one key await the same lookup, and the entry is dropped the + * moment it settles. Values live in the Ref caches above, which also answer + * with stale rows while the circuit is paused. + */ + const ownedRepositoriesInFlight = yield* Cache.makeWith(fetchOwnedRepositories, { + capacity: SEARCH_CACHE_CAPACITY, + timeToLive: () => Duration.zero, + }); + const ownedRepositories = (cwd: string) => Cache.get(ownedRepositoriesInFlight, cwd); + + const searchedRepositoriesInFlight = yield* Cache.makeWith(fetchSearchedRepositories, { + capacity: SEARCH_CACHE_CAPACITY, + timeToLive: () => Duration.zero, + }); + const searchedRepositories = (cwd: string, query: string) => + Cache.get(searchedRepositoriesInFlight, cwd + SEARCH_KEY_SEPARATOR + query); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => @@ -736,10 +772,18 @@ export const make = Effect.gen(function* () { raw.nameWithOwner.toLowerCase().includes(needle), ); + // A failed global search degrades to the local rows instead of failing + // the whole RPC, the same answer the paused circuit already gives. const searched = query.length >= MIN_GLOBAL_SEARCH_QUERY_LENGTH && localMatches.length < SUFFICIENT_LOCAL_MATCHES - ? yield* searchedRepositories(input.cwd, query) + ? yield* searchedRepositories(input.cwd, query).pipe( + Effect.catch((error) => + Effect.logWarning("GitHub repository search failed; keeping local matches", { + error, + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ) : []; // Owned repositories come first, and one the viewer owns is never diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 4b27243a2e69..adbb71004b9b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -284,6 +284,29 @@ it.effect("ranks, caps, and trims the repository search results it returns", () }), ); +it.effect("ranks a spaced query the way the search actually ran it", () => + Effect.gen(function* () { + // "t3 code" reaches gh as "t3code" after sanitizing, so ranking must use + // the sanitized form too: the exact-name match beats the popular substring. + const exact = searchResult({ nameWithOwner: "pingdotgg/t3code", starCount: 10 }); + const popularSubstring = searchResult({ + nameWithOwner: "acme/uses-t3code-inside", + starCount: 5000, + }); + const provider = yield* makeProvider({ + searchRepositories: () => Effect.succeed([popularSubstring, exact]), + }); + + const output = yield* provider.searchRepositories({ cwd: "/repo", query: "t3 code" }); + + assert.strictEqual(output.supported, true); + assert.deepStrictEqual( + output.results.map((result) => result.nameWithOwner), + ["pingdotgg/t3code", "acme/uses-t3code-inside"], + ); + }), +); + it.effect("redacts search queries in provider errors while keeping the CLI cause", () => Effect.gen(function* () { const cause = new GitHubCli.GitHubRepositorySearchDecodeError({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 62dc1cb08f3d..ffb524c40cf9 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -100,8 +100,9 @@ function rankSearchResults( results: ReadonlyArray, query: string, ): ReadonlyArray { + // Rank against the query as it actually searched: "t3 code" matched as "t3code". return [...results] - .sort(compareSearchResults(query.trim().toLowerCase())) + .sort(compareSearchResults(GitHubCli.sanitizeSearchQuery(query).toLowerCase())) .slice(0, MAX_SEARCH_RESULTS) .map(trimSearchResultDescription); } From 502a82236a886b1a20d1a38f7781f62736a42af4 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:49 +0800 Subject: [PATCH 14/27] fix(app): keep repository search support state between keystrokes --- .../features/projects/AddProjectScreen.tsx | 20 ++- apps/web/src/state/queries.ts | 24 +++- .../src/state/sourceControl.test.ts | 121 +++++++++++++++++- .../client-runtime/src/state/sourceControl.ts | 42 ++++++ 4 files changed, 200 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 3181389e097c..50b4fc0358f8 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -31,6 +31,10 @@ import { inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; +import { + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "@t3tools/client-runtime/state/source-control"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; @@ -686,10 +690,22 @@ function RepositorySearchResults(props: { : groupRepositorySearchResults(searchState.data?.results ?? [], provider), [provider, searchState.data?.results], ); + // Sticky per environment+provider, so a provider already known unsupported keeps its exact-path + // affordance instead of flashing "Searching repositories…" on every keystroke's fresh atom. + const answerMemoryRef = useRef | null>(null); + answerMemoryRef.current ??= new Map(); + const answer = resolveRepositorySearchAnswer({ + memory: answerMemoryRef.current, + environmentId: props.environment.environmentId, + provider, + canSearch, + data: searchState.data, + error: searchState.error, + }); const emptyStateMessage = repositorySearchEmptyState({ source: props.source, - supported: searchState.data?.supported ?? true, - error: searchState.error, + supported: answer.supported, + error: answer.error, isPending: canSearch && (searchTarget === null || searchState.isPending), canSearch, }); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2fe8138c24ab..4e420b46def8 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -8,6 +8,10 @@ import { makeThreadSearchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "@t3tools/client-runtime/state/source-control"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -23,7 +27,7 @@ import type { import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; @@ -129,13 +133,17 @@ export interface RepositorySearchView { /** * Debounced repository search for the add-project flow. Results blank while the query settles, * because a previous owner prefix's matches actively mislead, and each settled query subscribes to - * its own atom so a late response cannot overwrite a newer one. + * its own atom so a late response cannot overwrite a newer one. `supported` and `error` stay sticky + * per environment+provider across keystrokes, so a provider already known unsupported keeps its + * exact-path affordance instead of flashing the searching state on every character. */ export function useRepositorySearch(target: RepositorySearchTarget): RepositorySearchView { const normalizedQuery = target.query.trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, REPOSITORY_SEARCH_DEBOUNCE_MS); const environmentId = target.environmentId; const provider = target.provider; + const answerMemoryRef = useRef | null>(null); + answerMemoryRef.current ??= new Map(); const canSearch = environmentId !== null && provider !== null && @@ -150,13 +158,21 @@ export function useRepositorySearch(target: RepositorySearchTarget): RepositoryS input: { provider, query: settledQuery }, }), ); + const answer = resolveRepositorySearchAnswer({ + memory: answerMemoryRef.current, + environmentId, + provider, + canSearch, + data: result.data, + error: result.error, + }); return { results: isDebouncing ? EMPTY_REPOSITORY_SEARCH_RESULTS : (result.data?.results ?? EMPTY_REPOSITORY_SEARCH_RESULTS), - supported: result.data?.supported ?? true, - error: result.error, + supported: answer.supported, + error: answer.error, isPending: canSearch && (isDebouncing || result.isPending), canSearch, }; diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e3227d..81fe6569310f 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -22,7 +22,11 @@ import * as Persistence from "../platform/persistence.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; -import { createSourceControlEnvironmentAtoms } from "./sourceControl.ts"; +import { + createSourceControlEnvironmentAtoms, + resolveRepositorySearchAnswer, + type RepositorySearchAnswer, +} from "./sourceControl.ts"; import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -56,6 +60,121 @@ function session(client: WsRpcProtocolClient): RpcSession { }; } +describe("resolveRepositorySearchAnswer", () => { + const environmentId = EnvironmentId.make("environment-1"); + const pending = { canSearch: true, data: null, error: null } as const; + + it("defaults to supported with no error before any answer lands", () => { + expect( + resolveRepositorySearchAnswer({ + memory: new Map(), + environmentId, + provider: "github", + ...pending, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("keeps an unsupported provider unsupported across a new keystroke's pending query", () => { + const memory = new Map(); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: true, + data: { supported: false }, + error: null, + }), + ).toEqual({ supported: false, error: null }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "gitlab", ...pending }), + ).toEqual({ supported: false, error: null }); + }); + + it("scopes the memory to the environment and provider", () => { + const memory = new Map(); + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: true, + data: { supported: false }, + error: null, + }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: null }); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId: EnvironmentId.make("environment-2"), + provider: "gitlab", + ...pending, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("carries a failure across the next pending query until a settled success replaces it", () => { + const memory = new Map(); + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "github", + canSearch: true, + data: null, + error: "gh exited 1", + }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: "gh exited 1" }); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "github", + canSearch: true, + data: { supported: true }, + error: null, + }), + ).toEqual({ supported: true, error: null }); + expect( + resolveRepositorySearchAnswer({ memory, environmentId, provider: "github", ...pending }), + ).toEqual({ supported: true, error: null }); + }); + + it("ignores the memory while the query is too short to search", () => { + const memory = new Map([ + [`${environmentId}:gitlab`, { supported: false, error: null }], + ]); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId, + provider: "gitlab", + canSearch: false, + data: null, + error: null, + }), + ).toEqual({ supported: true, error: null }); + }); + + it("records nothing without an environment and provider", () => { + const memory = new Map(); + expect( + resolveRepositorySearchAnswer({ + memory, + environmentId: null, + provider: null, + canSearch: false, + data: null, + error: null, + }), + ).toEqual({ supported: true, error: null }); + expect(memory.size).toBe(0); + }); +}); + describe("source control environment atoms", () => { it.effect("invalidates cached refs after successful and failed publishing", () => Effect.scoped( diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index 90abb4668bf7..118a012770bc 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -1,5 +1,7 @@ import { WS_METHODS, + type EnvironmentId, + type SourceControlProviderKind, type SourceControlRepositorySearchInput, type SourceControlRepositorySearchOutput, } from "@t3tools/contracts"; @@ -55,6 +57,46 @@ export function createSourceControlEnvironmentAtoms( }; } +export interface RepositorySearchAnswer { + readonly supported: boolean; + readonly error: string | null; +} + +const DEFAULT_REPOSITORY_SEARCH_ANSWER: RepositorySearchAnswer = { supported: true, error: null }; + +/** + * Sticky `supported`/`error` for the repository search views. Each settled query subscribes to its + * own atom, so every keystroke starts a pending atom that knows nothing; without memory the UI + * flashes the searching state and rediscovers `supported: false` on every character. Search support + * is static per provider, so the last settled answer for an environment+provider stands in while + * the next query is pending, and the next settled answer (including a recovery from a transient + * error) replaces it. Callers own `memory` and scope it to the mounted view. + */ +export function resolveRepositorySearchAnswer(input: { + readonly memory: Map; + readonly environmentId: EnvironmentId | null; + readonly provider: SourceControlProviderKind | null; + /** False when the query is too short to search; remembered answers do not apply there. */ + readonly canSearch: boolean; + readonly data: { readonly supported: boolean } | null; + readonly error: string | null; +}): RepositorySearchAnswer { + const key = + input.environmentId !== null && input.provider !== null + ? `${input.environmentId}:${input.provider}` + : null; + const settled = + input.data !== null || input.error !== null + ? { supported: input.data?.supported ?? true, error: input.error } + : null; + if (settled !== null) { + if (key !== null) input.memory.set(key, settled); + return settled; + } + if (!input.canSearch || key === null) return DEFAULT_REPOSITORY_SEARCH_ANSWER; + return input.memory.get(key) ?? DEFAULT_REPOSITORY_SEARCH_ANSWER; +} + type AssertTrue = T; type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false; From b83a2788bd9ec9d88a0b28426e148f914448f0e6 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:50 +0800 Subject: [PATCH 15/27] fix(web): reserve input room for the wider highlighted shortcut --- apps/web/src/components/CommandPalette.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aac873c36bca..f0a35b7274de 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2496,7 +2496,10 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" + className={cn( + "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", + hasHighlightedRepositoryItem ? "gap-1" : "gap-1.5", + )} aria-label={`${remoteProjectButtonLabel ?? "Continue"} (${remoteProjectShortcutLabel})`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { @@ -2597,7 +2600,9 @@ function OpenCommandPaletteDialog(props: { // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "*:data-[slot=autocomplete-input]:pe-32!" + ? hasHighlightedRepositoryItem + ? "*:data-[slot=autocomplete-input]:pe-38!" + : "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing ? browseInputEndPaddingClass({ willCreateProjectPath, From 276c540cd7c85d5953984a15e07bb62c9855434d Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:20:50 +0800 Subject: [PATCH 16/27] docs(user): mark repository descriptions as optional --- docs/user/source-control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2d94c12b879b..0233502165a5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -25,7 +25,7 @@ T3 Code works with the platforms your team already uses: - On the repository step, type at least two characters and T3 Code searches GitHub as you type - **Your repositories** are listed first, then other matches under **GitHub** -- Each row shows the repository name and its description. Pick one to go straight to choosing a destination +- Each row shows the repository name and, when available, its description. Pick one to go straight to choosing a destination - Typing an exact `owner/repo` path and pressing Enter still works, on every provider - On the web, arrow keys move through the results and Enter picks the highlighted one. With nothing highlighted, Enter looks up the path you typed - On mobile, tap a result to pick it From d78742375af78afe3a425150e5da0263b83ac198 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:37:06 +0800 Subject: [PATCH 17/27] fix(web): keep repository search descriptions to one line --- apps/web/src/components/CommandPalette.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f0a35b7274de..0f955b8f95c7 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2177,7 +2177,9 @@ function OpenCommandPaletteDialog(props: { value: repositoryResultItemValue(repositorySearchFlow.environmentId, result.nameWithOwner), searchTerms: [result.nameWithOwner], title: result.nameWithOwner, - ...(result.description ? { description: result.description } : {}), + ...(result.description + ? { description: {result.description} } + : {}), icon: remoteProjectSourceIcon(repositorySearchFlow.source, ITEM_ICON_CLASS), keepOpen: true, run: async () => { From 7afc2d760846e5859edc6820fd0e1078fc802947 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:37:13 +0800 Subject: [PATCH 18/27] fix(app): drop stale repository lookups once the flow moves on --- .../src/features/projects/AddProjectScreen.tsx | 6 ++++++ apps/web/src/components/CommandPalette.tsx | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 50b4fc0358f8..3fff7d72c251 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -791,6 +791,12 @@ export function AddProjectRepositoryScreen(props: { repository: repositoryInput.trim(), }, }); + // Tapping a search result while the lookup is pending pushes the destination screen for that + // result; a stale lookup response must not push a second one on top of it. + if (!navigation.isFocused()) { + setIsSubmitting(false); + return; + } if (AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } else { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0f955b8f95c7..0b4c25b28027 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -695,6 +695,12 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + // Mirrors addProjectCloneFlow so the exact-path lookup continuation can tell whether the flow + // moved on (a search result was selected, or the flow was cancelled) while it was in flight. + const addProjectCloneFlowRef = useRef(null); + useEffect(() => { + addProjectCloneFlowRef.current = addProjectCloneFlow; + }, [addProjectCloneFlow]); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const projectGroupingSettings = useMemo( @@ -2000,6 +2006,13 @@ function OpenCommandPaletteDialog(props: { }, }); setIsRemoteProjectLookingUp(false); + // Every flow transition replaces the flow object, so a mismatch means a search result was + // selected or the flow was cancelled while the lookup was pending. Drop the stale response + // instead of clobbering the newer state (same commit-only-if-current shape as + // createBrowseNavigationCoordinator). + if (addProjectCloneFlowRef.current !== addProjectCloneFlow) { + return; + } if (lookupResult._tag === "Failure") { if (!isAtomCommandInterrupted(lookupResult)) { toastManager.add( From 34ceacfc1dbaf5976a85969ff087ff9a2668459b Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:31:07 +0800 Subject: [PATCH 19/27] fix(server): give repository search its own rate-limit circuit Effect memoizes layers by reference, so the search layer, the pull-request service, and source control discovery were all built over one shared SourceControlRateLimit instance, keyed by the same provider and host. One gh search rate limit paused PR listing for its whole cooldown, and a PR rate limit silently blanked search. Layer.fresh gives the search layer the independent circuit its comment already promised. Also mark a searched repository as the viewer's own by comparing the owner segment of its name against the viewer's login taken from the owned listing, instead of membership in that listing, which gh caps at 100 rows. And treat an empty description from gh as absent so it is not forwarded over the wire. --- .../src/sourceControl/GitHubCli.test.ts | 93 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 32 +++++-- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 80eddca42b6d..a9785ca4e86b 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -41,6 +41,17 @@ const searchLayer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( Layer.provideMerge(SourceControlRateLimit.layer), ); +/** + * The real `GitHubCli.layer` built alongside an outer `SourceControlRateLimit`, + * the way `server.ts` builds it next to the pull-request subsystem's. Both sides + * reference the same module-level layer, so without `Layer.fresh` inside + * `GitHubCli.layer` Effect would memoize them into one shared circuit. + */ +const sharedCircuitLayer = GitHubCli.layer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provideMerge(SourceControlRateLimit.layer), +); + /** The gh subcommand of every spawn so far, in order. */ const spawnedSubcommands = () => mockRun.mock.calls.map((call) => call[0].args.slice(0, 2).join(" ")); @@ -824,4 +835,86 @@ describe("GitHubCli.layer", () => { assert.strictEqual(paused._tag, "SourceControlRateLimitPausedError"); }).pipe(Effect.provide(searchLayer)), ); + + it.effect("keeps searching while another subsystem's github circuit is paused", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: "github" as const, host: "github.com" }; + const lease = yield* limits.check(key); + yield* limits.recordRateLimit({ ...key, lease }); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, []); + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos"]); + }).pipe(Effect.provide(sharedCircuitLayer)), + ); + + it.effect("marks a searched repository the capped listing missed as the viewer's own", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed(processOutput(JSON.stringify([ownedRepository("octocat/unrelated")]))) + : Effect.succeed( + processOutput( + JSON.stringify([ + { + fullName: "Octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + }, + { + fullName: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual( + results.map((result) => [result.nameWithOwner, result.ownedByViewer]), + [ + ["Octocat/codething-mvp", true], + ["acme/codething-tools", false], + ], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits an empty description instead of forwarding it", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + input.args[0] === "repo" + ? Effect.succeed(processOutput("[]")) + : Effect.succeed( + processOutput( + JSON.stringify([ + { + fullName: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + description: "", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(results, [ + { + nameWithOwner: "acme/codething-tools", + url: "https://github.com/acme/codething-tools", + sshUrl: "git@github.com:acme/codething-tools.git", + ownedByViewer: false, + }, + ]); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index a7a092020599..766b0c987af1 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -476,8 +476,15 @@ function deriveSshUrl(nameWithOwner: string, url: string): string { } } +/** gh reports a missing description as `null` or `""`; both mean absent. */ function optionalDescription(value: string | null | undefined) { - return value !== undefined && value !== null ? { description: value } : {}; + return value !== undefined && value !== null && value !== "" ? { description: value } : {}; +} + +/** The owner segment of `owner/name`, lowercased the way GitHub compares logins. */ +function repositoryOwner(nameWithOwner: string): string { + const separator = nameWithOwner.indexOf("/"); + return (separator === -1 ? nameWithOwner : nameWithOwner.slice(0, separator)).toLowerCase(); } function normalizeOwnedRepository(raw: RawOwnedRepository): SourceControlRepositorySearchResult { @@ -495,12 +502,13 @@ function normalizeOwnedRepository(raw: RawOwnedRepository): SourceControlReposit function normalizeSearchedRepository( raw: RawSearchedRepository, + viewerLogin: string | undefined, ): SourceControlRepositorySearchResult { return { nameWithOwner: raw.fullName, url: raw.url, sshUrl: deriveSshUrl(raw.fullName, raw.url), - ownedByViewer: false, + ownedByViewer: viewerLogin !== undefined && repositoryOwner(raw.fullName) === viewerLogin, ...optionalDescription(raw.description), ...(raw.stargazersCount !== undefined ? { starCount: raw.stargazersCount } : {}), ...(raw.isFork !== undefined ? { isFork: raw.isFork } : {}), @@ -767,11 +775,20 @@ export const make = Effect.gen(function* () { // `gh repo list` takes no query, so the viewer's repositories are matched // here against the cached listing. That is the common keystroke: no spawn. + const owned = yield* ownedRepositories(input.cwd); const needle = query.toLowerCase(); - const localMatches = (yield* ownedRepositories(input.cwd)).filter((raw) => + const localMatches = owned.filter((raw) => raw.nameWithOwner.toLowerCase().includes(needle), ); + // `gh repo list` lists repositories under the viewer's own login, so any + // row names the viewer. The listing is capped at 100 rows, which makes + // membership in it under-report ownership; comparing owner segments does + // not, so a searched repository the capped listing missed still lands + // under the viewer's own group. + const viewerLogin = + owned[0] !== undefined ? repositoryOwner(owned[0].nameWithOwner) : undefined; + // A failed global search degrades to the local rows instead of failing // the whole RPC, the same answer the paused circuit already gives. const searched = @@ -796,7 +813,7 @@ export const make = Effect.gen(function* () { continue; } seen.add(raw.fullName); - results.push(normalizeSearchedRepository(raw)); + results.push(normalizeSearchedRepository(raw, viewerLogin)); } return results; @@ -848,8 +865,11 @@ export const make = Effect.gen(function* () { * The circuit breaker is private to this layer. GitHub bills the search endpoint * (30 requests a minute) separately from the core quota the pull-request * subsystem tracks with its own `SourceControlRateLimit`, so the two circuits are - * deliberately independent rather than one pausing the other. + * deliberately independent rather than one pausing the other. `Layer.fresh` is + * what makes that true: the pull-request subsystem provides the same module-level + * `SourceControlRateLimit.layer`, and without it Effect would memoize both into + * one shared instance keyed identically by provider and host. */ export const layer = Layer.effect(GitHubCli, make).pipe( - Layer.provide(SourceControlRateLimit.layer), + Layer.provide(Layer.fresh(SourceControlRateLimit.layer)), ); From 116c45bf0abb1e379a5f859f5650524e7604e333 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:44:45 +0800 Subject: [PATCH 20/27] fix(app): close timing holes in the stale repository lookup guards --- .../src/features/projects/AddProjectScreen.tsx | 18 +++++++++++++++--- apps/web/src/components/CommandPalette.tsx | 18 +++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 3fff7d72c251..f6415c296c1a 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -661,6 +661,8 @@ function RepositorySearchResults(props: { readonly environment: EnvironmentOption; readonly source: AddProjectRemoteSource; readonly query: string; + /** Called before navigating so the parent can invalidate a pending exact-path lookup. */ + readonly onSelectResult: () => void; }) { const navigation = useNavigation(); const iconColor = useThemeColor("--color-icon"); @@ -730,6 +732,7 @@ function RepositorySearchResults(props: { icon={} isFirst={index === 0} onPress={() => { + props.onSelectResult(); navigation.dispatch( StackActions.push( "AddProjectDestination", @@ -763,6 +766,10 @@ export function AddProjectRepositoryScreen(props: { const [repositoryInput, setRepositoryInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + // Bumped whenever a competing action supersedes a pending exact-path lookup (selecting a search + // result). The continuation bails when its captured value is stale. isFocused alone is not + // enough: popping the pushed destination re-focuses this screen while the lookup still settles. + const lookupGenerationRef = useRef(0); const lookupRepository = useCallback(async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; @@ -784,6 +791,7 @@ export function AddProjectRepositoryScreen(props: { return; } + const generation = ++lookupGenerationRef.current; const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { @@ -791,9 +799,10 @@ export function AddProjectRepositoryScreen(props: { repository: repositoryInput.trim(), }, }); - // Tapping a search result while the lookup is pending pushes the destination screen for that - // result; a stale lookup response must not push a second one on top of it. - if (!navigation.isFocused()) { + // A stale lookup must not push a destination the user did not ask for: the generation catches + // a search result selected while it was pending (even after popping back here), and the focus + // check catches this screen no longer being current (popped, or something else on top). + if (generation !== lookupGenerationRef.current || !navigation.isFocused()) { setIsSubmitting(false); return; } @@ -843,6 +852,9 @@ export function AddProjectRepositoryScreen(props: { environment={environment} source={source} query={repositoryInput} + onSelectResult={() => { + lookupGenerationRef.current += 1; + }} /> ) : ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0b4c25b28027..e9ba13eae5e8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -694,13 +694,18 @@ function OpenCommandPaletteDialog(props: { null, ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); - const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const [addProjectCloneFlow, setAddProjectCloneFlowState] = useState( + null, + ); // Mirrors addProjectCloneFlow so the exact-path lookup continuation can tell whether the flow // moved on (a search result was selected, or the flow was cancelled) while it was in flight. + // Written synchronously by the wrapper setter: an effect-synced mirror leaves a window between + // a transition's commit and its effects where a settling lookup still compares equal. const addProjectCloneFlowRef = useRef(null); - useEffect(() => { - addProjectCloneFlowRef.current = addProjectCloneFlow; - }, [addProjectCloneFlow]); + const setAddProjectCloneFlow = useCallback((flow: AddProjectCloneFlow | null): void => { + addProjectCloneFlowRef.current = flow; + setAddProjectCloneFlowState(flow); + }, []); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const projectGroupingSettings = useMemo( @@ -1296,6 +1301,7 @@ function OpenCommandPaletteDialog(props: { getBrowseCwdForEnvironment, prefetchBrowsePath, pushPaletteView, + setAddProjectCloneFlow, ], ); @@ -1309,7 +1315,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [pushPaletteView], + [pushPaletteView, setAddProjectCloneFlow], ); const openSourceControlSettings = useCallback(() => { @@ -1443,6 +1449,7 @@ function OpenCommandPaletteDialog(props: { buildAddProjectSourceGroups, environments, pushPaletteView, + setAddProjectCloneFlow, sourceControlDiscovery.data, ], ); @@ -1553,6 +1560,7 @@ function OpenCommandPaletteDialog(props: { openIntent, projectThreadItems, pushPaletteView, + setAddProjectCloneFlow, ]); const actionItems: Array = []; From b9251617d882c1ccc029e3dc5028ec6935756140 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:30:21 +0800 Subject: [PATCH 21/27] fix(server): serve stale repository search results when a refresh fails A failed refresh after the 30 second search TTL answered with no public rows for one keystroke, then the open circuit brought the cached rows back. The failure path now serves the stale cached rows, falling back to local matches only when nothing is cached. --- .../src/sourceControl/GitHubCli.test.ts | 33 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 16 ++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index a9785ca4e86b..273d510b6c03 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -791,6 +791,39 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(searchLayer)), ); + it.effect("serves the stale cached search when a refresh fails", () => + Effect.gen(function* () { + let searchCalls = 0; + mockRun.mockImplementation((input) => { + if (input.args[0] === "repo") { + return Effect.succeed(processOutput("[]")); + } + searchCalls += 1; + return Effect.succeed( + processOutput( + searchCalls === 1 + ? `[{ "fullName": "acme/codething-tools", "url": "https://github.com/acme/codething-tools" }]` + : "not json", + ), + ); + }); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + // The cached search rows are 31 seconds old, past their 30 second TTL, + // and the refresh comes back unusable. The stale rows still answer. + yield* TestClock.adjust("31 seconds"); + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual(spawnedSubcommands(), ["repo list", "search repos", "search repos"]); + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["acme/codething-tools"], + ); + }).pipe(Effect.provide(searchLayer)), + ); + it.effect("runs no gh command while the GitHub circuit is open", () => Effect.gen(function* () { mockRun.mockReturnValue(Effect.succeed(processOutput("[]"))); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 766b0c987af1..a81f2afe6bf2 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -789,16 +789,24 @@ export const make = Effect.gen(function* () { const viewerLogin = owned[0] !== undefined ? repositoryOwner(owned[0].nameWithOwner) : undefined; - // A failed global search degrades to the local rows instead of failing - // the whole RPC, the same answer the paused circuit already gives. + // A failed global search degrades to the stale cached rows for this + // query, the same answer the paused circuit already gives, and to the + // local rows alone when nothing is cached. const searched = query.length >= MIN_GLOBAL_SEARCH_QUERY_LENGTH && localMatches.length < SUFFICIENT_LOCAL_MATCHES ? yield* searchedRepositories(input.cwd, query).pipe( Effect.catch((error) => - Effect.logWarning("GitHub repository search failed; keeping local matches", { + Effect.logWarning("GitHub repository search failed; serving cached matches", { error, - }).pipe(Effect.as([] as ReadonlyArray)), + }).pipe( + Effect.flatMap(() => Ref.get(searchedRepositoriesCache)), + Effect.map( + (cache) => + cache.get(input.cwd + SEARCH_KEY_SEPARATOR + query)?.value ?? + ([] as ReadonlyArray), + ), + ), ), ) : []; From 6bd4dadea2cfc2f6a52b44777063108b396c7902 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:09:37 +0800 Subject: [PATCH 22/27] refactor(server): move discovery layer wiring below the transport layers --- apps/server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7bdce5082ada..77be3c1675f9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -483,9 +483,9 @@ export const makeRoutesLayer = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), - Layer.provide(SourceControlDiscoveryLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), + Layer.provide(SourceControlDiscoveryLive), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), From 7f0b18e4461761a415169306f97b819e4e303654 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:01:05 +0800 Subject: [PATCH 23/27] fix(server): tell the user when a repository lookup finds nothing A typo in the add-project GitHub path surfaced as 'The source control operation could not be completed.' because gh's repository resolution failure was classified as a generic command failure and the service replaced every provider detail with that sentence. gh stderr saying 'could not resolve to a repository' now classifies as repository-not-found, GitHubCli maps it to a dedicated error, and the provider opts curated constant details into the client-facing message through a new userDetail field. Free-text provider detail still never reaches the wire. --- .../src/sourceControl/GitHubCli.test.ts | 36 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 17 +++++++++ .../GitHubSourceControlProvider.ts | 3 ++ apps/server/src/sourceControl/GitLabCli.ts | 1 + .../SourceControlRepositoryService.test.ts | 35 ++++++++++++++++++ .../SourceControlRepositoryService.ts | 9 ++++- apps/server/src/vcs/VcsProcess.test.ts | 23 ++++++++++++ apps/server/src/vcs/VcsProcess.ts | 6 +++- packages/contracts/src/sourceControl.ts | 7 ++++ packages/contracts/src/vcs.ts | 17 +++++---- 10 files changed, 145 insertions(+), 9 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 273d510b6c03..ce67bc4f375c 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -328,6 +328,42 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("maps a repository resolution failure to GitHubRepositoryNotFoundError", () => + Effect.gen(function* () { + const exitError = VcsProcessExitError.fromProcessExit( + { + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + argumentCount: 5, + }, + { + exitCode: 1, + stderr: + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + stderrTruncated: false, + }, + "repository-not-found", + ); + assert.strictEqual(exitError.detail, "Repository not found."); + mockRun.mockReturnValueOnce(Effect.fail(exitError)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* Effect.flip( + gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/nope", + }), + ); + + assert.strictEqual(error._tag, "GitHubRepositoryNotFoundError"); + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + }).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 a81f2afe6bf2..d7f88fdb1526 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -84,6 +84,19 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubRepositoryNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Repository not found. Check the owner/repo path and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubCliCommandError extends Schema.TaggedErrorClass()( "GitHubCliCommandError", gitHubCliFailureFields, @@ -173,6 +186,7 @@ export const GitHubCliError = Schema.Union([ GitHubCliAuthenticationError, GitHubCliRateLimitError, GitHubPullRequestNotFoundError, + GitHubRepositoryNotFoundError, GitHubCliCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, @@ -211,6 +225,9 @@ export function fromVcsError( if (error.failureKind === "not-found") { return new GitHubPullRequestNotFoundError({ ...context, cause: error }); } + if (error.failureKind === "repository-not-found") { + return new GitHubRepositoryNotFoundError({ ...context, cause: error }); + } } return new GitHubCliCommandError({ ...context, cause: error }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index ffb524c40cf9..36398e8b1bfe 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -318,6 +318,9 @@ export const make = Effect.gen(function* () { input.repository, ), detail: error.detail, + // GitHubCliError details are compile-time constants, so they are + // safe to show the user in place of the generic fallback. + userDetail: error.detail, cause: error, }), ), diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247..0c5cce099a88 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -142,6 +142,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/nope", + detail: "gh stderr that stays server-side", + userDetail: "Repository not found. Check the owner/repo path and try again.", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/nope", + cwd: "/workspace", + }), + ); + + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 400e6ccb9a81..62755dfe4c87 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + SourceControlProviderError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -25,6 +26,7 @@ import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +const isSourceControlProviderError = Schema.is(SourceControlProviderError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -51,7 +53,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi : new SourceControlRepositoryError({ operation, provider, - detail: "The source control operation could not be completed.", + // Provider `detail` may quote provider output, so only the curated + // `userDetail` opt-in reaches the client; everything else stays + // behind the generic sentence. + detail: + (isSourceControlProviderError(cause) ? cause.userDetail : undefined) ?? + "The source control operation could not be completed.", cause, }), ); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce2..6337c274c6b4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -315,3 +315,26 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); }); + +describe("classifyNonZeroExit", () => { + it("classifies a gh repository resolution failure as repository-not-found", () => { + expect( + VcsProcess.classifyNonZeroExit( + "gh", + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + ), + ).toBe("repository-not-found"); + }); + + it("keeps gh pull request resolution failures as not-found", () => { + expect( + VcsProcess.classifyNonZeroExit("gh", "GraphQL: Could not resolve to a PullRequest."), + ).toBe("not-found"); + }); + + it("does not classify repository resolution failures for other commands", () => { + expect(VcsProcess.classifyNonZeroExit("git", "could not resolve to a repository")).toBe( + "command-failed", + ); + }); +}); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13604..d52ad160dbe9 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -53,7 +53,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +export const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( @@ -79,6 +79,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } + if (command === "gh" && normalized.includes("could not resolve to a repository")) { + return "repository-not-found"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index b30857e7c6e1..d87d664625ce 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -187,6 +187,13 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass Date: Thu, 27 Aug 2026 16:33:00 +0800 Subject: [PATCH 24/27] fix(web): keep Enter on a bare search term with the search Enter with no highlighted row always ran the exact-path lookup, so a search term like "effect" errored with "Repository not found" while its results were still loading. A bare term now stays with the search; path-shaped input, sources without a live search, and the primary modifier still look up. --- .../web/src/components/CommandPalette.test.ts | 61 ++++++++++++++++++- apps/web/src/components/CommandPalette.tsx | 46 ++++++++++---- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/CommandPalette.test.ts b/apps/web/src/components/CommandPalette.test.ts index 1863c058a4b5..eadce56b6484 100644 --- a/apps/web/src/components/CommandPalette.test.ts +++ b/apps/web/src/components/CommandPalette.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + looksLikeRepositoryPath, repositoryResultItemValue, repositoryStepEmptyState, repositoryStepEnterAction, @@ -10,23 +11,31 @@ import { const environmentId = EnvironmentId.make("environment-a"); describe("repositoryStepEnterAction", () => { + const pathQuery = { queryIsRepositoryPath: true, searchCanAnswer: true } as const; + it("selects the highlighted repository instead of looking up the raw text", () => { expect( repositoryStepEnterAction({ highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), hasPrimaryModifier: false, + ...pathQuery, }), ).toBe("select-highlighted-repository"); }); it("looks up the typed path when no repository row is highlighted", () => { expect( - repositoryStepEnterAction({ highlightedItemValue: null, hasPrimaryModifier: false }), + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + ...pathQuery, + }), ).toBe("lookup-typed-repository"); expect( repositoryStepEnterAction({ highlightedItemValue: "browse:/home/user/projects", hasPrimaryModifier: false, + ...pathQuery, }), ).toBe("lookup-typed-repository"); }); @@ -36,9 +45,55 @@ describe("repositoryStepEnterAction", () => { repositoryStepEnterAction({ highlightedItemValue: repositoryResultItemValue(environmentId, "t3dotgg/t3code"), hasPrimaryModifier: true, + ...pathQuery, }), ).toBe("lookup-typed-repository"); }); + + it("leaves a bare search term with the search instead of erroring through the lookup", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + queryIsRepositoryPath: false, + searchCanAnswer: true, + }), + ).toBe("continue-search"); + }); + + it("looks up a bare term when the search cannot answer", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: false, + queryIsRepositoryPath: false, + searchCanAnswer: false, + }), + ).toBe("lookup-typed-repository"); + }); + + it("forces the lookup of a bare term with the primary modifier", () => { + expect( + repositoryStepEnterAction({ + highlightedItemValue: null, + hasPrimaryModifier: true, + queryIsRepositoryPath: false, + searchCanAnswer: true, + }), + ).toBe("lookup-typed-repository"); + }); +}); + +describe("looksLikeRepositoryPath", () => { + it("treats any slash as a repository path, covering nested groups and clone URLs", () => { + expect(looksLikeRepositoryPath("t3dotgg/t3code")).toBe(true); + expect(looksLikeRepositoryPath("group/subgroup/project")).toBe(true); + expect(looksLikeRepositoryPath("https://github.com/t3dotgg/t3code.git")).toBe(true); + }); + + it("treats a bare word as a search term", () => { + expect(looksLikeRepositoryPath("t3code")).toBe(false); + }); }); describe("repositoryResultItemValue", () => { @@ -55,9 +110,9 @@ describe("repositoryResultItemValue", () => { describe("repositoryStepEmptyState", () => { const settled = { supported: true, error: null, isPending: false, canSearch: true } as const; - it("shows the ordinary empty state when a supported provider returns nothing", () => { + it("points a no-match query at the full path, since a bare term no longer submits", () => { expect(repositoryStepEmptyState({ source: "github", search: settled })).toBe( - "No repositories match. Press Enter to look up the exact path.", + "No repositories match. Enter owner/repo and press Enter to look it up.", ); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e9ba13eae5e8..2f37a5216dd2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -289,18 +289,31 @@ function isRepositoryResultValue(value: string | null): boolean { return value?.startsWith(REPOSITORY_RESULT_VALUE_PREFIX) ?? false; } +/** True when the typed text names a repository rather than a search term: any "/" covers owner/repo, nested groups, and clone URLs. */ +export function looksLikeRepositoryPath(query: string): boolean { + return query.includes("/"); +} + /** * Enter in the repository step runs the highlighted search result, or looks up the typed path when * no result is highlighted. Mirrors how a highlighted browse row takes Enter from the path submit, - * including the primary modifier as the escape hatch back to the exact-path lookup. + * including the primary modifier as the escape hatch back to the exact-path lookup. A bare search + * term stays with the search ("continue-search") instead of erroring through the exact-path lookup; + * lookup still owns Enter when the search cannot answer (unsupported, failed, or query too short). */ export function repositoryStepEnterAction(input: { readonly highlightedItemValue: string | null; readonly hasPrimaryModifier: boolean; -}): "select-highlighted-repository" | "lookup-typed-repository" { - return isRepositoryResultValue(input.highlightedItemValue) && !input.hasPrimaryModifier - ? "select-highlighted-repository" - : "lookup-typed-repository"; + readonly queryIsRepositoryPath: boolean; + readonly searchCanAnswer: boolean; +}): "select-highlighted-repository" | "lookup-typed-repository" | "continue-search" { + if (isRepositoryResultValue(input.highlightedItemValue) && !input.hasPrimaryModifier) { + return "select-highlighted-repository"; + } + if (input.hasPrimaryModifier || input.queryIsRepositoryPath || !input.searchCanAnswer) { + return "lookup-typed-repository"; + } + return "continue-search"; } /** @@ -325,7 +338,7 @@ export function repositoryStepEmptyState(input: { if (!input.search.canSearch) { return "Enter a repository path and press Enter to look it up."; } - return "No repositories match. Press Enter to look up the exact path."; + return `No repositories match. Enter ${remoteProjectSourcePathHint(input.source)} and press Enter to look it up.`; } function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { @@ -2339,17 +2352,24 @@ function OpenCommandPaletteDialog(props: { } if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { - if ( - repositoryStepEnterAction({ - highlightedItemValue, - hasPrimaryModifier: isPrimaryModifierPressed(event), - }) === "select-highlighted-repository" - ) { + const enterAction = repositoryStepEnterAction({ + highlightedItemValue, + hasPrimaryModifier: isPrimaryModifierPressed(event), + queryIsRepositoryPath: looksLikeRepositoryPath(query.trim()), + searchCanAnswer: + repositorySearchFlow !== null && + repositorySearch.supported && + repositorySearch.error === null && + repositorySearch.canSearch, + }); + if (enterAction === "select-highlighted-repository") { // Leave the event alone so the highlighted row runs itself. return; } event.preventDefault(); - void submitAddProjectCloneFlow(); + if (enterAction === "lookup-typed-repository") { + void submitAddProjectCloneFlow(); + } return; } From 25307e374f1ec4bd7bbbb10c94ce473e11e6e1c1 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:46:54 +0800 Subject: [PATCH 25/27] fix(web): advertise the modifier shortcut when plain Enter stays with the search The Lookup accessory kept promising Enter while a bare search term made plain Enter a no-op. The accessory, its width reservations, and the key handler now share one plain-Enter decision, so the shortcut label switches to the modifier chord exactly when that is the key that runs the lookup. --- apps/web/src/components/CommandPalette.tsx | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2f37a5216dd2..f313619dd652 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2285,7 +2285,25 @@ function OpenCommandPaletteDialog(props: { : "Lookup" : null; const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; - const remoteProjectShortcutLabel = hasHighlightedRepositoryItem + const repositorySearchCanAnswer = + repositorySearchFlow !== null && + repositorySearch.supported && + repositorySearch.error === null && + repositorySearch.canSearch; + // What plain Enter does right now, shared by the key handler and the input accessory so the + // advertised shortcut never names a key that would do nothing. + const repositoryPlainEnterAction = + addProjectCloneFlow?.step === "repository" + ? repositoryStepEnterAction({ + highlightedItemValue, + hasPrimaryModifier: false, + queryIsRepositoryPath: looksLikeRepositoryPath(query.trim()), + searchCanAnswer: repositorySearchCanAnswer, + }) + : null; + const remoteProjectShortcutIsModified = + repositoryPlainEnterAction !== null && repositoryPlainEnterAction !== "lookup-typed-repository"; + const remoteProjectShortcutLabel = remoteProjectShortcutIsModified ? `${submitModifierLabel} Enter` : "Enter"; const canSubmitRemoteProjectFlow = @@ -2356,11 +2374,7 @@ function OpenCommandPaletteDialog(props: { highlightedItemValue, hasPrimaryModifier: isPrimaryModifierPressed(event), queryIsRepositoryPath: looksLikeRepositoryPath(query.trim()), - searchCanAnswer: - repositorySearchFlow !== null && - repositorySearch.supported && - repositorySearch.error === null && - repositorySearch.canSearch, + searchCanAnswer: repositorySearchCanAnswer, }); if (enterAction === "select-highlighted-repository") { // Leave the event alone so the highlighted row runs itself. @@ -2541,7 +2555,7 @@ function OpenCommandPaletteDialog(props: { tabIndex={-1} className={cn( "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", - hasHighlightedRepositoryItem ? "gap-1" : "gap-1.5", + remoteProjectShortcutIsModified ? "gap-1" : "gap-1.5", )} aria-label={`${remoteProjectButtonLabel ?? "Continue"} (${remoteProjectShortcutLabel})`} disabled={!canSubmitRemoteProjectFlow} @@ -2643,7 +2657,7 @@ function OpenCommandPaletteDialog(props: { // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? hasHighlightedRepositoryItem + ? remoteProjectShortcutIsModified ? "*:data-[slot=autocomplete-input]:pe-38!" : "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing From 3e5b037326b3b5e0e797826ccf23df45d58dcd2e Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:59:57 +0800 Subject: [PATCH 26/27] fix(web): drop the footer action hint while Enter stays with the search --- apps/web/src/components/CommandPalette.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f313619dd652..e96905356496 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2626,9 +2626,12 @@ function OpenCommandPaletteDialog(props: { const footerActionLabel = addProjectCloneFlow?.step === "repository" - ? hasHighlightedRepositoryItem + ? repositoryPlainEnterAction === "select-highlighted-repository" ? "Select" - : (remoteProjectButtonLabel ?? "Continue") + : repositoryPlainEnterAction === "lookup-typed-repository" + ? (remoteProjectButtonLabel ?? "Continue") + : // Plain Enter stays with the search, so there is no action to hint. + undefined : !canSubmitBrowsePath || hasHighlightedBrowseItem ? "Select" : undefined; From ad21b58160a42c2c3131ef78f2b2b0c2b0a21970 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:33:28 +0800 Subject: [PATCH 27/27] fix(server): dedup search results case-insensitively --- .../src/sourceControl/GitHubCli.test.ts | 48 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 11 +++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index ce67bc4f375c..79c2c36820e4 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -651,6 +651,54 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("dedups an owned repository against a search row that disagrees on casing", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + isPrivate: false, + nameWithOwner: "Octocat/CodeThing-MVP", + sshUrl: "git@github.com:Octocat/CodeThing-MVP.git", + stargazerCount: 42, + url: "https://github.com/Octocat/CodeThing-MVP", + }, + ]), + ), + ), + ); + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + description: "Minimal GUI for coding agents", + isFork: false, + fullName: "octocat/codething-mvp", + isPrivate: false, + stargazersCount: 42, + url: "https://github.com/octocat/codething-mvp", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const results = yield* gh.searchRepositories({ cwd: "/repo", query: "codething" }); + + assert.deepStrictEqual( + results.map((result) => result.nameWithOwner), + ["Octocat/CodeThing-MVP"], + ); + }).pipe(Effect.provide(layer)), + ); + it.effect("degrades to local matches when gh returns unusable search JSON", () => Effect.gen(function* () { mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d7f88fdb1526..a59796f6d187 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -829,15 +829,18 @@ export const make = Effect.gen(function* () { : []; // Owned repositories come first, and one the viewer owns is never - // repeated by the public search below it. + // repeated by the public search below it. GitHub compares repository + // names case-insensitively, and the two commands can disagree on + // casing, so the dedup key is lowercased. const results = localMatches.map(normalizeOwnedRepository); - const seen = new Set(results.map((result) => result.nameWithOwner)); + const seen = new Set(results.map((result) => result.nameWithOwner.toLowerCase())); for (const raw of searched) { - if (seen.has(raw.fullName)) { + const dedupKey = raw.fullName.toLowerCase(); + if (seen.has(dedupKey)) { continue; } - seen.add(raw.fullName); + seen.add(dedupKey); results.push(normalizeSearchedRepository(raw, viewerLogin)); }