import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { t } from "@/lib/i18n"; export { generateUUID } from "./uuid"; import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp"; 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/requirements/costs.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)}`; } /** * 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 { if (parseStrictRfc3339Timestamp(dateString) === null) return ""; 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); // Every branch, not just the first. Localizing only "just now" would show a // translated string for ten seconds and then flip to English `10s ago`, which // is worse than leaving the whole ladder in English. if (diffSec < 10) return t("common:justNow"); if (diffSec < 60) return t("common:relativeSecondsAgo", { count: diffSec }); if (diffMin < 60) return t("common:relativeMinutesAgo", { count: diffMin }); if (diffHour < 24) return t("common:relativeHoursAgo", { count: diffHour }); if (diffDay === 1) return t("common:relativeYesterday"); if (diffDay < 7) return t("common:relativeDaysAgo", { count: diffDay }); 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 t("common:justNow"); if (diffSec < 60) return t("common:relativeSecondsAgo", { count: diffSec }); if (diffMin < 60) return t("common:relativeMinutesAgo", { count: diffMin }); 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 t("common:preciseToday", { 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 t("common:preciseYesterday", { time }); const sameYear = date.getFullYear() === now.getFullYear(); const dateLabel = date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: sameYear ? undefined : "numeric", }); return t("common:preciseDated", { date: 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); }