mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Task 167 - Deduplicate truncateCommand and MIN_TOUCH_TARGET in mobile components
- Add shared truncateCommand(command, maxLength) to src/shared/formatters.ts - Handles newline replacement, whitespace trimming, unicode ellipsis - 8 comprehensive tests added - Add MIN_TOUCH_TARGET constant to src/web/mobile/constants.ts - Refactor OfflineQueueBanner, RecentCommandChips, CommandHistoryDrawer to use shared truncateCommand - Refactor QuickActionsMenu, CommandInputButtons, SlashCommandAutocomplete to use shared MIN_TOUCH_TARGET - Update test expectations for new unicode ellipsis format
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
formatCost,
|
||||
estimateTokenCount,
|
||||
truncatePath,
|
||||
truncateCommand,
|
||||
} from '../../shared/formatters';
|
||||
|
||||
describe('shared/formatters', () => {
|
||||
@@ -363,4 +364,59 @@ describe('shared/formatters', () => {
|
||||
expect(truncatePath('/parent/child', 50)).toBe('/parent/child');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// truncateCommand tests
|
||||
// ==========================================================================
|
||||
describe('truncateCommand', () => {
|
||||
it('should return command unchanged if within maxLength', () => {
|
||||
expect(truncateCommand('npm run build')).toBe('npm run build');
|
||||
expect(truncateCommand('git status', 20)).toBe('git status');
|
||||
});
|
||||
|
||||
it('should truncate long commands with ellipsis', () => {
|
||||
const longCommand = 'npm run build --watch --verbose --output=/path/to/output';
|
||||
const result = truncateCommand(longCommand, 30);
|
||||
expect(result.length).toBe(30);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('should replace newlines with spaces', () => {
|
||||
const multilineCommand = 'echo "hello\nworld"';
|
||||
const result = truncateCommand(multilineCommand, 50);
|
||||
expect(result).toBe('echo "hello world"');
|
||||
expect(result.includes('\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(truncateCommand(' git status ')).toBe('git status');
|
||||
expect(truncateCommand('\n\ngit status\n\n')).toBe('git status');
|
||||
});
|
||||
|
||||
it('should use default maxLength of 40', () => {
|
||||
const longCommand = 'a'.repeat(50);
|
||||
const result = truncateCommand(longCommand);
|
||||
expect(result.length).toBe(40);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect custom maxLength parameter', () => {
|
||||
const command = 'a'.repeat(100);
|
||||
expect(truncateCommand(command, 20).length).toBe(20);
|
||||
expect(truncateCommand(command, 50).length).toBe(50);
|
||||
expect(truncateCommand(command, 60).length).toBe(60);
|
||||
});
|
||||
|
||||
it('should handle multiple newlines as spaces', () => {
|
||||
const command = 'echo "one\ntwo\nthree"';
|
||||
const result = truncateCommand(command, 50);
|
||||
expect(result).toBe('echo "one two three"');
|
||||
});
|
||||
|
||||
it('should handle empty command', () => {
|
||||
expect(truncateCommand('')).toBe('');
|
||||
expect(truncateCommand(' ')).toBe('');
|
||||
expect(truncateCommand('\n\n')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,8 +208,8 @@ describe('truncateCommand (via component)', () => {
|
||||
const entry = createMockEntry({ command: longCommand });
|
||||
render(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
|
||||
|
||||
// Should truncate to 57 chars + '...'
|
||||
const truncated = longCommand.slice(0, 57) + '...';
|
||||
// Should truncate to 59 chars + '…' (unicode ellipsis) = 60 total (uses shared truncateCommand)
|
||||
const truncated = longCommand.slice(0, 59) + '…';
|
||||
expect(screen.getByText(truncated)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -226,7 +226,8 @@ describe('truncateCommand (via component)', () => {
|
||||
const entry = createMockEntry({ command });
|
||||
render(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
|
||||
|
||||
const truncated = 'a'.repeat(57) + '...';
|
||||
// Should truncate to 59 chars + '…' (unicode ellipsis) = 60 total (uses shared truncateCommand)
|
||||
const truncated = 'a'.repeat(59) + '…';
|
||||
expect(screen.getByText(truncated)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -924,11 +925,11 @@ describe('Edge cases', () => {
|
||||
const entry = createMockEntry({ command: ' ' });
|
||||
render(<CommandHistoryDrawer {...createDefaultProps({ history: [entry] })} />);
|
||||
|
||||
// Should render whitespace command - getByText normalizes whitespace
|
||||
// Instead, find the paragraph element with monospace font that contains whitespace
|
||||
// Shared truncateCommand trims whitespace, so whitespace-only becomes empty string
|
||||
// The entry should still render with an empty command text
|
||||
const commandElements = document.querySelectorAll('p[style*="font-family: ui-monospace"]');
|
||||
const whitespaceCommand = Array.from(commandElements).find(el => el.textContent === ' ');
|
||||
expect(whitespaceCommand).toBeTruthy();
|
||||
const emptyCommand = Array.from(commandElements).find(el => el.textContent === '');
|
||||
expect(emptyCommand).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles negative timestamp', () => {
|
||||
|
||||
@@ -113,9 +113,9 @@ describe('OfflineQueueBanner', () => {
|
||||
const toggleButton = screen.getByRole('button', { name: /command.* queued/i });
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should truncate to 40 chars total: substring(0, 37) + '...'
|
||||
// longCommand.substring(0, 37) = "this is a very long command that shou"
|
||||
expect(screen.getByText('this is a very long command that shou...')).toBeInTheDocument();
|
||||
// Should truncate to 40 chars total: slice(0, 39) + '…' (unicode ellipsis)
|
||||
// Uses shared truncateCommand function
|
||||
expect(screen.getByText(longCommand.slice(0, 39) + '…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles command exactly at truncation boundary', () => {
|
||||
@@ -139,8 +139,8 @@ describe('OfflineQueueBanner', () => {
|
||||
const toggleButton = screen.getByRole('button', { name: /command.* queued/i });
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should truncate: 37 b's + '...'
|
||||
expect(screen.getByText('b'.repeat(37) + '...')).toBeInTheDocument();
|
||||
// Should truncate: 39 b's + '…' (unicode ellipsis) = 40 total
|
||||
expect(screen.getByText('b'.repeat(39) + '…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty command', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - formatCost: USD currency display ($1.23, <$0.01)
|
||||
* - estimateTokenCount: Estimate token count from text (~4 chars/token)
|
||||
* - truncatePath: Truncate file paths for display (.../<parent>/<current>)
|
||||
* - truncateCommand: Truncate command text for display with ellipsis
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -224,3 +225,18 @@ export function truncatePath(path: string, maxLength: number = 35): string {
|
||||
|
||||
return `...${separator}${lastTwo}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate command text for display.
|
||||
* Replaces newlines with spaces, trims whitespace, and adds ellipsis if truncated.
|
||||
*
|
||||
* @param command - The command text to truncate
|
||||
* @param maxLength - Maximum length of the returned string (default: 40)
|
||||
* @returns Truncated command string (e.g., "npm run build --...")
|
||||
*/
|
||||
export function truncateCommand(command: string, maxLength: number = 40): string {
|
||||
// Replace newlines with spaces for single-line display
|
||||
const singleLine = command.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) return singleLine;
|
||||
return singleLine.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import React, { useRef, useCallback, useState, useEffect } from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
import { triggerHaptic, HAPTIC_PATTERNS, GESTURE_THRESHOLDS } from './constants';
|
||||
import { formatRelativeTime } from '../../shared/formatters';
|
||||
import { formatRelativeTime, truncateCommand } from '../../shared/formatters';
|
||||
import { useSwipeGestures } from '../hooks/useSwipeGestures';
|
||||
import type { CommandHistoryEntry } from '../hooks/useCommandHistory';
|
||||
|
||||
@@ -37,6 +37,9 @@ const FLICK_VELOCITY_THRESHOLD = 0.5;
|
||||
/** Snap threshold - if dragged past this percentage, snap open/close */
|
||||
const SNAP_THRESHOLD = 0.3;
|
||||
|
||||
/** Maximum length for truncated command display in drawer */
|
||||
const MAX_COMMAND_LENGTH = 60;
|
||||
|
||||
export interface CommandHistoryDrawerProps {
|
||||
/** Whether the drawer is open */
|
||||
isOpen: boolean;
|
||||
@@ -52,16 +55,6 @@ export interface CommandHistoryDrawerProps {
|
||||
onClearHistory?: () => void;
|
||||
}
|
||||
|
||||
// formatRelativeTime imported from ../../shared/formatters
|
||||
|
||||
/**
|
||||
* Truncate command text for display
|
||||
*/
|
||||
function truncateCommand(command: string, maxLength = 60): string {
|
||||
if (command.length <= maxLength) return command;
|
||||
return command.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/** Width of the delete action button revealed on swipe */
|
||||
const DELETE_ACTION_WIDTH = 80;
|
||||
|
||||
@@ -276,7 +269,7 @@ function SwipeableHistoryItem({
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{truncateCommand(entry.command)}
|
||||
{truncateCommand(entry.command, MAX_COMMAND_LENGTH)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
import React from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
import type { InputMode } from './CommandInputBar';
|
||||
import { triggerHaptic } from './constants';
|
||||
|
||||
/** Minimum touch target size per Apple HIG guidelines (44pt) */
|
||||
const MIN_TOUCH_TARGET = 44;
|
||||
import { triggerHaptic, MIN_TOUCH_TARGET } from './constants';
|
||||
|
||||
/** Default minimum height for the buttons */
|
||||
const MIN_INPUT_HEIGHT = 48;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useThemeColors } from '../components/ThemeProvider';
|
||||
import { Badge } from '../components/Badge';
|
||||
import type { QueuedCommand, QueueStatus } from '../hooks/useOfflineQueue';
|
||||
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
|
||||
import { formatRelativeTime } from '../../shared/formatters';
|
||||
import { formatRelativeTime, truncateCommand } from '../../shared/formatters';
|
||||
|
||||
export interface OfflineQueueBannerProps {
|
||||
/** Queued commands */
|
||||
@@ -36,16 +36,6 @@ export interface OfflineQueueBannerProps {
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate command text for display
|
||||
*/
|
||||
function truncateCommand(command: string, maxLength: number = 40): string {
|
||||
if (command.length <= maxLength) return command;
|
||||
return command.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
// formatRelativeTime imported from ../../shared/formatters
|
||||
|
||||
export function OfflineQueueBanner({
|
||||
queue,
|
||||
status,
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
|
||||
/** Minimum touch target size per Apple HIG guidelines (44pt) */
|
||||
const MIN_TOUCH_TARGET = 44;
|
||||
import { MIN_TOUCH_TARGET } from './constants';
|
||||
|
||||
export type QuickAction = 'switch_mode';
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, { useCallback, useRef, useEffect } from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
|
||||
import type { CommandHistoryEntry } from '../hooks/useCommandHistory';
|
||||
import { truncateCommand } from '../../shared/formatters';
|
||||
|
||||
/** Maximum characters to show in a chip before truncating */
|
||||
const MAX_CHIP_LENGTH = 30;
|
||||
@@ -35,16 +36,6 @@ export interface RecentCommandChipsProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate command text for display in chip
|
||||
*/
|
||||
function truncateCommand(command: string, maxLength: number = MAX_CHIP_LENGTH): string {
|
||||
// Replace newlines with spaces for display
|
||||
const singleLine = command.replace(/\n/g, ' ').trim();
|
||||
if (singleLine.length <= maxLength) return singleLine;
|
||||
return singleLine.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* RecentCommandChips component
|
||||
*
|
||||
@@ -204,7 +195,7 @@ export function RecentCommandChips({
|
||||
</svg>
|
||||
)}
|
||||
{/* Command text */}
|
||||
<span>{truncateCommand(entry.command)}</span>
|
||||
<span>{truncateCommand(entry.command, MAX_CHIP_LENGTH)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
import type { InputMode } from './CommandInputBar';
|
||||
import { MIN_TOUCH_TARGET } from './constants';
|
||||
|
||||
/**
|
||||
* Slash command definition
|
||||
@@ -51,9 +52,6 @@ export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Minimum touch target size per Apple HIG guidelines (44pt) */
|
||||
const MIN_TOUCH_TARGET = 44;
|
||||
|
||||
export interface SlashCommandAutocompleteProps {
|
||||
/** Whether the autocomplete is visible */
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -57,6 +57,12 @@ export const SAFE_AREA_DEFAULTS = {
|
||||
right: 0,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum touch target size per Apple HIG guidelines (44pt).
|
||||
* Use this constant for all interactive elements to ensure accessibility.
|
||||
*/
|
||||
export const MIN_TOUCH_TARGET = 44;
|
||||
|
||||
/**
|
||||
* Mobile gesture detection thresholds
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user