import type { IssueComment, PullComment, RepoContext, TreeEntry } from './api';
import { ENRY_LANGUAGE_COLOR_FALLBACK, ENRY_LANGUAGE_COLORS, ENRY_LANGUAGE_GROUPS } from './language-colors';
const RECENT_REPOS_KEY = 'untangled.recentRepos';
export const REPO_LIST_PAGE_LIMIT = 10;
export type SearchParamValue = string | string[] | undefined;
export const parseIntegerSearchParam = (value: SearchParamValue, fallback: number, minimum: number): number => {
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < minimum) {
return fallback;
}
return parsed;
};
export interface RecentRepo {
owner: string;
slug: string;
title: string;
description?: string;
isFork?: boolean;
language?: string;
stars?: number;
openIssues?: number;
openPRs?: number;
updatedAt: string;
}
export interface RecentIssuePull {
uri: string;
kind: 'issue' | 'pull';
title: string;
repoLabel: string;
href: string;
state: 'open' | 'closed' | 'merged';
number: number;
author: string;
updatedAt: string;
}
export const parseRepoJump = (value: string): { owner: string; repo: string } | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const url = new URL(trimmed);
const [owner, repo] = url.pathname.split('/').filter(Boolean);
return owner && repo ? { owner, repo } : null;
} catch {
return null;
}
}
const [owner, repo] = trimmed.split('/').filter(Boolean);
return owner && repo ? { owner, repo } : null;
};
export const formatRelativeTime = (value?: string): string => {
if (!value) {
return 'unknown time';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const diff = date.getTime() - Date.now();
const seconds = Math.round(diff / 1000);
const absSeconds = Math.abs(seconds);
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
if (absSeconds < 60) return rtf.format(seconds, 'second');
if (absSeconds < 3600) return rtf.format(Math.round(seconds / 60), 'minute');
if (absSeconds < 86_400) return rtf.format(Math.round(seconds / 3600), 'hour');
if (absSeconds < 2_592_000) return rtf.format(Math.round(seconds / 86_400), 'day');
if (absSeconds < 31_536_000) return rtf.format(Math.round(seconds / 2_592_000), 'month');
return rtf.format(Math.round(seconds / 31_536_000), 'year');
};
export const getErrorMessage = (cause: unknown): string => {
if (cause instanceof Error) {
const message = cause.message;
if (cause.name === 'ScopeMissingError' || message.includes('ScopeMissingError')) {
return `please relogin (${message})`;
}
return message;
}
return 'Unknown error';
};
export const formatBytes = (value: number): string => {
if (value < 1024) {
return `${value} B`;
}
const units = ['KB', 'MB', 'GB'];
let current = value / 1024;
let unitIndex = 0;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
return `${current.toFixed(current < 10 ? 1 : 0)} ${units[unitIndex]}`;
};
export const treeHref = (repo: RepoContext, ref: string, path = ''): string =>
`/${repo.owner.handle}/${repo.slug}/tree/${encodeURIComponent(ref)}${path ? `/${encodePath(path)}` : ''}`;
export const blobHref = (repo: RepoContext, ref: string, path: string): string =>
`/${repo.owner.handle}/${repo.slug}/blob/${encodeURIComponent(ref)}/${encodePath(path)}`;
export const commitsHref = (repo: RepoContext, ref: string): string =>
`/${repo.owner.handle}/${repo.slug}/commits/${encodeURIComponent(ref)}`;
export const commitHref = (repo: RepoContext, ref: string): string =>
`/${repo.owner.handle}/${repo.slug}/commit/${encodeURIComponent(ref)}`;
const encodeRouteParam = (value: string | number): string => encodeURIComponent(String(value));
export const issueHref = (repo: RepoContext, issueRef: string | number): string =>
`/${repo.owner.handle}/${repo.slug}/issues/${encodeRouteParam(issueRef)}`;
export const pullHref = (repo: RepoContext, pullRef: string | number): string =>
`/${repo.owner.handle}/${repo.slug}/pulls/${encodeRouteParam(pullRef)}`;
export const encodePath = (path: string): string =>
path
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
export const decodeRoutePath = (path?: string): string => {
if (!path) {
return '';
}
return path
.split('/')
.filter(Boolean)
.map((segment) => safeDecode(segment))
.join('/');
};
export const safeDecode = (value?: string): string => {
if (!value) {
return '';
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
export const getParentPath = (path: string): string | null => {
if (!path) {
return null;
}
const segments = path.split('/').filter(Boolean);
if (segments.length <= 1) {
return '';
}
return segments.slice(0, -1).join('/');
};
export const markdownLike = (path: string, mimeType?: string): boolean =>
(mimeType?.includes('markdown') ?? false) || /\.mdx?$/i.test(path);
export const svgLike = (path: string, mimeType?: string): boolean =>
mimeType === 'image/svg+xml' || /\.svg$/i.test(path);
export const imageLike = (path: string, mimeType?: string): boolean =>
(mimeType?.startsWith('image/') ?? false) || /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i.test(path);
export const videoLike = (path: string, mimeType?: string): boolean =>
(mimeType?.startsWith('video/') ?? false) || /\.(mp4|mov|webm|ogg)$/i.test(path);
export const isDirectory = (entry: TreeEntry): boolean => entry.mode.startsWith('004');
export const sortedTreeEntries = (entries: TreeEntry[]): TreeEntry[] =>
[...entries].sort((left, right) => {
const leftIsDirectory = isDirectory(left);
const rightIsDirectory = isDirectory(right);
if (leftIsDirectory !== rightIsDirectory) {
return leftIsDirectory ? -1 : 1;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
export const joinPath = (base: string, name: string): string => (base ? `${base}/${name}` : name);
export const trimUri = (value: string): string => value.replace(/^https?:\/\//, '');
export const splitCodeLines = (value: string): string[] => {
const normalized = value.endsWith('\n') ? value.slice(0, -1) : value;
return normalized.length === 0 ? [''] : normalized.split('\n');
};
export const countLines = (value: string): number => splitCodeLines(value).length;
export const formatLanguagePercent = (value: number): string =>
value > 0 && value < 0.05 ? '0.1%' : `${value.toFixed(1)}%`;
export const loadRecentRepos = (): RecentRepo[] => {
try {
const raw = localStorage.getItem(RECENT_REPOS_KEY);
if (!raw) {
return [];
}
return JSON.parse(raw) as RecentRepo[];
} catch {
return [];
}
};
export const saveRecentRepo = (
repo: RepoContext,
stats?: { stars?: number; openIssues?: number; openPRs?: number }
) => {
const currentItems = loadRecentRepos();
const existing = currentItems.find((item) => item.owner === repo.owner.handle && item.slug === repo.slug);
const next: RecentRepo = {
owner: repo.owner.handle,
slug: repo.slug,
title: `${repo.owner.handle}/${repo.slug}`,
description: repo.record.value.description?.trim() || undefined,
isFork: Boolean(repo.record.value.source),
language: existing?.language,
stars: stats?.stars ?? existing?.stars,
openIssues: stats?.openIssues ?? existing?.openIssues,
openPRs: stats?.openPRs ?? existing?.openPRs,
updatedAt: new Date().toISOString(),
};
const items = currentItems.filter((item) => !(item.owner === next.owner && item.slug === next.slug));
items.unshift(next);
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(items.slice(0, 8)));
};
export const updateRecentRepoLanguage = (owner: string, slug: string, language: string) => {
try {
const items = loadRecentRepos();
const idx = items.findIndex((item) => item.owner === owner && item.slug === slug);
if (idx !== -1) {
items[idx] = { ...items[idx], language };
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(items));
}
} catch {
// ignore
}
};
export const loadRecentIssuesPulls = (): RecentIssuePull[] => {
try {
const raw = localStorage.getItem('untangled.recentIssuesPulls');
if (!raw) {
return [];
}
return JSON.parse(raw) as RecentIssuePull[];
} catch {
return [];
}
};
export const saveRecentIssueOrPull = (
owner: string,
slug: string,
item: {
uri: string;
kind: 'issue' | 'pull';
title: string;
number: number;
state: 'open' | 'closed' | 'merged';
author: string;
},
) => {
const next: RecentIssuePull = {
uri: item.uri,
kind: item.kind,
title: item.title,
repoLabel: `${owner}/${slug}`,
href: `/${owner}/${slug}/${item.kind}s/${item.number || item.uri.split('/').pop()}`,
state: item.state,
number: item.number,
author: item.author,
updatedAt: new Date().toISOString(),
};
const items = loadRecentIssuesPulls().filter((x) => x.uri !== next.uri);
items.unshift(next);
localStorage.setItem('untangled.recentIssuesPulls', JSON.stringify(items.slice(0, 10)));
};
export const uniqueCommenters = (
comments: Array<IssueComment | PullComment>,
): Array<{ did: string; handle: string }> => {
const seen = new Set<string>();
const result: Array<{ did: string; handle: string }> = [];
for (const comment of comments) {
if (seen.has(comment.author.did)) {
continue;
}
seen.add(comment.author.did);
result.push({
did: comment.author.did,
handle: comment.author.handle,
});
}
return result;
};
export const languageColor = (name: string): string => {
const normalized = name.trim().toLowerCase();
const group = ENRY_LANGUAGE_GROUPS[normalized];
return ENRY_LANGUAGE_COLORS[normalized] ?? (group ? ENRY_LANGUAGE_COLORS[group] : undefined) ?? ENRY_LANGUAGE_COLOR_FALLBACK;
};