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)
- - -
- - - -
- - } - > -
- +
+ +); + +const PullThreadSkeleton: Component = () => ( +
+ +
); +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