Files
kandev/apps/web/lib/utils.ts
T
2026-08-02 16:06:14 +01:00

303 lines
11 KiB
TypeScript

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format a cost stored in subcents (hundredths of a cent) as a USD
* dollar string. The backend persists every cost figure as int64
* subcents so token-rate math stays integer-only;
* docs/specs/office-costs/spec.md. UI consumers should never multiply
* or divide the raw value themselves — call this helper so the unit
* boundary lives in one place.
*/
export function formatDollars(subcents: number | null | undefined): string {
if (subcents == null || !Number.isFinite(subcents)) {
return "$0.00";
}
return `$${(subcents / 10000).toFixed(2)}`;
}
let uuidFallbackCounter = 0;
let uuidFallbackContextCounter = 0;
function createUuidFallbackContext(): string {
const entropy = new Uint32Array(2);
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
try {
crypto.getRandomValues(entropy);
return entropy[0].toString(16).padStart(8, "0");
} catch {
// Fall through to the non-cryptographic legacy-environment fallback.
}
}
const monotonic = typeof performance !== "undefined" ? Math.floor(performance.now() * 1000) : 0;
const counter = uuidFallbackContextCounter++;
return ((Date.now() ^ monotonic ^ counter) >>> 0).toString(16).padStart(8, "0");
}
// Keep a per-module component so two tabs generating IDs in the same
// millisecond do not share the timestamp/counter prefix. Prefer Web Crypto
// entropy when available; old environments get a monotonic best-effort ID.
const uuidFallbackContext = createUuidFallbackContext();
/**
* Generate a UUID. Falls back to crypto.getRandomValues when randomUUID is
* unavailable (e.g., HTTP on non-localhost), then to a UUID-shaped fallback
* with a per-context component for legacy environments without any Web Crypto API.
*/
export function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const context = uuidFallbackContext;
const timestamp = Date.now().toString(16).padStart(12, "0").slice(-12);
const counter = (uuidFallbackCounter++ >>> 0).toString(16).padStart(8, "0");
const chars = `${context}${timestamp}${counter}`.padEnd(32, "0").slice(0, 32).split("");
chars[12] = "4";
chars[16] = "8";
const hex = chars.join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Extract organization/repository name from a repository URL.
* Supports common Git providers and SSH/HTTPS formats.
* @param repositoryUrl - The repository URL or local path
* @returns The org/repo-name format, or null if unable to extract
*/
export function extractRepoName(repositoryUrl: string | null | undefined): string | null {
if (!repositoryUrl) return null;
try {
const normalized = repositoryUrl.replace(/\.git$/, "");
// SSH format: git@host:org/repo
const sshMatch = normalized.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/);
if (sshMatch) {
return sshMatch[1];
}
// HTTPS format: https://host/org/repo or http(s)://host/org/repo
const httpMatch = normalized.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/);
if (httpMatch) {
return httpMatch[1];
}
return null;
} catch {
return null;
}
}
export function isLocalRepositoryPath(repositoryUrl: string): boolean {
return (
repositoryUrl.startsWith("/") ||
repositoryUrl.startsWith("~/") ||
/^[A-Za-z]:[\\/]/.test(repositoryUrl)
);
}
export function getRepositoryDisplayName(repositoryUrl: string | null | undefined): string | null {
if (!repositoryUrl) return null;
if (isLocalRepositoryPath(repositoryUrl)) {
return formatUserHomePath(repositoryUrl.replace(/\.git$/, ""));
}
return extractRepoName(repositoryUrl) ?? repositoryUrl;
}
/**
* Normalize a local path for display, replacing the user home directory with "~".
* Supports macOS/Linux (/Users/name, /home/name) and Windows (C:\Users\name).
*/
export function formatUserHomePath(path: string): string {
if (!path) return path;
const normalized = path.replace(/\\/g, "/");
const macMatch = normalized.match(/^\/Users\/[^/]+(\/.*)?$/);
if (macMatch) {
return `~${macMatch[1] ?? ""}`;
}
const linuxMatch = normalized.match(/^\/home\/[^/]+(\/.*)?$/);
if (linuxMatch) {
return `~${linuxMatch[1] ?? ""}`;
}
const windowsMatch = normalized.match(/^[A-Za-z]:\/Users\/[^/]+(\/.*)?$/);
if (windowsMatch) {
return `~${windowsMatch[1] ?? ""}`;
}
return path;
}
/**
* Truncate a repository path for display, favoring the last segments.
*/
export function truncateRepoPath(path: string, maxLength = 34): string {
const displayPath = formatUserHomePath(path);
if (displayPath.length <= maxLength) return displayPath;
const normalizedPath = displayPath.replace(/\\/g, "/");
const hasHomePrefix = normalizedPath.startsWith("~/");
const hasRootPrefix = normalizedPath.startsWith("/");
let prefix = "";
if (hasHomePrefix) {
prefix = "~/";
} else if (hasRootPrefix) {
prefix = "/";
}
const parts = normalizedPath.replace(/^~\//, "").replace(/^\//, "").split("/").filter(Boolean);
if (parts.length === 0) return displayPath;
const lastThree = parts.slice(-3).join("/");
let result = `${prefix}.../${lastThree}`;
if (result.length <= maxLength) return result;
const lastTwo = parts.slice(-2).join("/");
result = `${prefix}.../${lastTwo}`;
if (result.length <= maxLength) return result;
const lastOne = parts.slice(-1).join("/");
result = `${prefix}.../${lastOne}`;
if (result.length <= maxLength) return result;
const remaining = Math.max(1, maxLength - prefix.length - 4);
return `${prefix}.../${lastOne.slice(-remaining)}`;
}
type BranchSelectionCandidate = {
name: string;
type?: string;
remote?: string;
};
export function selectPreferredBranch(branches: BranchSelectionCandidate[]): string | null {
const localMain = branches.find((branch) => branch.type === "local" && branch.name === "main");
if (localMain) return "main";
const localMaster = branches.find(
(branch) => branch.type === "local" && branch.name === "master",
);
if (localMaster) return "master";
const originMain = branches.find(
(branch) => branch.type === "remote" && branch.remote === "origin" && branch.name === "main",
);
if (originMain) return "origin/main";
const originMaster = branches.find(
(branch) => branch.type === "remote" && branch.remote === "origin" && branch.name === "master",
);
if (originMaster) return "origin/master";
return null;
}
export const DEFAULT_LOCAL_EXECUTOR_TYPE = "worktree";
/**
* Format a date string as a human-readable relative time (e.g., "2m ago", "1h ago", "yesterday").
* @param dateString - ISO date string
* @returns Formatted relative time string
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) return "just now";
if (diffSec < 60) return `${diffSec}s ago`;
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay === 1) return "yesterday";
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString();
}
/**
* Format a date string with minute-level precision and short context.
* Examples: "just now", "5m ago", "Today, 14:32", "Yesterday, 14:32",
* "Apr 25, 14:32", "Apr 25 2025, 14:32".
*/
export function formatPreciseTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
if (diffSec < 10) return "just now";
if (diffSec < 60) return `${diffSec}s ago`;
if (diffMin < 60) return `${diffMin}m ago`;
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) return `Today, ${time}`;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const isYesterday =
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate();
if (isYesterday) return `Yesterday, ${time}`;
const sameYear = date.getFullYear() === now.getFullYear();
const dateLabel = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: sameYear ? undefined : "numeric",
});
return `${dateLabel}, ${time}`;
}
/**
* Convert an absolute file path to a relative path based on a workspace root.
* If the path is within the workspace, returns the relative portion.
* Otherwise, returns the original path (with home directory formatting applied).
*/
export function toRelativePath(absolutePath: string, workspaceRoot?: string | null): string {
if (!absolutePath) return absolutePath;
if (!workspaceRoot) return formatUserHomePath(absolutePath);
// Normalize paths for comparison
const normalizedPath = absolutePath.replace(/\\/g, "/");
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/$/, "");
// Check if path is within the workspace
if (normalizedPath.startsWith(normalizedRoot + "/")) {
// Return the relative portion (strip workspace root)
return normalizedPath.slice(normalizedRoot.length + 1);
}
// Path is outside workspace, use home path formatting
return formatUserHomePath(absolutePath);
}
/**
* Transform any absolute paths in a string to relative paths based on workspace root.
* Useful for titles/descriptions that may contain embedded file paths.
*/
export function transformPathsInText(text: string, workspaceRoot?: string | null): string {
if (!text || !workspaceRoot) return text;
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/$/, "");
// Match the workspace root followed by a path (non-whitespace characters)
const pattern = new RegExp(
normalizedRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/([^\\s]+)",
"g",
);
return text.replace(pattern, (_, relativePart) => relativePart);
}