Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 41 additions & 17 deletions src/app/components/onboarding/RepoSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
Show,
Index,
} from "solid-js";
import { fetchOrgs, fetchRepos, OrgEntry, RepoRef } from "../../services/api";
import { fetchOrgs, fetchRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api";
import { getClient } from "../../services/github";
import { relativeTime } from "../../lib/format";
import LoadingSpinner from "../shared/LoadingSpinner";
import FilterInput from "../shared/FilterInput";

Expand All @@ -20,7 +21,7 @@ interface RepoSelectorProps {
interface OrgRepoState {
org: string;
type: "org" | "user";
repos: RepoRef[];
repos: RepoEntry[];
loading: boolean;
error: string | null;
}
Expand Down Expand Up @@ -146,23 +147,46 @@ export default function RepoSelector(props: RepoSelectorProps) {
new Set(props.selected.map((r) => r.fullName))
);

const sortedOrgStates = createMemo(() => {
const states = orgStates();
// Defer sorting during initial load to prevent layout shift as orgs trickle in.
// After initial load (all orgs resolved), sorting stays active during retries
// because loadedCount is not reset by retryOrg.
if (loadedCount() < props.selectedOrgs.length) return states;
const maxPushedAt = new Map(
states.map((s) => [
s.org,
s.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, ""),
])
);
return [...states].sort((a, b) => {
const aMax = maxPushedAt.get(a.org) ?? "";
const bMax = maxPushedAt.get(b.org) ?? "";
return aMax > bMax ? -1 : aMax < bMax ? 1 : 0;
});
});

function toRepoRef(entry: RepoEntry): RepoRef {
return { owner: entry.owner, name: entry.name, fullName: entry.fullName };
}

function isSelected(fullName: string) {
return selectedSet().has(fullName);
}

function toggleRepo(repo: RepoRef) {
function toggleRepo(repo: RepoEntry) {
if (isSelected(repo.fullName)) {
props.onChange(props.selected.filter((r) => r.fullName !== repo.fullName));
} else {
props.onChange([...props.selected, repo]);
props.onChange([...props.selected, toRepoRef(repo)]);
}
}

// ── Filtering ──────────────────────────────────────────────────────────────

const q = () => filter().toLowerCase().trim();

function filteredReposForOrg(state: OrgRepoState): RepoRef[] {
function filteredReposForOrg(state: OrgRepoState): RepoEntry[] {
const query = q();
if (!query) return state.repos;
return state.repos.filter(
Expand All @@ -177,7 +201,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
function selectAllInOrg(state: OrgRepoState) {
const visible = filteredReposForOrg(state);
const current = new Map(props.selected.map((r) => [r.fullName, r]));
for (const repo of visible) current.set(repo.fullName, repo);
for (const repo of visible) current.set(repo.fullName, toRepoRef(repo));
props.onChange([...current.values()]);
}

Expand All @@ -186,18 +210,13 @@ export default function RepoSelector(props: RepoSelectorProps) {
props.onChange(props.selected.filter((r) => !visible.has(r.fullName)));
}

function allVisibleInOrgSelected(state: OrgRepoState): boolean {
const visible = filteredReposForOrg(state);
return visible.length > 0 && visible.every((r) => isSelected(r.fullName));
}

// ── Global select/deselect all ────────────────────────────────────────────

function selectAll() {
const current = new Map(props.selected.map((r) => [r.fullName, r]));
for (const state of orgStates()) {
for (const repo of filteredReposForOrg(state)) {
current.set(repo.fullName, repo);
current.set(repo.fullName, toRepoRef(repo));
}
}
props.onChange([...current.values()]);
Expand Down Expand Up @@ -252,9 +271,9 @@ export default function RepoSelector(props: RepoSelectorProps) {
</Show>

{/* Per-org repo lists */}
<For each={orgStates()}>
<For each={sortedOrgStates()}>
{(state) => {
const visible = () => filteredReposForOrg(state);
const visible = createMemo(() => filteredReposForOrg(state));

return (
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
Expand All @@ -269,8 +288,8 @@ export default function RepoSelector(props: RepoSelectorProps) {
type="button"
onClick={() => selectAllInOrg(state)}
disabled={
allVisibleInOrgSelected(state) ||
visible().length === 0
visible().length === 0 ||
visible().every((r) => isSelected(r.fullName))
}
class="text-xs text-blue-600 hover:underline disabled:cursor-not-allowed disabled:opacity-40 dark:text-blue-400"
>
Expand Down Expand Up @@ -341,9 +360,14 @@ export default function RepoSelector(props: RepoSelectorProps) {
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{repo().name}
</span>
<Show when={repo().pushedAt}>
<span class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400">
{relativeTime(repo().pushedAt!)}
</span>
</Show>
</div>
</div>
</label>
Expand Down
1 change: 1 addition & 0 deletions src/app/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
*/
export function relativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
if (isNaN(diffMs)) return "";
const diffSec = Math.floor(diffMs / 1000);

if (diffSec < 60) return rtf.format(-diffSec, "second");
Expand Down
49 changes: 32 additions & 17 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface RepoRef {
fullName: string;
}

export interface RepoEntry extends RepoRef {
pushedAt: string | null;
}

export interface Issue {
id: number;
number: number;
Expand Down Expand Up @@ -110,6 +114,7 @@ interface RawRepo {
owner: { login: string };
name: string;
full_name: string;
pushed_at: string | null;
}

interface RawPullRequest {
Expand Down Expand Up @@ -380,32 +385,42 @@ export async function fetchRepos(
octokit: ReturnType<typeof getClient>,
orgOrUser: string,
type: "org" | "user"
): Promise<RepoRef[]> {
): Promise<RepoEntry[]> {
if (!octokit) throw new Error("No GitHub client available");

const route =
type === "org"
? `GET /orgs/{org}/repos`
: `GET /user/repos`;

const params =
type === "org"
? { org: orgOrUser, per_page: 100 }
: { affiliation: "owner", per_page: 100 };

const repos: RepoRef[] = [];
const repos: RepoEntry[] = [];

for await (const response of octokit.paginate.iterator(route, params)) {
const page = response.data as RawRepo[];
function collectRepos(page: RawRepo[], into: RepoEntry[]): void {
for (const repo of page) {
repos.push({
into.push({
owner: repo.owner.login,
name: repo.name,
fullName: repo.full_name,
pushedAt: repo.pushed_at ?? null,
});
}
}

if (type === "org") {
for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, {
org: orgOrUser,
per_page: 100,
sort: "pushed" as const,
direction: "desc" as const,
})) {
collectRepos(response.data as RawRepo[], repos);
}
} else {
for await (const response of octokit.paginate.iterator(`GET /user/repos`, {
affiliation: "owner",
per_page: 100,
sort: "pushed" as const,
direction: "desc" as const,
})) {
collectRepos(response.data as RawRepo[], repos);
}
}

return repos;
}

Expand Down Expand Up @@ -1022,14 +1037,14 @@ export async function fetchWorkflowRuns(
runs,
latestAt: runs.reduce((max, r) => r.updated_at > max ? r.updated_at : max, ""),
}));
workflowEntries.sort((a, b) => b.latestAt > a.latestAt ? -1 : b.latestAt < a.latestAt ? 1 : 0);
workflowEntries.sort((a, b) => a.latestAt > b.latestAt ? -1 : a.latestAt < b.latestAt ? 1 : 0);
const topWorkflows = workflowEntries
.slice(0, maxWorkflows);

// Take most recent M runs per workflow
for (const { runs: workflowRuns } of topWorkflows) {
const sorted = workflowRuns.sort(
(a, b) => b.created_at > a.created_at ? -1 : b.created_at < a.created_at ? 1 : 0
(a, b) => a.created_at > b.created_at ? -1 : a.created_at < b.created_at ? 1 : 0
);
for (const run of sorted.slice(0, maxRuns)) {
allRuns.push({
Expand Down
Loading
Loading