mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: enhance auto mode service and UI components for branch handling and verification
- Added a new function to retrieve the current branch name in the auto mode service, improving branch management. - Updated the `getRunningCountForWorktree` method to utilize the current branch name for accurate feature counting. - Modified UI components to include a toggle for skipping verification in auto mode, enhancing user control. - Refactored various hooks and components to ensure consistent handling of branch names across the application. - Introduced a new utility file for string operations, providing common functions for text manipulation.
This commit is contained in:
@@ -74,6 +74,21 @@ import { getNotificationService } from './notification-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Get the current branch name for a git repository
|
||||
* @param projectPath - Path to the git repository
|
||||
* @returns The current branch name, or null if not in a git repo or on detached HEAD
|
||||
*/
|
||||
async function getCurrentBranch(projectPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
|
||||
const branch = stdout.trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// PlanningMode type is imported from @automaker/types
|
||||
|
||||
interface ParsedTask {
|
||||
@@ -635,7 +650,7 @@ export class AutoModeService {
|
||||
iterationCount++;
|
||||
try {
|
||||
// Count running features for THIS project/worktree only
|
||||
const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName);
|
||||
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
|
||||
// Check if we have capacity for this project/worktree
|
||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||
@@ -728,20 +743,24 @@ export class AutoModeService {
|
||||
/**
|
||||
* Get count of running features for a specific worktree
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
|
||||
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
|
||||
*/
|
||||
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||
private async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
// Get the actual primary branch name for the project
|
||||
const primaryBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
if (normalizedBranch === null) {
|
||||
// Main worktree: match features with branchName === null OR branchName === "main"
|
||||
if (
|
||||
feature.projectPath === projectPath &&
|
||||
(featureBranch === null || featureBranch === 'main')
|
||||
) {
|
||||
if (branchName === null) {
|
||||
// Main worktree: match features with branchName === null OR branchName matching primary branch
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (feature.projectPath === projectPath && isPrimaryBranch) {
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
@@ -790,7 +809,7 @@ export class AutoModeService {
|
||||
// Remove from map
|
||||
this.autoLoopsByProject.delete(worktreeKey);
|
||||
|
||||
return this.getRunningCountForWorktree(projectPath, branchName);
|
||||
return await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1025,7 +1044,7 @@ export class AutoModeService {
|
||||
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||
|
||||
// Get current running count for this worktree
|
||||
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
|
||||
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
|
||||
return {
|
||||
hasCapacity: currentAgents < maxAgents,
|
||||
@@ -2952,6 +2971,10 @@ Format your response as a structured markdown document.`;
|
||||
// Features are stored in .automaker directory
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
|
||||
// Get the actual primary branch name for the project (e.g., "main", "master", "develop")
|
||||
// This is needed to correctly match features when branchName is null (main worktree)
|
||||
const primaryBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(featuresDir, {
|
||||
withFileTypes: true,
|
||||
@@ -2991,17 +3014,21 @@ Format your response as a structured markdown document.`;
|
||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
||||
) {
|
||||
// Filter by branchName:
|
||||
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
||||
// - If branchName is null (main worktree), include features with:
|
||||
// - branchName === null, OR
|
||||
// - branchName === primaryBranch (e.g., "main", "master", "develop")
|
||||
// - If branchName is set, only include features with matching branchName
|
||||
const featureBranch = feature.branchName ?? null;
|
||||
if (branchName === null) {
|
||||
// Main worktree: include features without branchName OR with branchName === "main"
|
||||
// This handles both correct (null) and legacy ("main") cases
|
||||
if (featureBranch === null || featureBranch === 'main') {
|
||||
// Main worktree: include features without branchName OR with branchName matching primary branch
|
||||
// This handles repos where the primary branch is named something other than "main"
|
||||
const isPrimaryBranch =
|
||||
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
|
||||
if (isPrimaryBranch) {
|
||||
pendingFeatures.push(feature);
|
||||
} else {
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree`
|
||||
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -142,7 +142,8 @@ export function BoardHeader({
|
||||
onConcurrencyChange={onConcurrencyChange}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onAutoModeToggle={onAutoModeToggle}
|
||||
onOpenAutoModeSettings={() => {}}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
|
||||
@@ -180,8 +180,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
@@ -23,7 +23,8 @@ interface HeaderMobileMenuProps {
|
||||
// Auto mode
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenAutoModeSettings: () => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
// Plan button
|
||||
onOpenPlanDialog: () => void;
|
||||
// Usage bar visibility
|
||||
@@ -41,7 +42,8 @@ export function HeaderMobileMenu({
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenAutoModeSettings,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
@@ -66,9 +68,11 @@ export function HeaderMobileMenu({
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Section */}
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
@@ -80,15 +84,7 @@ export function HeaderMobileMenu({
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
<span
|
||||
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
|
||||
data-testid="mobile-auto-mode-max-concurrency"
|
||||
title="Max concurrent agents"
|
||||
>
|
||||
{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
@@ -96,17 +92,51 @@ export function HeaderMobileMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
</div>
|
||||
|
||||
{/* Skip Verification Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
|
||||
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
|
||||
data-testid="mobile-skip-verification-toggle-container"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<FastForward className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 pl-9 border-t border-border/30"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,32 +159,6 @@ export function HeaderMobileMenu({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -487,7 +487,15 @@ export function useBoardActions({
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
const featureBranchName = feature.branchName ?? null;
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
@@ -567,6 +575,7 @@ export function useBoardActions({
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -128,15 +128,22 @@ export function useBoardDragDrop({
|
||||
const targetBranch = worktreeData.branch;
|
||||
const currentBranch = draggedFeature.branchName;
|
||||
|
||||
// For main worktree, set branchName to null to indicate it should use main
|
||||
// (must use null not undefined so it serializes to JSON for the API call)
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? null : targetBranch;
|
||||
|
||||
// If already on the same branch, nothing to do
|
||||
if (currentBranch === targetBranch) {
|
||||
// For main worktree: feature with null/undefined branchName is already on main
|
||||
// For other worktrees: compare branch names directly
|
||||
const isAlreadyOnTarget = worktreeData.isMain
|
||||
? !currentBranch // null or undefined means already on main
|
||||
: currentBranch === targetBranch;
|
||||
|
||||
if (isAlreadyOnTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For main worktree, set branchName to undefined/null to indicate it should use main
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
||||
|
||||
// Update feature's branchName
|
||||
updateFeature(featureId, { branchName: newBranchName });
|
||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||
|
||||
@@ -17,11 +17,8 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
||||
if (worktree.isMain && feature.branchName === 'main') {
|
||||
return true;
|
||||
}
|
||||
// Check if branch names match - this handles both main worktree (any primary branch name)
|
||||
// and feature worktrees
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
@@ -90,6 +91,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
getWorktreeKey: state.getWorktreeKey,
|
||||
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -197,9 +199,21 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
}
|
||||
|
||||
// Extract branchName from event, defaulting to null (main worktree)
|
||||
const eventBranchName: string | null =
|
||||
const rawEventBranchName: string | null =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
|
||||
// Get projectPath for worktree lookup
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
|
||||
|
||||
// Normalize branchName: convert primary worktree branch to null for consistent key lookup
|
||||
// This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
|
||||
const eventBranchName: string | null =
|
||||
eventProjectPath &&
|
||||
rawEventBranchName &&
|
||||
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
|
||||
? null
|
||||
: rawEventBranchName;
|
||||
|
||||
// Skip event if we couldn't determine the project
|
||||
if (!eventProjectId) {
|
||||
logger.warn('Could not determine project for event:', event);
|
||||
@@ -493,6 +507,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
currentProject?.path,
|
||||
getMaxConcurrencyForWorktree,
|
||||
setMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]);
|
||||
|
||||
// Start auto mode - calls backend to start the auto loop for this worktree
|
||||
|
||||
@@ -895,12 +895,15 @@ function RootLayoutContent() {
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FileBrowserProvider>
|
||||
<RootLayoutContent />
|
||||
</FileBrowserProvider>
|
||||
{SHOW_QUERY_DEVTOOLS ? (
|
||||
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
|
||||
178
libs/utils/src/string-utils.ts
Normal file
178
libs/utils/src/string-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* String utility functions for common text operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length, adding an ellipsis if truncated
|
||||
* @param str - The string to truncate
|
||||
* @param maxLength - Maximum length of the result (including ellipsis)
|
||||
* @param ellipsis - The ellipsis string to use (default: '...')
|
||||
* @returns The truncated string
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
|
||||
if (maxLength < ellipsis.length) {
|
||||
throw new Error(
|
||||
`maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
|
||||
);
|
||||
}
|
||||
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
|
||||
* @param str - The string to convert
|
||||
* @returns The kebab-case string
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
|
||||
.replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
|
||||
.replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
|
||||
.replace(/-+/g, '-') // collapse multiple hyphens
|
||||
.replace(/^-|-$/g, '') // remove leading/trailing hyphens
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The camelCase string
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str
|
||||
.replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
|
||||
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
|
||||
.replace(/^[A-Z]/, (char) => char.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The PascalCase string
|
||||
*/
|
||||
export function toPascalCase(str: string): string {
|
||||
const camel = toCamelCase(str);
|
||||
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
* @param str - The string to capitalize
|
||||
* @returns The string with first letter capitalized
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
if (str.length === 0) {
|
||||
return str;
|
||||
}
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate whitespace from a string, preserving single spaces
|
||||
* @param str - The string to clean
|
||||
* @returns The string with duplicate whitespace removed
|
||||
*/
|
||||
export function collapseWhitespace(str: string): string {
|
||||
return str.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is empty or contains only whitespace
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is blank
|
||||
*/
|
||||
export function isBlank(str: string | null | undefined): boolean {
|
||||
return str === null || str === undefined || str.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is not empty and contains non-whitespace characters
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is not blank
|
||||
*/
|
||||
export function isNotBlank(str: string | null | undefined): boolean {
|
||||
return !isBlank(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a string to an integer, returning a default value on failure
|
||||
* @param str - The string to parse
|
||||
* @param defaultValue - The default value if parsing fails (default: 0)
|
||||
* @returns The parsed integer or the default value
|
||||
*/
|
||||
export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
|
||||
if (isBlank(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = parseInt(str!, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a string (URL-friendly identifier)
|
||||
* @param str - The string to convert to a slug
|
||||
* @param maxLength - Optional maximum length for the slug
|
||||
* @returns The slugified string
|
||||
*/
|
||||
export function slugify(str: string, maxLength?: number): string {
|
||||
let slug = str
|
||||
.toLowerCase()
|
||||
.normalize('NFD') // Normalize unicode characters
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
|
||||
if (maxLength !== undefined && slug.length > maxLength) {
|
||||
// Truncate at word boundary if possible
|
||||
slug = slug.slice(0, maxLength);
|
||||
const lastHyphen = slug.lastIndexOf('-');
|
||||
if (lastHyphen > maxLength * 0.5) {
|
||||
slug = slug.slice(0, lastHyphen);
|
||||
}
|
||||
slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for use in a RegExp
|
||||
*/
|
||||
export function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluralize a word based on count
|
||||
* @param word - The singular form of the word
|
||||
* @param count - The count to base pluralization on
|
||||
* @param pluralForm - Optional custom plural form (default: word + 's')
|
||||
* @returns The word in singular or plural form
|
||||
*/
|
||||
export function pluralize(word: string, count: number, pluralForm?: string): string {
|
||||
if (count === 1) {
|
||||
return word;
|
||||
}
|
||||
return pluralForm || `${word}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a count with its associated word (e.g., "1 item", "3 items")
|
||||
* @param count - The count
|
||||
* @param singular - The singular form of the word
|
||||
* @param plural - Optional custom plural form
|
||||
* @returns Formatted string with count and word
|
||||
*/
|
||||
export function formatCount(count: number, singular: string, plural?: string): string {
|
||||
return `${count} ${pluralize(singular, count, plural)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user