2b1d0cf7d1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
258 lines
9.3 KiB
TypeScript
258 lines
9.3 KiB
TypeScript
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);
|
|
}
|