Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0d786de
feat(contracts): add source control repository search schemas
msegec Aug 26, 2026
a1aeaae
feat(contracts): add the repository search RPC definition
msegec Aug 26, 2026
68ad1e1
feat(server): add a repository search method to the source control pr…
msegec Aug 26, 2026
93c4d97
feat(server): search GitHub repositories through the gh CLI
msegec Aug 26, 2026
c76042f
feat(server): return ranked GitHub search results from the provider
msegec Aug 26, 2026
83e846f
perf(server): cache and rate limit GitHub repository search
msegec Aug 26, 2026
0431401
feat(server): expose repository search over the websocket API
msegec Aug 26, 2026
02e15a1
feat(client-runtime): add a repository search query atom family
msegec Aug 26, 2026
f14a3c3
feat(web): search GitHub repositories from the add-project flow
msegec Aug 26, 2026
b30f72e
feat(mobile): search GitHub repositories when adding a project
msegec Aug 26, 2026
4510fed
docs(user): describe GitHub repository search when adding a project
msegec Aug 26, 2026
a54b8f0
fix(server): share one source control discovery service across clients
msegec Aug 26, 2026
fa1abdb
fix(server): harden repository search caching and failure handling
msegec Aug 26, 2026
502a822
fix(app): keep repository search support state between keystrokes
msegec Aug 26, 2026
b83a278
fix(web): reserve input room for the wider highlighted shortcut
msegec Aug 26, 2026
276c540
docs(user): mark repository descriptions as optional
msegec Aug 26, 2026
d787423
fix(web): keep repository search descriptions to one line
msegec Aug 26, 2026
7afc2d7
fix(app): drop stale repository lookups once the flow moves on
msegec Aug 26, 2026
34ceacf
fix(server): give repository search its own rate-limit circuit
msegec Aug 27, 2026
116c45b
fix(app): close timing holes in the stale repository lookup guards
msegec Aug 27, 2026
b925161
fix(server): serve stale repository search results when a refresh fails
msegec Aug 27, 2026
7867766
Merge branch 'main' into feat/github-repo-search
msegec Aug 27, 2026
6bd4dad
refactor(server): move discovery layer wiring below the transport layers
msegec Aug 27, 2026
7f0b18e
fix(server): tell the user when a repository lookup finds nothing
msegec Aug 27, 2026
cee8af8
fix(web): keep Enter on a bare search term with the search
msegec Aug 27, 2026
9d9db37
Merge branch 'main' into feat/github-repo-search
msegec Aug 27, 2026
25307e3
fix(web): advertise the modifier shortcut when plain Enter stays with…
msegec Aug 27, 2026
3e5b037
fix(web): drop the footer action hint while Enter stays with the search
msegec Aug 27, 2026
ad21b58
fix(server): dedup search results case-insensitively
msegec Aug 27, 2026
75452e4
Merge branch 'main' into feat/github-repo-search
msegec Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
});
});
117 changes: 115 additions & 2 deletions apps/mobile/src/features/projects/AddProjectScreen.logic.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<SourceControlRepositorySearchResult>;
}

/**
* 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<SourceControlRepositorySearchResult>,
source: AddProjectRemoteProviderKind,
): ReadonlyArray<RepositorySearchGroup> {
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.";
}
Loading
Loading