From 194c3201089ec49c649f6c41ee7ae3ca15b6a41c Mon Sep 17 00:00:00 2001
From: dawn <90008@gaze.systems>
Date: Wed, 20 May 2026 21:05:01 +0300
Subject: [PATCH] update loading skeletons and fix issues / prs ordering
---
src/components/repo.tsx | 247 ++++++++++++++++++++++++++-------
src/lib/api/core.ts | 285 ++++++++++++++++++++++++++++++++++-----
src/pages/repo/pulls.tsx | 6 +-
3 files changed, 457 insertions(+), 81 deletions(-)
diff --git a/src/components/repo.tsx b/src/components/repo.tsx
index b0ccb7f..595d264 100644
--- a/src/components/repo.tsx
+++ b/src/components/repo.tsx
@@ -147,7 +147,11 @@ export const PaginationControls: Component<{
};
const skeletonRows = Array.from({ length: 7 });
+const listSkeletonRows = Array.from({ length: 10 });
const skeletonCodeLines = Array.from({ length: 18 });
+const prFileTreeSkeletonRows = Array.from({ length: 8 });
+const prDiffFileSkeletons = Array.from({ length: 2 });
+const prDiffCodeLines = Array.from({ length: 8 });
export const RepoTreeSkeleton: Component = () => (
@@ -214,7 +218,7 @@ export const RepoBlobSkeleton: Component = () => (
export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props) => (
-
+
{(_, index) => (
@@ -223,41 +227,39 @@ export const RepoListSkeleton: Component<{ kind: 'issues' | 'pulls' }> = (props)
-
+
+
+
+
+
-
-
+
+
)}
);
-export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props) => (
+const IssueThreadSkeleton: Component = () => (
-
+
-
+
@@ -275,44 +277,197 @@ export const RepoThreadSkeleton: Component<{ kind: 'issue' | 'pull' }> = (props)
-
-
-
-
-
-
-
-
- }
- >
-
+);
+
export const RepoFrameSkeleton: Component<{ active: 'code' | 'issues' | 'pulls' }> = (props) => (
<>
diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts
index 6af682c..1fd8c15 100644
--- a/src/lib/api/core.ts
+++ b/src/lib/api/core.ts
@@ -127,6 +127,11 @@ export interface PullSummary extends HydratedRecord {
state: 'open' | 'closed' | 'merged';
}
+type StatefulHydratedRecord = HydratedRecord & {
+ number: number;
+ state: State;
+};
+
export interface IssueComment extends HydratedRecord {}
export interface PullComment extends HydratedRecord {}
@@ -363,6 +368,8 @@ type AppviewParamValue = string | number | boolean | null | undefined;
const actorCache = new Map>();
const rpcCache = new Map();
+const SORTABLE_BASE32_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz';
+const TID_PATTERN = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/;
interface AppviewCursorCheckpoint {
matchingSeen: number;
@@ -380,6 +387,77 @@ export const clearApiCaches = () => {
appviewPageCursorCache.clear();
};
+const timestampFromDate = (value?: string): number => {
+ if (!value) {
+ return 0;
+ }
+
+ const timestamp = new Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : 0;
+};
+
+const timestampFromTid = (rkey?: string): number => {
+ if (!rkey || !TID_PATTERN.test(rkey)) {
+ return 0;
+ }
+
+ let micros = 0;
+ for (const char of rkey.slice(0, 11)) {
+ const value = SORTABLE_BASE32_ALPHABET.indexOf(char);
+ if (value < 0) {
+ return 0;
+ }
+ micros = micros * 32 + value;
+ }
+
+ return Math.floor(micros / 1000);
+};
+
+const maxTimestamp = (...values: number[]): number =>
+ values.reduce((latest, value) => Math.max(latest, value), 0);
+
+const sortStatefulRecordsByUpdatedAt = async (
+ items: Array>,
+ getUpdatedAt: (item: StatefulHydratedRecord) => Promise,
+): Promise>> => {
+ const decorated = await Promise.all(
+ items.map(async (item) => ({
+ item,
+ updatedAt: await getUpdatedAt(item),
+ })),
+ );
+
+ decorated.sort((left, right) => {
+ const updatedDiff = right.updatedAt - left.updatedAt;
+ if (updatedDiff !== 0) {
+ return updatedDiff;
+ }
+
+ if (left.item.number !== right.item.number) {
+ return right.item.number - left.item.number;
+ }
+
+ return right.item.rkey.localeCompare(left.item.rkey);
+ });
+
+ return decorated.map(({ item }) => item);
+};
+
+const paginateStatefulRecords = (
+ items: Array>,
+ options: { offset: number; limit: number; state: State },
+): PaginatedResult> => {
+ const offset = Math.max(options.offset, 0);
+ const limit = Math.max(options.limit, 1);
+ const matching = items.filter((item) => item.state === options.state);
+
+ return {
+ items: matching.slice(offset, offset + limit),
+ totalCount: matching.length,
+ hasNext: offset + limit < matching.length,
+ };
+};
+
const normalizeServiceUrl = (input: string): string => {
if (input.startsWith('http://') || input.startsWith('https://')) {
return input;
@@ -610,13 +688,55 @@ const getNearestAppviewCursorCheckpoint = (
return nearest;
};
-const listAppviewStatefulRecordsPage = async (
+const listAppviewStatefulRecordsPage = async (
nsid: string,
subject: string,
options: { offset: number; limit: number; state: State },
normalizeState: (value?: string) => State,
extraParams: Record = {},
-): Promise & { number: number; state: State }>> => {
+ getUpdatedAt?: (
+ record: StatefulHydratedRecord,
+ appviewRecord: AppviewStatefulRecordView,
+ ) => Promise,
+): Promise>> => {
+ if (getUpdatedAt) {
+ const records = await listAllAppviewRecords(nsid, subject, extraParams) as Array>;
+ const hydrated = await Promise.all(records.map((record) => appviewRecordToHydrated(record)));
+ const numbers = toNumberMap(hydrated);
+ const decorated = await Promise.all(
+ hydrated.map(async (record, index) => {
+ const item: StatefulHydratedRecord = {
+ ...record,
+ number: numbers.get(record.uri) ?? index + 1,
+ state: normalizeState(records[index].state),
+ };
+
+ return {
+ item,
+ updatedAt: await getUpdatedAt(item, records[index]),
+ };
+ }),
+ );
+
+ decorated.sort((left, right) => {
+ const updatedDiff = right.updatedAt - left.updatedAt;
+ if (updatedDiff !== 0) {
+ return updatedDiff;
+ }
+
+ if (left.item.number !== right.item.number) {
+ return right.item.number - left.item.number;
+ }
+
+ return right.item.rkey.localeCompare(left.item.rkey);
+ });
+
+ return paginateStatefulRecords(
+ decorated.map(({ item }) => item),
+ options,
+ );
+ }
+
// Bobbin exposes cursor pagination, while the UI still uses offsets. Cache
// page-boundary cursors so sequential pages can resume from the prior page.
const offset = Math.max(options.offset, 0);
@@ -987,6 +1107,28 @@ const getRepoBranchesFromKnot = async (repo: RepoContext): Promise
+ maxTimestamp(
+ timestampFromDate(branch.commit?.Committer?.When),
+ timestampFromDate(branch.commit?.Author?.When),
+ );
+
+const sortBranchResponseByUpdatedAt = (response: BranchResponse): BranchResponse => ({
+ ...response,
+ branches: [...response.branches].sort((left, right) => {
+ const updatedDiff = branchUpdatedTimestamp(right) - branchUpdatedTimestamp(left);
+ if (updatedDiff !== 0) {
+ return updatedDiff;
+ }
+
+ if (left.is_default !== right.is_default) {
+ return left.is_default ? -1 : 1;
+ }
+
+ return left.reference.name.localeCompare(right.reference.name);
+ }),
+});
+
const getRepoDefaultBranchFromKnot = async (repo: RepoContext): Promise =>
(await ok(
getRpc(repo.knot).get('sh.tangled.repo.getDefaultBranch', {
@@ -1073,7 +1215,7 @@ export const getRepoBranches = async (repo: RepoContext): Promise getRepoViaAppview('sh.tangled.repo.branches', repo),
() => getRepoBranchesFromKnot(repo),
- );
+ ).then(sortBranchResponseByUpdatedAt);
export const getRepoDefaultBranch = async (repo: RepoContext): Promise =>
appviewOptionalKnotRequest(
@@ -1300,8 +1442,8 @@ const toNumberMap = => {
const ordered = [...items].sort((left, right) => {
- const leftTime = left.value.createdAt ? new Date(left.value.createdAt).getTime() : 0;
- const rightTime = right.value.createdAt ? new Date(right.value.createdAt).getTime() : 0;
+ const leftTime = timestampFromDate(left.value.createdAt);
+ const rightTime = timestampFromDate(right.value.createdAt);
if (leftTime !== rightTime) {
return leftTime - rightTime;
}
@@ -1330,6 +1472,33 @@ const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => {
const latestBacklinkRefByRkey = (refs: BacklinkRecordRef[]): BacklinkRecordRef | undefined =>
[...refs].sort((left, right) => left.rkey.localeCompare(right.rkey)).at(-1);
+const getUniqueBacklinkRefs = async (
+ subject: GenericUri,
+ source: string | string[],
+): Promise =>
+ Array.from(
+ new Map(
+ (
+ await Promise.all(
+ (Array.isArray(source) ? source : [source]).map((entry) => getBacklinks(subject, entry)),
+ )
+ )
+ .flat()
+ .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const),
+ ).values(),
+ );
+
+const getLatestBacklinkTimestamp = async (
+ subject: GenericUri,
+ source: string | string[],
+): Promise => {
+ try {
+ return timestampFromTid(latestBacklinkRefByRkey(await getUniqueBacklinkRefs(subject, source))?.rkey);
+ } catch {
+ return 0;
+ }
+};
+
const BACKLINK_BATCH_LIMIT = 50;
const listBacklinkedRecordsPage = async (
@@ -1337,9 +1506,24 @@ const listBacklinkedRecordsPage = async Promise,
-): Promise & { number: number; state: State }>> => {
+ getUpdatedAt?: (record: StatefulHydratedRecord) => Promise,
+): Promise>> => {
+ if (getUpdatedAt) {
+ const hydrated = await hydrateBacklinks(await getUniqueBacklinkRefs(subject, source));
+ const numbers = toNumberMap(hydrated);
+ const states = await Promise.all(hydrated.map((record) => getState(record.uri)));
+ const items = hydrated.map((record, index) => ({
+ ...record,
+ number: numbers.get(record.uri) ?? index + 1,
+ state: states[index],
+ }));
+ const ordered = await sortStatefulRecordsByUpdatedAt(items, getUpdatedAt);
+
+ return paginateStatefulRecords(ordered, options);
+ }
+
const requestedEnd = options.offset + options.limit;
- const items: Array & { number: number; state: State }> = [];
+ const items: Array> = [];
const sources = Array.isArray(source) ? source : [source];
const batchLimit = Math.min(BACKLINK_BATCH_LIMIT, Math.max(options.limit + 1, 1));
const cursors = new Map(sources.map((entry) => [entry, undefined as string | undefined]));
@@ -1444,30 +1628,52 @@ const getLatestPullStatus = async (pullUri: ResourceUri): Promise<'open' | 'clos
return normalizePullStatus(status.status);
};
-const listIssuesFromBacklinks = async (repo: RepoContext): Promise => {
- const refs = Array.from(
- new Map(
- (
- await Promise.all([
- getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repoDid'),
- getBacklinks(repo.repoDid, 'sh.tangled.repo.issue:repo'),
- ])
- )
- .flat()
- .map((ref) => [`${ref.did}/${ref.collection}/${ref.rkey}`, ref] as const),
- ).values(),
+const getIssueUpdatedAt = async (
+ issue: HydratedRecord,
+ stateUpdatedAt?: string,
+): Promise =>
+ maxTimestamp(
+ timestampFromDate(issue.value.createdAt),
+ timestampFromTid(issue.rkey),
+ timestampFromDate(stateUpdatedAt),
+ await getLatestBacklinkTimestamp(issue.uri, [
+ `${ISSUE_COMMENT_COLLECTION}:issue`,
+ `${ISSUE_STATE_COLLECTION}:issue`,
+ ]),
);
+
+const getPullUpdatedAt = async (
+ pull: HydratedRecord,
+ stateUpdatedAt?: string,
+): Promise =>
+ maxTimestamp(
+ timestampFromDate(pull.value.createdAt),
+ timestampFromTid(pull.rkey),
+ ...pull.value.rounds.map((round) => timestampFromDate(round.createdAt)),
+ timestampFromDate(stateUpdatedAt),
+ await getLatestBacklinkTimestamp(pull.uri, [
+ `${PULL_COMMENT_COLLECTION}:pull`,
+ `${PULL_STATUS_COLLECTION}:pull`,
+ ]),
+ );
+
+const listIssuesFromBacklinks = async (repo: RepoContext): Promise => {
+ const refs = await getUniqueBacklinkRefs(repo.repoDid, [
+ 'sh.tangled.repo.issue:repoDid',
+ 'sh.tangled.repo.issue:repo',
+ ]);
const issues = await hydrateBacklinks(refs);
const numbers = toNumberMap(issues);
const states = await Promise.all(issues.map((issue) => getLatestIssueState(issue.uri)));
- return issues
+ const summaries = issues
.map((issue, index) => ({
...issue,
number: numbers.get(issue.uri) ?? index + 1,
state: states[index],
- }))
- .sort((left, right) => right.number - left.number);
+ }));
+
+ return sortStatefulRecordsByUpdatedAt(summaries, (issue) => getIssueUpdatedAt(issue));
};
const listIssuesPageFromBacklinks = async (
@@ -1479,6 +1685,7 @@ const listIssuesPageFromBacklinks = async (
['sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo'],
options,
getLatestIssueState,
+ (issue) => getIssueUpdatedAt(issue),
);
const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise => {
@@ -1504,13 +1711,14 @@ const listPullsFromBacklinks = async (repo: RepoContext): Promise
const numbers = toNumberMap(pulls);
const states = await Promise.all(pulls.map((pull) => getLatestPullStatus(pull.uri)));
- return pulls
+ const summaries = pulls
.map((pull, index) => ({
...pull,
number: numbers.get(pull.uri) ?? index + 1,
state: states[index],
- }))
- .sort((left, right) => right.number - left.number);
+ }));
+
+ return sortStatefulRecordsByUpdatedAt(summaries, (pull) => getPullUpdatedAt(pull));
};
const listPullsPageFromBacklinks = async (
@@ -1522,6 +1730,7 @@ const listPullsPageFromBacklinks = async (
'sh.tangled.repo.pull:target.repo',
options,
getLatestPullStatus,
+ (pull) => getPullUpdatedAt(pull),
);
const getPullFromBacklinks = async (repo: RepoContext, pullRef: string): Promise => {
@@ -1568,14 +1777,19 @@ const listIssuesFromAppview = async (repo: RepoContext): Promise
) as Array>;
const issues = await Promise.all(records.map((record) => appviewRecordToHydrated(record)));
const numbers = toNumberMap(issues);
+ const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt]));
- return issues
+ const summaries = issues
.map((issue, index) => ({
...issue,
number: numbers.get(issue.uri) ?? index + 1,
state: normalizeIssueState(records[index].state),
- }))
- .sort((left, right) => right.number - left.number);
+ }));
+
+ return sortStatefulRecordsByUpdatedAt(
+ summaries,
+ (issue) => getIssueUpdatedAt(issue, stateUpdatedAtByUri.get(issue.uri)),
+ );
};
const listIssuesPageFromAppview = async (
@@ -1587,6 +1801,8 @@ const listIssuesPageFromAppview = async (
repo.repoDid,
options,
normalizeIssueState,
+ {},
+ (issue, record) => getIssueUpdatedAt(issue, record.stateUpdatedAt),
);
const getIssueFromAppview = async (repo: RepoContext, issueRef: string): Promise => {
@@ -1609,14 +1825,19 @@ const listPullsFromAppview = async (repo: RepoContext): Promise =
) as Array>;
const pulls = await Promise.all(records.map((record) => appviewRecordToHydrated(record)));
const numbers = toNumberMap(pulls);
+ const stateUpdatedAtByUri = new Map(records.map((record) => [record.uri, record.stateUpdatedAt]));
- return pulls
+ const summaries = pulls
.map((pull, index) => ({
...pull,
number: numbers.get(pull.uri) ?? index + 1,
state: normalizePullStatus(records[index].state),
- }))
- .sort((left, right) => right.number - left.number);
+ }));
+
+ return sortStatefulRecordsByUpdatedAt(
+ summaries,
+ (pull) => getPullUpdatedAt(pull, stateUpdatedAtByUri.get(pull.uri)),
+ );
};
const listPullsPageFromAppview = async (
@@ -1628,6 +1849,8 @@ const listPullsPageFromAppview = async (
repo.repoDid,
options,
normalizePullStatus,
+ {},
+ (pull, record) => getPullUpdatedAt(pull, record.stateUpdatedAt),
);
const getPullFromAppview = async (repo: RepoContext, pullRef: string): Promise => {
diff --git a/src/pages/repo/pulls.tsx b/src/pages/repo/pulls.tsx
index 5ea4836..663732b 100644
--- a/src/pages/repo/pulls.tsx
+++ b/src/pages/repo/pulls.tsx
@@ -19,7 +19,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type
import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, setPullStatus, type RepoContext } from '../../lib/api';
import { useAuth } from '../../lib/auth';
import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common';
-import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo';
+import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffSkeleton, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo';
import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared';
import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils';
import { parsePastedPatchPrefill, parsePullStateFilter, type PullSourceMode, type PullStateFilter } from './pulls-helpers';
@@ -914,9 +914,7 @@ export const PullPage: Component = () => {
-
-
-
+
--
2.51.2