Just a whole lot of crap

This commit is contained in:
2025-08-14 16:36:12 -05:00
parent 66b7e42275
commit 62f6d6f279
144 changed files with 41421 additions and 2458 deletions

495
src/services/LogService.js Normal file
View File

@@ -0,0 +1,495 @@
const fs = require("fs").promises;
const path = require("path");
/**
* LogService for Progress.md parsing
* Handles reading and parsing log files for the TUI View Logs screen
* Requirements: 2.1, 2.3, 2.4, 2.5
*/
class LogService {
constructor() {
this.progressFile = "Progress.md";
this.logDirectory = ".";
}
/**
* Discover available log files
* @returns {Promise<Array>} Array of log file information
*/
async getLogFiles() {
try {
const files = await fs.readdir(this.logDirectory);
const logFiles = [];
for (const file of files) {
if (
file.endsWith(".md") &&
(file.includes("Progress") || file.includes("log"))
) {
try {
const filePath = path.join(this.logDirectory, file);
const stats = await fs.stat(filePath);
const content = await fs.readFile(filePath, "utf8");
// Count operations in the file
const operationCount = (
content.match(
/^## .+ - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/gm
) || []
).length;
logFiles.push({
filename: file,
path: filePath,
size: stats.size,
createdAt: stats.birthtime,
modifiedAt: stats.mtime,
operationCount,
isMainLog: file === this.progressFile,
});
} catch (error) {
// Skip files that can't be read
continue;
}
}
}
// Sort by modification time (newest first)
return logFiles.sort(
(a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()
);
} catch (error) {
throw new Error(`Failed to discover log files: ${error.message}`);
}
}
/**
* Read Progress.md content
* @param {string} filename - Optional filename, defaults to Progress.md
* @returns {Promise<string>} Raw log file content
*/
async readLogFile(filename = null) {
const targetFile = filename || this.progressFile;
const filePath = path.isAbsolute(targetFile)
? targetFile
: path.join(this.logDirectory, targetFile);
try {
const content = await fs.readFile(filePath, "utf8");
return content;
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`Log file not found: ${targetFile}`);
}
throw new Error(`Failed to read log file: ${error.message}`);
}
}
/**
* Extract structured log entries from content
* @param {string} content - Raw log file content
* @returns {Array} Array of structured log entries
*/
parseLogContent(content) {
const entries = [];
const lines = content.split("\n");
let currentOperation = null;
let currentSection = null;
let lineIndex = 0;
while (lineIndex < lines.length) {
const line = lines[lineIndex].trim();
lineIndex++;
// Skip empty lines and markdown headers (but not operation headers that start with ##)
if (
!line ||
line === "---" ||
(line.startsWith("#") && !line.startsWith("## "))
) {
continue;
}
// Parse operation headers (## Operation Name - Timestamp)
const operationMatch = line.match(/^## (.+) - (.+)$/);
// Validate that it looks like a timestamp or at least has some timestamp-like content
if (
operationMatch &&
operationMatch[2] &&
(operationMatch[2].includes("UTC") ||
operationMatch[2].includes("-") ||
operationMatch[2].includes(":"))
) {
const [, operationType, timestamp] = operationMatch;
currentOperation = {
id: `op_${entries.length}_${Date.now()}`,
type: this._parseOperationType(operationType),
operationType: operationType,
timestamp: this._parseTimestamp(timestamp),
rawTimestamp: timestamp,
title: operationType,
level: "INFO",
message: `Started: ${operationType}`,
details: "",
configuration: {},
progress: [],
summary: {},
errors: [],
status: "unknown",
};
entries.push(currentOperation);
currentSection = "header";
continue;
}
if (!currentOperation) continue;
// Parse section headers
if (line.startsWith("**Configuration:**")) {
currentSection = "configuration";
continue;
}
if (line.startsWith("**Progress:**")) {
currentSection = "progress";
continue;
}
if (
line.startsWith("**Summary:**") ||
line.startsWith("**Rollback Summary:**")
) {
currentSection = "summary";
continue;
}
// Handle standalone error analysis sections (not part of a specific operation)
if (
line.startsWith("**Error Analysis") ||
line.startsWith("**Error Summary by Category:**") ||
line.startsWith("**Detailed Error Log:**")
) {
currentSection = "skip";
continue;
}
// Parse content based on current section (skip if section is 'skip')
if (currentSection !== "skip") {
this._parseLineContent(
line,
currentOperation,
currentSection,
entries,
lines,
lineIndex - 1
);
}
}
// Determine operation status based on summary and errors
entries.forEach((entry) => {
if (entry.errors && entry.errors.length > 0) {
entry.status = "failed";
entry.level = "ERROR";
} else if (entry.summary && Object.keys(entry.summary).length > 0) {
entry.status = "completed";
entry.level = "SUCCESS";
} else {
entry.status = "pending";
}
});
// Sort entries by timestamp (newest first)
return entries.sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
}
/**
* Filter logs by date range, operation type, and status
* @param {Array} logs - Array of log entries
* @param {Object} filters - Filter options
* @param {string} filters.dateRange - Date range filter ('today', 'yesterday', 'week', 'month', 'all')
* @param {string} filters.operationType - Operation type filter ('update', 'rollback', 'all')
* @param {string} filters.status - Status filter ('completed', 'failed', 'pending', 'all')
* @param {string} filters.searchTerm - Search term for text filtering
* @returns {Array} Filtered log entries
*/
filterLogs(logs, filters = {}) {
const {
dateRange = "all",
operationType = "all",
status = "all",
searchTerm = "",
} = filters;
let filteredLogs = [...logs];
// Date range filtering
if (dateRange !== "all") {
const now = new Date();
let startDate;
switch (dateRange) {
case "today":
startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
break;
case "yesterday":
startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - 1
);
const endDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
filteredLogs = filteredLogs.filter(
(log) => log.timestamp >= startDate && log.timestamp < endDate
);
break;
case "week":
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case "month":
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (dateRange !== "yesterday") {
filteredLogs = filteredLogs.filter((log) => log.timestamp >= startDate);
}
}
// Operation type filtering
if (operationType !== "all") {
filteredLogs = filteredLogs.filter((log) => log.type === operationType);
}
// Status filtering
if (status !== "all") {
filteredLogs = filteredLogs.filter((log) => log.status === status);
}
// Search term filtering
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filteredLogs = filteredLogs.filter((log) => {
const searchableText = [
log.title,
log.message,
log.details,
log.operationType,
JSON.stringify(log.configuration),
JSON.stringify(log.summary),
]
.join(" ")
.toLowerCase();
return searchableText.includes(searchLower);
});
}
return filteredLogs;
}
/**
* Handle pagination for large log files
* @param {Array} logs - Array of log entries
* @param {number} page - Page number (0-based)
* @param {number} pageSize - Number of entries per page
* @returns {Object} Paginated results with metadata
*/
paginateLogs(logs, page = 0, pageSize = 20) {
const totalEntries = logs.length;
const totalPages = Math.ceil(totalEntries / pageSize);
const startIndex = page * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalEntries);
const pageEntries = logs.slice(startIndex, endIndex);
return {
entries: pageEntries,
pagination: {
currentPage: page,
pageSize,
totalEntries,
totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0,
startIndex: startIndex + 1,
endIndex,
},
};
}
/**
* Parse operation type from title
* @private
* @param {string} title - Operation title
* @returns {string} Parsed operation type
*/
_parseOperationType(title) {
const titleLower = title.toLowerCase();
if (titleLower.includes("rollback")) {
return "rollback";
} else if (titleLower.includes("scheduled")) {
return "scheduled";
} else if (titleLower.includes("update")) {
return "update";
}
return "unknown";
}
/**
* Parse timestamp string into Date object
* @private
* @param {string} timestampStr - Timestamp string
* @returns {Date} Parsed date
*/
_parseTimestamp(timestampStr) {
try {
// Handle format: "2025-08-06 20:30:39 UTC"
const cleanStr = timestampStr.replace(" UTC", "Z").replace(" ", "T");
return new Date(cleanStr);
} catch (error) {
return new Date();
}
}
/**
* Parse individual line content based on section
* @private
* @param {string} line - Line content
* @param {Object} operation - Current operation object
* @param {string} section - Current section
* @param {Array} entries - All entries array
* @param {Array} lines - All lines array
* @param {number} lineIndex - Current line index
*/
_parseLineContent(line, operation, section, entries, lines, lineIndex) {
switch (section) {
case "configuration":
this._parseConfigurationLine(line, operation);
break;
case "progress":
this._parseProgressLine(line, operation, entries);
break;
case "summary":
this._parseSummaryLine(line, operation);
break;
case "errors":
this._parseErrorLine(line, operation, entries);
break;
}
}
/**
* Parse configuration lines
* @private
* @param {string} line - Configuration line
* @param {Object} operation - Operation object
*/
_parseConfigurationLine(line, operation) {
const configMatch = line.match(/^- (.+): (.+)$/);
if (configMatch) {
const [, key, value] = configMatch;
operation.configuration[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse progress lines (individual product updates)
* @private
* @param {string} line - Progress line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
*/
_parseProgressLine(line, operation, entries) {
// Parse product update lines
const updateMatch = line.match(/^- ([✅❌🔄]) \*\*(.+?)\*\* \((.+?)\)$/);
if (updateMatch) {
const [, status, productTitle, productId] = updateMatch;
const level =
status === "✅" ? "SUCCESS" : status === "🔄" ? "INFO" : "ERROR";
const progressEntry = {
id: `progress_${entries.length}_${Date.now()}`,
type: "product_update",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `Product Update: ${productTitle}`,
level,
message: `${status} ${productTitle}`,
details: `Product ID: ${productId}\nOperation: ${operation.title}`,
productTitle,
productId,
status:
status === "✅"
? "success"
: status === "🔄"
? "processing"
: "failed",
parentOperation: operation.id,
};
operation.progress.push(progressEntry);
}
}
/**
* Parse summary lines
* @private
* @param {string} line - Summary line
* @param {Object} operation - Operation object
*/
_parseSummaryLine(line, operation) {
const summaryMatch = line.match(/^- (.+): (.+)$/);
if (summaryMatch) {
const [, key, value] = summaryMatch;
operation.summary[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse error lines
* @private
* @param {string} line - Error line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
*/
_parseErrorLine(line, operation, entries) {
// Parse numbered error entries
const errorMatch = line.match(/^(\d+)\. \*\*(.+?)\*\* \((.+?)\)$/);
if (errorMatch) {
const [, errorNum, productTitle, productId] = errorMatch;
const errorEntry = {
id: `error_${entries.length}_${Date.now()}`,
type: "error",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `Error: ${productTitle}`,
level: "ERROR",
message: `Error processing ${productTitle}`,
details: `Product ID: ${productId}\nError #${errorNum}\nOperation: ${operation.title}`,
productTitle,
productId,
errorNumber: parseInt(errorNum),
parentOperation: operation.id,
};
operation.errors.push(errorEntry);
}
}
}
module.exports = LogService;

View File

@@ -0,0 +1,396 @@
const ShopifyService = require("./shopify");
/**
* Tag Analysis service for analyzing Shopify product tags
* Provides functionality to fetch, analyze, and search product tags
*/
class TagAnalysisService {
constructor() {
this.shopifyService = new ShopifyService();
this.pageSize = 50; // Consistent with ProductService
}
/**
* GraphQL query to fetch all products with their tags and basic variant info
*/
getAllProductsWithTagsQuery() {
return `
query getAllProductsWithTags($first: Int!, $after: String) {
products(first: $first, after: $after) {
edges {
node {
id
title
tags
variants(first: 100) {
edges {
node {
id
price
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
}
/**
* GraphQL query to fetch products by specific tag with detailed variant info
*/
getProductsByTagQuery() {
return `
query getProductsByTag($query: String!, $first: Int!, $after: String) {
products(first: $first, after: $after, query: $query) {
edges {
node {
id
title
tags
variants(first: 100) {
edges {
node {
id
price
compareAtPrice
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
}
/**
* Fetch all unique tags from the store with basic statistics
* @returns {Promise<Array>} Array of tag objects with basic statistics
*/
async fetchAllTags() {
console.log("Starting to fetch all product tags from store...");
const tagMap = new Map(); // Use Map to track tag statistics
let hasNextPage = true;
let cursor = null;
let pageCount = 0;
let totalProducts = 0;
try {
while (hasNextPage) {
pageCount++;
console.log(
`Fetching page ${pageCount} of products for tag analysis...`
);
const variables = {
first: this.pageSize,
after: cursor,
};
const response = await this.shopifyService.executeWithRetry(() =>
this.shopifyService.executeQuery(
this.getAllProductsWithTagsQuery(),
variables
)
);
if (!response.products) {
throw new Error("Invalid response structure: missing products field");
}
const { edges, pageInfo } = response.products;
// Process products from this page
for (const edge of edges) {
const product = edge.node;
totalProducts++;
// Process each tag for this product
if (product.tags && Array.isArray(product.tags)) {
for (const tag of product.tags) {
if (!tagMap.has(tag)) {
tagMap.set(tag, {
tag: tag,
productCount: 0,
variantCount: 0,
totalValue: 0,
products: [],
});
}
const tagData = tagMap.get(tag);
tagData.productCount++;
// Add product variants to tag statistics
if (product.variants && product.variants.edges) {
for (const variantEdge of product.variants.edges) {
const variant = variantEdge.node;
tagData.variantCount++;
// Add to total value if price is valid
if (variant.price && !isNaN(parseFloat(variant.price))) {
tagData.totalValue += parseFloat(variant.price);
}
}
}
// Store product reference (limited to avoid memory issues)
if (tagData.products.length < 10) {
tagData.products.push({
id: product.id,
title: product.title,
variantCount: product.variants
? product.variants.edges.length
: 0,
});
}
}
}
}
// Update pagination info
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
// Log progress for large datasets
if (totalProducts > 0 && totalProducts % 100 === 0) {
console.log(`Processed ${totalProducts} products so far...`);
}
}
// Convert Map to Array and add calculated statistics
const tags = Array.from(tagMap.values()).map((tagData) => ({
...tagData,
averagePrice:
tagData.variantCount > 0
? tagData.totalValue / tagData.variantCount
: 0,
priceRange: {
min: 0, // Will be calculated in getTagDetails if needed
max: 0,
},
}));
// Sort tags by product count (most popular first)
tags.sort((a, b) => b.productCount - a.productCount);
console.log(
`Successfully fetched ${tags.length} unique tags from ${totalProducts} products`
);
return tags;
} catch (error) {
console.error(`Failed to fetch tags: ${error.message}`);
throw new Error(`Tag fetching failed: ${error.message}`);
}
}
/**
* Get detailed information about products with a specific tag
* @param {string} tag - Tag to analyze
* @returns {Promise<Object>} Detailed tag analysis
*/
async getTagDetails(tag) {
console.log(`Fetching detailed analysis for tag: ${tag}`);
const products = [];
let hasNextPage = true;
let cursor = null;
let pageCount = 0;
try {
while (hasNextPage) {
pageCount++;
console.log(`Fetching page ${pageCount} for tag "${tag}"...`);
const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`;
const variables = {
query: queryString,
first: this.pageSize,
after: cursor,
};
const response = await this.shopifyService.executeWithRetry(() =>
this.shopifyService.executeQuery(
this.getProductsByTagQuery(),
variables
)
);
if (!response.products) {
throw new Error("Invalid response structure: missing products field");
}
const { edges, pageInfo } = response.products;
// Process products from this page
const pageProducts = edges.map((edge) => ({
id: edge.node.id,
title: edge.node.title,
tags: edge.node.tags,
variants: edge.node.variants.edges.map((variantEdge) => ({
id: variantEdge.node.id,
price: parseFloat(variantEdge.node.price),
compareAtPrice: variantEdge.node.compareAtPrice
? parseFloat(variantEdge.node.compareAtPrice)
: null,
title: variantEdge.node.title,
})),
}));
products.push(...pageProducts);
// Update pagination info
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
}
// Calculate detailed statistics
const statistics = this.calculateTagStatistics(products);
console.log(
`Found ${products.length} products with tag "${tag}" (${statistics.variantCount} variants)`
);
return {
tag: tag,
...statistics,
products: products,
};
} catch (error) {
console.error(`Failed to get tag details for "${tag}": ${error.message}`);
throw new Error(`Tag analysis failed: ${error.message}`);
}
}
/**
* Calculate comprehensive statistics for a set of products
* @param {Array} products - Array of products to analyze
* @returns {Object} Calculated statistics
*/
calculateTagStatistics(products) {
if (!products || products.length === 0) {
return {
productCount: 0,
variantCount: 0,
totalValue: 0,
averagePrice: 0,
priceRange: { min: 0, max: 0 },
};
}
let totalValue = 0;
let variantCount = 0;
let minPrice = Infinity;
let maxPrice = -Infinity;
for (const product of products) {
if (product.variants && Array.isArray(product.variants)) {
for (const variant of product.variants) {
if (typeof variant.price === "number" && !isNaN(variant.price)) {
variantCount++;
totalValue += variant.price;
if (variant.price < minPrice) minPrice = variant.price;
if (variant.price > maxPrice) maxPrice = variant.price;
}
}
}
}
// Handle case where no valid prices were found
if (variantCount === 0) {
minPrice = 0;
maxPrice = 0;
}
return {
productCount: products.length,
variantCount: variantCount,
totalValue: totalValue,
averagePrice: variantCount > 0 ? totalValue / variantCount : 0,
priceRange: {
min: minPrice === Infinity ? 0 : minPrice,
max: maxPrice === -Infinity ? 0 : maxPrice,
},
};
}
/**
* Search and filter tags by query string
* @param {Array} tags - Array of tag objects to search
* @param {string} query - Search query
* @returns {Array} Filtered array of tags
*/
searchTags(tags, query) {
if (!query || query.trim() === "") {
return tags;
}
const searchTerm = query.toLowerCase().trim();
return tags.filter((tagData) => {
// Search in tag name
if (tagData.tag.toLowerCase().includes(searchTerm)) {
return true;
}
// Search in product titles (if available)
if (tagData.products && Array.isArray(tagData.products)) {
return tagData.products.some((product) =>
product.title.toLowerCase().includes(searchTerm)
);
}
return false;
});
}
/**
* Get a summary of tag analysis results
* @param {Array} tags - Array of tag objects
* @returns {Object} Summary statistics
*/
getTagAnalysisSummary(tags) {
if (!tags || tags.length === 0) {
return {
totalTags: 0,
totalProducts: 0,
totalVariants: 0,
totalValue: 0,
averageProductsPerTag: 0,
averageVariantsPerTag: 0,
};
}
const totalProducts = tags.reduce((sum, tag) => sum + tag.productCount, 0);
const totalVariants = tags.reduce((sum, tag) => sum + tag.variantCount, 0);
const totalValue = tags.reduce((sum, tag) => sum + tag.totalValue, 0);
return {
totalTags: tags.length,
totalProducts: totalProducts,
totalVariants: totalVariants,
totalValue: totalValue,
averageProductsPerTag: totalProducts / tags.length,
averageVariantsPerTag: totalVariants / tags.length,
};
}
}
module.exports = TagAnalysisService;

501
src/services/logReader.js Normal file
View File

@@ -0,0 +1,501 @@
const fs = require("fs").promises;
const path = require("path");
/**
* LogReader Service
* Handles reading and parsing log files for the TUI LogViewer
* Requirements: 6.1, 6.4, 10.3
*/
class LogReaderService {
constructor(progressFilePath = "Progress.md") {
this.progressFilePath = progressFilePath;
this.cache = {
entries: [],
lastModified: null,
isValid: false,
};
}
/**
* Read and parse log entries from the progress file
* @returns {Promise<Array>} Array of parsed log entries
*/
async readLogEntries() {
try {
// Check if file exists
const stats = await fs.stat(this.progressFilePath);
// Check cache validity
if (
this.cache.isValid &&
this.cache.lastModified &&
stats.mtime.getTime() === this.cache.lastModified.getTime()
) {
return this.cache.entries;
}
// Read and parse file
const content = await fs.readFile(this.progressFilePath, "utf8");
const entries = this.parseLogContent(content);
// Update cache
this.cache = {
entries,
lastModified: stats.mtime,
isValid: true,
};
return entries;
} catch (error) {
if (error.code === "ENOENT") {
// File doesn't exist, return empty array
return [];
}
throw error;
}
}
/**
* Parse log content into structured entries
* @param {string} content - Raw log file content
* @returns {Array} Parsed log entries
*/
parseLogContent(content) {
const entries = [];
const lines = content.split("\n");
let currentOperation = null;
let currentSection = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines and headers
if (!line || line.startsWith("#") || line === "---") {
continue;
}
// Parse operation headers
const operationMatch = line.match(/^## (.+) - (.+)$/);
if (operationMatch) {
const [, operationType, timestamp] = operationMatch;
currentOperation = {
id: `op_${entries.length}`,
type: this.parseOperationType(operationType),
timestamp: this.parseTimestamp(timestamp),
rawTimestamp: timestamp,
title: operationType,
level: "INFO",
message: `Started: ${operationType}`,
details: "",
section: "operation_start",
configuration: {},
progress: [],
summary: null,
errors: [],
};
entries.push(currentOperation);
currentSection = "header";
continue;
}
// Parse configuration section
if (line.startsWith("**Configuration:**")) {
currentSection = "configuration";
continue;
}
// Parse progress section
if (line.startsWith("**Progress:**")) {
currentSection = "progress";
continue;
}
// Parse summary sections
if (
line.startsWith("**Summary:**") ||
line.startsWith("**Rollback Summary:**")
) {
currentSection = "summary";
continue;
}
// Parse error analysis section
if (
line.startsWith("**Error Analysis") ||
line.startsWith("**Detailed Error Log:**")
) {
currentSection = "errors";
continue;
}
// Parse content based on current section
if (currentOperation && currentSection) {
this.parseLineContent(line, currentOperation, currentSection, entries);
}
}
// Sort entries by timestamp (newest first)
return entries.sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
}
/**
* Parse individual line content based on section
* @param {string} line - Line content
* @param {Object} operation - Current operation object
* @param {string} section - Current section
* @param {Array} entries - All entries array
*/
parseLineContent(line, operation, section, entries) {
switch (section) {
case "configuration":
this.parseConfigurationLine(line, operation);
break;
case "progress":
this.parseProgressLine(line, operation, entries);
break;
case "summary":
this.parseSummaryLine(line, operation);
break;
case "errors":
this.parseErrorLine(line, operation, entries);
break;
}
}
/**
* Parse configuration lines
* @param {string} line - Configuration line
* @param {Object} operation - Operation object
*/
parseConfigurationLine(line, operation) {
const configMatch = line.match(/^- (.+): (.+)$/);
if (configMatch) {
const [, key, value] = configMatch;
operation.configuration[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse progress lines (individual product updates)
* @param {string} line - Progress line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
*/
parseProgressLine(line, operation, entries) {
// Parse product update lines
const updateMatch = line.match(/^- ([✅❌🔄]) \*\*(.+?)\*\* \((.+?)\)$/);
if (updateMatch) {
const [, status, productTitle, productId] = updateMatch;
const level =
status === "✅" ? "SUCCESS" : status === "🔄" ? "INFO" : "ERROR";
const entry = {
id: `entry_${entries.length}`,
type: "product_update",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `Product Update: ${productTitle}`,
level,
message: `${status} ${productTitle}`,
details: `Product ID: ${productId}\nOperation: ${operation.title}`,
section: "progress",
productTitle,
productId,
status,
parentOperation: operation.id,
};
// Parse additional details from following lines
const detailsLines = [];
let nextLineIndex = 1;
while (operation.progress.length + nextLineIndex < 10) {
// Limit lookahead
const nextLine = line; // This would need to be passed differently in real implementation
if (nextLine && nextLine.startsWith(" - ")) {
detailsLines.push(nextLine.substring(4));
nextLineIndex++;
} else {
break;
}
}
if (detailsLines.length > 0) {
entry.details += "\n" + detailsLines.join("\n");
}
entries.push(entry);
operation.progress.push(entry);
}
}
/**
* Parse summary lines
* @param {string} line - Summary line
* @param {Object} operation - Operation object
*/
parseSummaryLine(line, operation) {
const summaryMatch = line.match(/^- (.+): (.+)$/);
if (summaryMatch) {
const [, key, value] = summaryMatch;
if (!operation.summary) {
operation.summary = {};
}
operation.summary[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse error lines
* @param {string} line - Error line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
*/
parseErrorLine(line, operation, entries) {
// Parse numbered error entries
const errorMatch = line.match(/^(\d+)\. \*\*(.+?)\*\* \((.+?)\)$/);
if (errorMatch) {
const [, errorNum, productTitle, productId] = errorMatch;
const entry = {
id: `error_${entries.length}`,
type: "error",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `Error: ${productTitle}`,
level: "ERROR",
message: `Error processing ${productTitle}`,
details: `Product ID: ${productId}\nError #${errorNum}\nOperation: ${operation.title}`,
section: "error",
productTitle,
productId,
errorNumber: parseInt(errorNum),
parentOperation: operation.id,
};
entries.push(entry);
operation.errors.push(entry);
}
}
/**
* Parse operation type from title
* @param {string} title - Operation title
* @returns {string} Parsed operation type
*/
parseOperationType(title) {
if (title.toLowerCase().includes("rollback")) {
return "rollback";
} else if (title.toLowerCase().includes("update")) {
return "update";
} else if (title.toLowerCase().includes("scheduled")) {
return "scheduled";
}
return "unknown";
}
/**
* Parse timestamp string into Date object
* @param {string} timestampStr - Timestamp string
* @returns {Date} Parsed date
*/
parseTimestamp(timestampStr) {
try {
// Handle format: "2025-08-06 19:30:21 UTC"
const cleanStr = timestampStr.replace(" UTC", "Z").replace(" ", "T");
return new Date(cleanStr);
} catch (error) {
return new Date();
}
}
/**
* Get paginated log entries
* @param {Object} options - Pagination options
* @param {number} options.page - Page number (0-based)
* @param {number} options.pageSize - Number of entries per page
* @param {string} options.levelFilter - Filter by log level
* @param {string} options.searchTerm - Search term for filtering
* @returns {Promise<Object>} Paginated results
*/
async getPaginatedEntries(options = {}) {
const {
page = 0,
pageSize = 20,
levelFilter = "ALL",
searchTerm = "",
} = options;
const allEntries = await this.readLogEntries();
// Apply filters
let filteredEntries = allEntries;
// Level filter
if (levelFilter !== "ALL") {
filteredEntries = filteredEntries.filter(
(entry) => entry.level === levelFilter
);
}
// Search filter with enhanced capabilities
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
filteredEntries = filteredEntries.filter((entry) => {
// Basic text search
const basicMatch =
entry.message.toLowerCase().includes(searchLower) ||
entry.title.toLowerCase().includes(searchLower) ||
entry.details.toLowerCase().includes(searchLower) ||
(entry.productTitle &&
entry.productTitle.toLowerCase().includes(searchLower));
// Type-specific search
const typeMatch =
entry.type && entry.type.toLowerCase().includes(searchLower);
// Configuration search
const configMatch =
entry.configuration &&
Object.values(entry.configuration).some((value) =>
value.toLowerCase().includes(searchLower)
);
// Date-based search (e.g., "today", "yesterday")
let dateMatch = false;
if (searchLower === "today") {
const today = new Date();
dateMatch = entry.timestamp.toDateString() === today.toDateString();
} else if (searchLower === "yesterday") {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
dateMatch =
entry.timestamp.toDateString() === yesterday.toDateString();
}
return basicMatch || typeMatch || configMatch || dateMatch;
});
}
// Calculate pagination
const totalEntries = filteredEntries.length;
const totalPages = Math.ceil(totalEntries / pageSize);
const startIndex = page * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalEntries);
const pageEntries = filteredEntries.slice(startIndex, endIndex);
return {
entries: pageEntries,
pagination: {
currentPage: page,
pageSize,
totalEntries,
totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0,
startIndex: startIndex + 1,
endIndex,
},
filters: {
levelFilter,
searchTerm,
},
};
}
/**
* Get log statistics
* @returns {Promise<Object>} Log statistics
*/
async getLogStatistics() {
const entries = await this.readLogEntries();
const stats = {
totalEntries: entries.length,
byLevel: {},
byType: {},
dateRange: {
oldest: null,
newest: null,
},
operations: {
total: 0,
successful: 0,
failed: 0,
rollbacks: 0,
},
};
entries.forEach((entry) => {
// Count by level
stats.byLevel[entry.level] = (stats.byLevel[entry.level] || 0) + 1;
// Count by type
stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1;
// Track date range
if (!stats.dateRange.oldest || entry.timestamp < stats.dateRange.oldest) {
stats.dateRange.oldest = entry.timestamp;
}
if (!stats.dateRange.newest || entry.timestamp > stats.dateRange.newest) {
stats.dateRange.newest = entry.timestamp;
}
// Count operations
if (entry.section === "operation_start") {
stats.operations.total++;
if (entry.type === "rollback") {
stats.operations.rollbacks++;
}
// Determine success based on summary or errors
if (entry.summary && entry.errors.length === 0) {
stats.operations.successful++;
} else if (entry.errors.length > 0) {
stats.operations.failed++;
}
}
});
return stats;
}
/**
* Clear cache to force refresh on next read
*/
clearCache() {
this.cache.isValid = false;
}
/**
* Watch for file changes and clear cache
* @param {Function} callback - Callback to call when file changes
* @returns {Function} Cleanup function
*/
watchFile(callback) {
const fs = require("fs");
try {
const watcher = fs.watchFile(this.progressFilePath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
this.clearCache();
if (callback) {
callback();
}
}
});
// Return cleanup function
return () => {
fs.unwatchFile(this.progressFilePath);
};
} catch (error) {
// File watching not available, return no-op cleanup
return () => {};
}
}
}
module.exports = LogReaderService;

View File

@@ -0,0 +1,345 @@
const fs = require("fs").promises;
const path = require("path");
/**
* ScheduleService - Manages scheduled operations with JSON persistence
* Handles CRUD operations for schedule management in the TUI
*/
class ScheduleService {
constructor() {
this.schedulesFile = path.join(process.cwd(), "schedules.json");
}
/**
* Load schedules from JSON file
* @returns {Promise<Array>} Array of schedule objects
*/
async loadSchedules() {
try {
const data = await fs.readFile(this.schedulesFile, "utf8");
const schedules = JSON.parse(data);
// Ensure all schedules have required properties and convert date strings back to Date objects
return schedules.map((schedule) => ({
...schedule,
scheduledTime: new Date(schedule.scheduledTime),
createdAt: new Date(schedule.createdAt),
lastExecuted: schedule.lastExecuted
? new Date(schedule.lastExecuted)
: null,
nextExecution: schedule.nextExecution
? new Date(schedule.nextExecution)
: null,
}));
} catch (error) {
if (error.code === "ENOENT") {
// File doesn't exist, return empty array
return [];
}
throw new Error(`Failed to load schedules: ${error.message}`);
}
}
/**
* Save schedules to JSON file
* @param {Array} schedules - Array of schedule objects
* @returns {Promise<void>}
*/
async saveSchedules(schedules) {
try {
// Convert Date objects to ISO strings for JSON serialization
const serializedSchedules = schedules.map((schedule) => ({
...schedule,
scheduledTime: schedule.scheduledTime.toISOString(),
createdAt: schedule.createdAt.toISOString(),
lastExecuted: schedule.lastExecuted
? schedule.lastExecuted.toISOString()
: null,
nextExecution: schedule.nextExecution
? schedule.nextExecution.toISOString()
: null,
}));
await fs.writeFile(
this.schedulesFile,
JSON.stringify(serializedSchedules, null, 2),
"utf8"
);
} catch (error) {
throw new Error(`Failed to save schedules: ${error.message}`);
}
}
/**
* Add a new schedule
* @param {Object} schedule - Schedule object to add
* @returns {Promise<Object>} The added schedule with generated ID
*/
async addSchedule(schedule) {
const validationError = this.validateSchedule(schedule);
if (validationError) {
throw new Error(`Invalid schedule: ${validationError}`);
}
const schedules = await this.loadSchedules();
// Generate unique ID
const id = this._generateId(schedules);
// Create new schedule with defaults
const newSchedule = {
id,
operationType: schedule.operationType,
scheduledTime: new Date(schedule.scheduledTime),
recurrence: schedule.recurrence || "once",
enabled: schedule.enabled !== undefined ? schedule.enabled : true,
config: schedule.config || {},
status: "pending",
createdAt: new Date(),
lastExecuted: null,
nextExecution: this._calculateNextExecution(
new Date(schedule.scheduledTime),
schedule.recurrence || "once"
),
};
schedules.push(newSchedule);
await this.saveSchedules(schedules);
return newSchedule;
}
/**
* Update an existing schedule
* @param {string} id - Schedule ID to update
* @param {Object} updates - Updates to apply
* @returns {Promise<Object>} The updated schedule
*/
async updateSchedule(id, updates) {
const schedules = await this.loadSchedules();
const scheduleIndex = schedules.findIndex((s) => s.id === id);
if (scheduleIndex === -1) {
throw new Error(`Schedule with ID ${id} not found`);
}
// Merge updates with existing schedule
const updatedSchedule = {
...schedules[scheduleIndex],
...updates,
};
// Validate the updated schedule
const validationError = this.validateSchedule(updatedSchedule);
if (validationError) {
throw new Error(`Invalid schedule update: ${validationError}`);
}
// Ensure dates are Date objects
if (updates.scheduledTime) {
updatedSchedule.scheduledTime = new Date(updates.scheduledTime);
updatedSchedule.nextExecution = this._calculateNextExecution(
updatedSchedule.scheduledTime,
updatedSchedule.recurrence
);
}
schedules[scheduleIndex] = updatedSchedule;
await this.saveSchedules(schedules);
return updatedSchedule;
}
/**
* Delete a schedule
* @param {string} id - Schedule ID to delete
* @returns {Promise<boolean>} True if deleted, false if not found
*/
async deleteSchedule(id) {
const schedules = await this.loadSchedules();
const initialLength = schedules.length;
const filteredSchedules = schedules.filter((s) => s.id !== id);
if (filteredSchedules.length === initialLength) {
return false; // Schedule not found
}
await this.saveSchedules(filteredSchedules);
return true;
}
/**
* Validate schedule data
* @param {Object} schedule - Schedule object to validate
* @returns {string|null} Error message if invalid, null if valid
*/
validateSchedule(schedule) {
if (!schedule) {
return "Schedule object is required";
}
// Validate operation type
if (!schedule.operationType) {
return "Operation type is required";
}
if (!["update", "rollback"].includes(schedule.operationType)) {
return 'Operation type must be "update" or "rollback"';
}
// Validate scheduled time
if (!schedule.scheduledTime) {
return "Scheduled time is required";
}
const scheduledTime = new Date(schedule.scheduledTime);
if (isNaN(scheduledTime.getTime())) {
return "Scheduled time must be a valid date";
}
// Check if scheduled time is in the future (for new schedules)
if (!schedule.id && scheduledTime <= new Date()) {
return "Scheduled time must be in the future";
}
// Validate recurrence
if (
schedule.recurrence &&
!["once", "daily", "weekly", "monthly"].includes(schedule.recurrence)
) {
return "Recurrence must be one of: once, daily, weekly, monthly";
}
// Validate status
if (
schedule.status &&
!["pending", "completed", "failed", "cancelled"].includes(schedule.status)
) {
return "Status must be one of: pending, completed, failed, cancelled";
}
// Validate enabled flag
if (
schedule.enabled !== undefined &&
typeof schedule.enabled !== "boolean"
) {
return "Enabled must be a boolean value";
}
// Validate config object
if (schedule.config && typeof schedule.config !== "object") {
return "Config must be an object";
}
return null; // Valid
}
/**
* Generate unique ID for new schedule
* @param {Array} existingSchedules - Array of existing schedules
* @returns {string} Unique ID
* @private
*/
_generateId(existingSchedules) {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 9);
let id = `schedule_${timestamp}_${random}`;
// Ensure uniqueness (very unlikely collision, but safety check)
while (existingSchedules.some((s) => s.id === id)) {
const newRandom = Math.random().toString(36).substr(2, 9);
id = `schedule_${timestamp}_${newRandom}`;
}
return id;
}
/**
* Calculate next execution time based on recurrence
* @param {Date} scheduledTime - Original scheduled time
* @param {string} recurrence - Recurrence pattern
* @returns {Date|null} Next execution time or null for 'once'
* @private
*/
_calculateNextExecution(scheduledTime, recurrence) {
if (recurrence === "once") {
return null;
}
const nextExecution = new Date(scheduledTime);
switch (recurrence) {
case "daily":
nextExecution.setDate(nextExecution.getDate() + 1);
break;
case "weekly":
nextExecution.setDate(nextExecution.getDate() + 7);
break;
case "monthly":
nextExecution.setMonth(nextExecution.getMonth() + 1);
break;
default:
return null;
}
return nextExecution;
}
/**
* Get schedules by status
* @param {string} status - Status to filter by
* @returns {Promise<Array>} Filtered schedules
*/
async getSchedulesByStatus(status) {
const schedules = await this.loadSchedules();
return schedules.filter((schedule) => schedule.status === status);
}
/**
* Get schedules by operation type
* @param {string} operationType - Operation type to filter by
* @returns {Promise<Array>} Filtered schedules
*/
async getSchedulesByOperationType(operationType) {
const schedules = await this.loadSchedules();
return schedules.filter(
(schedule) => schedule.operationType === operationType
);
}
/**
* Get enabled schedules
* @returns {Promise<Array>} Enabled schedules
*/
async getEnabledSchedules() {
const schedules = await this.loadSchedules();
return schedules.filter((schedule) => schedule.enabled);
}
/**
* Mark schedule as completed
* @param {string} id - Schedule ID
* @returns {Promise<Object>} Updated schedule
*/
async markScheduleCompleted(id) {
return await this.updateSchedule(id, {
status: "completed",
lastExecuted: new Date(),
});
}
/**
* Mark schedule as failed
* @param {string} id - Schedule ID
* @param {string} errorMessage - Error message
* @returns {Promise<Object>} Updated schedule
*/
async markScheduleFailed(id, errorMessage) {
return await this.updateSchedule(id, {
status: "failed",
lastExecuted: new Date(),
errorMessage: errorMessage,
});
}
}
module.exports = ScheduleService;

509
src/services/tagAnalysis.js Normal file
View File

@@ -0,0 +1,509 @@
const ProductService = require("./product");
const ProgressService = require("./progress");
/**
* Tag Analysis Service
* Provides comprehensive analysis of product tags for price update operations
* Requirements: 7.1, 7.2, 7.3, 7.4
*/
class TagAnalysisService {
constructor() {
this.productService = new ProductService();
this.progressService = new ProgressService();
this.cache = new Map();
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
}
/**
* Get comprehensive tag analysis for the store
* @param {number} limit - Maximum number of products to analyze (default: 250)
* @returns {Promise<Object>} Tag analysis results
*/
async getTagAnalysis(limit = 250) {
const cacheKey = `tag_analysis_${limit}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheExpiry) {
return cached.data;
}
try {
await this.progressService.info("Starting tag analysis...");
// Fetch products for analysis
const products = await this.productService.debugFetchAllProductTags(
limit
);
if (!products || products.length === 0) {
throw new Error("No products found for tag analysis");
}
// Analyze tags
const analysis = this.analyzeProductTags(products);
// Cache the results
this.cache.set(cacheKey, {
data: analysis,
timestamp: Date.now(),
});
await this.progressService.info(
`Tag analysis completed for ${products.length} products`
);
return analysis;
} catch (error) {
await this.progressService.error(`Tag analysis failed: ${error.message}`);
throw error;
}
}
/**
* Analyze product tags and generate insights
* @param {Array} products - Array of products to analyze
* @returns {Object} Analysis results
*/
analyzeProductTags(products) {
const tagCounts = new Map();
const tagPrices = new Map();
const totalProducts = products.length;
// Count tags and collect price data
products.forEach((product) => {
if (!product.tags || !Array.isArray(product.tags)) return;
product.tags.forEach((tag) => {
// Count occurrences
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
// Collect price data
if (!tagPrices.has(tag)) {
tagPrices.set(tag, []);
}
// Get prices from variants
if (product.variants && Array.isArray(product.variants)) {
product.variants.forEach((variant) => {
if (variant.price) {
const price = parseFloat(variant.price);
if (!isNaN(price)) {
tagPrices.get(tag).push(price);
}
}
});
}
});
});
// Convert to sorted arrays
const tagCountsArray = Array.from(tagCounts.entries())
.map(([tag, count]) => ({
tag,
count,
percentage: (count / totalProducts) * 100,
}))
.sort((a, b) => b.count - a.count);
// Calculate price ranges
const priceRanges = {};
tagPrices.forEach((prices, tag) => {
if (prices.length > 0) {
const sortedPrices = prices.sort((a, b) => a - b);
priceRanges[tag] = {
min: sortedPrices[0],
max: sortedPrices[sortedPrices.length - 1],
average:
prices.reduce((sum, price) => sum + price, 0) / prices.length,
count: prices.length,
};
}
});
// Generate recommendations
const recommendations = this.generateRecommendations(
tagCountsArray,
priceRanges
);
return {
totalProducts,
tagCounts: tagCountsArray,
priceRanges,
recommendations,
analyzedAt: new Date().toISOString(),
};
}
/**
* Generate recommendations based on tag analysis
* @param {Array} tagCounts - Array of tag count objects
* @param {Object} priceRanges - Price range data by tag
* @returns {Array} Array of recommendation objects
*/
generateRecommendations(tagCounts, priceRanges) {
const recommendations = [];
const totalProducts = tagCounts.reduce((sum, tag) => sum + tag.count, 0);
// High-impact tags (many products)
const highImpactTags = tagCounts
.filter(
(tag) =>
tag.count >= Math.max(20, totalProducts * 0.1) && tag.percentage >= 10
)
.slice(0, 3)
.map((tag) => ({
tag: tag.tag,
count: tag.count,
percentage: tag.percentage,
impact: this.calculateImpactScore(tag, priceRanges[tag.tag]),
}));
if (highImpactTags.length > 0) {
recommendations.push({
type: "high_impact",
title: "High-Impact Tags",
description:
"Tags with many products that would benefit most from price updates",
tags: highImpactTags.map((t) => t.tag),
details: highImpactTags,
reason:
"These tags have the highest product counts and are most likely to need price adjustments",
priority: "high",
actionable: true,
estimatedImpact: `${highImpactTags.reduce(
(sum, t) => sum + t.count,
0
)} products affected`,
});
}
// High-value tags (expensive products)
const highValueTags = tagCounts
.filter((tag) => {
const priceData = priceRanges[tag.tag];
return priceData && priceData.average > 100 && tag.count >= 5;
})
.sort(
(a, b) =>
(priceRanges[b.tag]?.average || 0) -
(priceRanges[a.tag]?.average || 0)
)
.slice(0, 3)
.map((tag) => ({
tag: tag.tag,
count: tag.count,
averagePrice: priceRanges[tag.tag]?.average || 0,
potentialRevenue: (priceRanges[tag.tag]?.average || 0) * tag.count,
}));
if (highValueTags.length > 0) {
const totalRevenue = highValueTags.reduce(
(sum, t) => sum + t.potentialRevenue,
0
);
recommendations.push({
type: "high_value",
title: "High-Value Tags",
description: "Tags with products having higher average prices",
tags: highValueTags.map((t) => t.tag),
details: highValueTags,
reason:
"These tags contain premium products where price adjustments have the most financial impact",
priority: "medium",
actionable: true,
estimatedImpact: `$${totalRevenue.toFixed(2)} total product value`,
});
}
// Optimal target tags (balanced impact and value)
const optimalTags = this.findOptimalTargetTags(tagCounts, priceRanges);
if (optimalTags.length > 0) {
recommendations.push({
type: "optimal",
title: "Recommended Target Tags",
description:
"Best balance of product count and pricing for bulk operations",
tags: optimalTags.map((t) => t.tag),
details: optimalTags,
reason:
"These tags offer the best combination of reach and value for price update operations",
priority: "high",
actionable: true,
estimatedImpact: `${optimalTags.reduce(
(sum, t) => sum + t.count,
0
)} products with balanced impact`,
});
}
// Sale/discount related tags (use caution)
const cautionTags = tagCounts
.filter((tag) => {
const tagLower = tag.tag.toLowerCase();
return (
(tagLower.includes("sale") ||
tagLower.includes("discount") ||
tagLower.includes("clearance") ||
tagLower.includes("new") ||
tagLower.includes("seasonal") ||
tagLower.includes("promo")) &&
tag.count >= 3
);
})
.slice(0, 4)
.map((tag) => ({
tag: tag.tag,
count: tag.count,
riskLevel: this.assessRiskLevel(tag.tag),
}));
if (cautionTags.length > 0) {
recommendations.push({
type: "caution",
title: "Use Caution",
description: "Tags that may require special handling",
tags: cautionTags.map((t) => t.tag),
details: cautionTags,
reason:
"These tags may have products with special pricing strategies that shouldn't be automatically adjusted",
priority: "low",
actionable: false,
estimatedImpact: "Manual review recommended before bulk operations",
});
}
// Price consistency analysis
const consistencyIssues = this.findPriceConsistencyIssues(
tagCounts,
priceRanges
);
if (consistencyIssues.length > 0) {
recommendations.push({
type: "consistency",
title: "Price Consistency Issues",
description:
"Tags with unusual price variations that may need attention",
tags: consistencyIssues.map((t) => t.tag),
details: consistencyIssues,
reason:
"These tags show unusual price ranges that might indicate pricing errors or inconsistencies",
priority: "medium",
actionable: true,
estimatedImpact: "Review and standardize pricing",
});
}
// Low-count tags (might be test or special products)
const lowCountTags = tagCounts
.filter((tag) => tag.count <= 2 && tag.count >= 1)
.slice(0, 5)
.map((tag) => ({
tag: tag.tag,
count: tag.count,
suggestion:
tag.count === 1
? "Consider if this is a test product"
: "Verify these are not test items",
}));
if (lowCountTags.length > 0) {
recommendations.push({
type: "low_count",
title: "Low-Count Tags",
description:
"Tags with very few products - verify before bulk operations",
tags: lowCountTags.map((t) => t.tag),
details: lowCountTags,
reason:
"These tags have very few products and might be test items or special cases",
priority: "info",
actionable: false,
estimatedImpact: "Manual verification recommended",
});
}
// Sort recommendations by priority
const priorityOrder = { high: 3, medium: 2, low: 1, info: 0 };
return recommendations.sort(
(a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]
);
}
/**
* Get sample products for a specific tag
* @param {string} tag - Tag to get samples for
* @param {number} limit - Maximum number of samples (default: 5)
* @returns {Promise<Array>} Array of sample products
*/
async getSampleProductsForTag(tag, limit = 5) {
try {
await this.progressService.info(
`Fetching sample products for tag: ${tag}`
);
const products = await this.productService.fetchProductsByTag(tag);
// Return limited sample with essential info
return products.slice(0, limit).map((product) => ({
id: product.id,
title: product.title,
tags: product.tags,
variants: product.variants.slice(0, 3).map((variant) => ({
id: variant.id,
title: variant.title,
price: variant.price,
compareAtPrice: variant.compareAtPrice,
})),
}));
} catch (error) {
await this.progressService.error(
`Failed to fetch sample products for tag ${tag}: ${error.message}`
);
throw error;
}
}
/**
* Clear the analysis cache
*/
clearCache() {
this.cache.clear();
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getCacheStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys()),
oldestEntry:
this.cache.size > 0
? Math.min(...Array.from(this.cache.values()).map((v) => v.timestamp))
: null,
};
}
/**
* Calculate impact score for a tag
* @param {Object} tagInfo - Tag information
* @param {Object} priceData - Price range data
* @returns {number} Impact score
*/
calculateImpactScore(tagInfo, priceData) {
const countWeight = 0.6;
const priceWeight = 0.4;
const normalizedCount = Math.min(tagInfo.count / 100, 1); // Normalize to 0-1
const normalizedPrice = priceData
? Math.min((priceData.average || 0) / 200, 1)
: 0;
return (
(normalizedCount * countWeight + normalizedPrice * priceWeight) * 100
);
}
/**
* Find optimal target tags based on balanced criteria
* @param {Array} tagCounts - Array of tag count objects
* @param {Object} priceRanges - Price range data by tag
* @returns {Array} Array of optimal tag objects
*/
findOptimalTargetTags(tagCounts, priceRanges) {
return tagCounts
.filter((tag) => {
const priceData = priceRanges[tag.tag];
// Filter for tags with reasonable count and price data
return (
tag.count >= 5 &&
tag.count <= 100 &&
priceData &&
priceData.average > 10 &&
priceData.average < 500
);
})
.map((tag) => ({
tag: tag.tag,
count: tag.count,
percentage: tag.percentage,
averagePrice: priceRanges[tag.tag]?.average || 0,
score: this.calculateOptimalScore(tag, priceRanges[tag.tag]),
}))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
/**
* Calculate optimal score for tag selection
* @param {Object} tagInfo - Tag information
* @param {Object} priceData - Price range data
* @returns {number} Optimal score
*/
calculateOptimalScore(tagInfo, priceData) {
if (!priceData) return 0;
// Factors: count (30%), price range (20%), consistency (25%), market position (25%)
const countScore = Math.min(tagInfo.count / 50, 1) * 30;
const priceScore = Math.min(priceData.average / 100, 1) * 20;
const consistencyScore =
(1 - Math.min((priceData.max - priceData.min) / priceData.average, 1)) *
25;
const marketScore =
priceData.average > 20 && priceData.average < 200 ? 25 : 10;
return countScore + priceScore + consistencyScore + marketScore;
}
/**
* Assess risk level for a tag
* @param {string} tagName - Tag name
* @returns {string} Risk level
*/
assessRiskLevel(tagName) {
const tagLower = tagName.toLowerCase();
if (tagLower.includes("sale") || tagLower.includes("clearance"))
return "high";
if (tagLower.includes("new") || tagLower.includes("seasonal"))
return "medium";
if (tagLower.includes("discount") || tagLower.includes("promo"))
return "high";
return "low";
}
/**
* Find price consistency issues
* @param {Array} tagCounts - Array of tag count objects
* @param {Object} priceRanges - Price range data by tag
* @returns {Array} Array of tags with consistency issues
*/
findPriceConsistencyIssues(tagCounts, priceRanges) {
return tagCounts
.filter((tag) => {
const priceData = priceRanges[tag.tag];
if (!priceData || tag.count < 3) return false;
// Check for unusual price variations
const priceRange = priceData.max - priceData.min;
const averagePrice = priceData.average;
const variationRatio = priceRange / averagePrice;
// Flag if price variation is more than 200% of average
return variationRatio > 2.0;
})
.slice(0, 3)
.map((tag) => ({
tag: tag.tag,
count: tag.count,
priceRange: priceRanges[tag.tag],
issue: "High price variation",
variationRatio: (
(priceRanges[tag.tag].max - priceRanges[tag.tag].min) /
priceRanges[tag.tag].average
).toFixed(2),
}));
}
}
module.exports = TagAnalysisService;

View File

@@ -2,24 +2,979 @@
/**
* TUI Entry Point
* Initializes the Ink-based Terminal User Interface
* Initializes the Ink-based Terminal User Interface with working configuration
* Requirements: 2.2, 2.5
*/
const React = require("react");
const { render } = require("ink");
const TuiApplication = require("./tui/TuiApplication.jsx");
// Initialize the TUI application
const main = () => {
const main = async () => {
try {
// Render the main TUI application
const { waitUntilExit } = render(React.createElement(TuiApplication));
console.log("🚀 Starting TUI application...");
// Use dynamic imports for ESM modules
const React = await import("react");
const { render, Text, Box, useInput } = await import("ink");
const TextInput = await import("ink-text-input");
console.log("✅ Loaded React and Ink successfully");
// Load current configuration from .env file
const loadConfiguration = () => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) {
return {
shopDomain: "",
accessToken: "",
targetTag: "",
priceAdjustment: "",
operationMode: "update",
};
}
const envContent = fs.readFileSync(envPath, "utf8");
const envVars = {};
envContent.split("\n").forEach((line) => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith("#")) {
const [key, ...valueParts] = trimmedLine.split("=");
if (key && valueParts.length > 0) {
envVars[key.trim()] = valueParts.join("=").trim();
}
}
});
return {
shopDomain: envVars.SHOPIFY_SHOP_DOMAIN || "",
accessToken: envVars.SHOPIFY_ACCESS_TOKEN || "",
targetTag: envVars.TARGET_TAG || "",
priceAdjustment: envVars.PRICE_ADJUSTMENT_PERCENTAGE || "",
operationMode: envVars.OPERATION_MODE || "update",
};
} catch (error) {
console.error("Error loading configuration:", error);
return {
shopDomain: "",
accessToken: "",
targetTag: "",
priceAdjustment: "",
operationMode: "update",
};
}
};
// Save configuration to .env file
const saveConfiguration = (config) => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
let envContent = "";
try {
envContent = fs.readFileSync(envPath, "utf8");
} catch (err) {
envContent = "";
}
const envVars = {
SHOPIFY_SHOP_DOMAIN: config.shopDomain,
SHOPIFY_ACCESS_TOKEN: config.accessToken,
TARGET_TAG: config.targetTag,
PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment,
OPERATION_MODE: config.operationMode,
};
for (const [key, value] of Object.entries(envVars)) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`^${escapedKey}=.*$`, "m");
const line = `${key}=${value}`;
if (envContent.match(regex)) {
envContent = envContent.replace(regex, line);
} else {
if (envContent && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += `${line}\n`;
}
}
fs.writeFileSync(envPath, envContent, "utf8");
return true;
} catch (error) {
console.error("Error saving configuration:", error);
return false;
}
};
// Execute operations function
const executeOperation = async (
operation,
config,
setOperationStatus,
setOperationProgress,
setOperationResults
) => {
try {
setOperationStatus(`🚀 Starting ${operation} operation...`);
setOperationProgress({
current: 0,
total: 100,
message: "Initializing...",
});
setOperationResults(null);
// Simulate progress updates
const updateProgress = (current, message) => {
setOperationProgress({ current, total: 100, message });
};
if (operation === "test") {
// Test connection
updateProgress(25, "Testing Shopify API connection...");
// Set up environment for testing
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = config.operationMode;
try {
const ShopifyService = require("../../services/shopify");
const shopifyService = new ShopifyService();
updateProgress(50, "Connecting to Shopify...");
const testResult = await shopifyService.testConnection();
updateProgress(75, "Verifying permissions...");
await new Promise((resolve) => setTimeout(resolve, 1000)); // Brief delay for UX
updateProgress(100, "Connection test complete!");
if (testResult) {
setOperationStatus("✅ Connection test successful!");
setOperationResults({
success: true,
message: "Successfully connected to Shopify API",
details: [
`Store: ${config.shopDomain}`,
"API access verified",
"All permissions working correctly",
],
});
} else {
setOperationStatus("❌ Connection test failed!");
setOperationResults({
success: false,
message: "Failed to connect to Shopify API",
details: [
"Please check your credentials",
"Verify your access token is valid",
"Ensure your store domain is correct",
],
});
}
} catch (error) {
setOperationStatus("❌ Connection test error!");
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Check your network connection",
"Verify your Shopify credentials",
"Try again in a few moments",
],
});
} finally {
// Restore original environment
process.env = originalEnv;
}
} else if (operation === "analyze") {
// Analyze products
updateProgress(25, "Fetching products with target tag...");
try {
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = config.operationMode;
const ProductService = require("../../services/product");
const productService = new ProductService();
updateProgress(50, "Analyzing product prices...");
const products = await productService.fetchProductsWithTag(
config.targetTag
);
updateProgress(75, "Calculating price changes...");
await new Promise((resolve) => setTimeout(resolve, 1000));
updateProgress(100, "Analysis complete!");
const adjustment = parseFloat(config.priceAdjustment);
let affectedProducts = 0;
let totalVariants = 0;
products.forEach((product) => {
product.variants.forEach((variant) => {
totalVariants++;
if (variant.price && parseFloat(variant.price) > 0) {
affectedProducts++;
}
});
});
setOperationStatus("✅ Product analysis complete!");
setOperationResults({
success: true,
message: `Found ${products.length} products with tag "${config.targetTag}"`,
details: [
`Total products: ${products.length}`,
`Total variants: ${totalVariants}`,
`Variants with prices: ${affectedProducts}`,
`Price adjustment: ${adjustment > 0 ? "+" : ""}${adjustment}%`,
`Operation mode: ${config.operationMode}`,
],
});
process.env = originalEnv;
} catch (error) {
setOperationStatus("❌ Analysis failed!");
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Could not fetch product data",
"Check your API credentials",
"Verify the target tag exists",
],
});
}
} else if (operation === "update" || operation === "rollback") {
// Run actual price update/rollback
updateProgress(10, "Preparing operation...");
try {
const originalEnv = { ...process.env };
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
process.env.TARGET_TAG = config.targetTag;
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
process.env.OPERATION_MODE = operation;
updateProgress(25, "Starting price operation...");
// Import and run the main application logic
const mainApp = require("../../index");
// Capture console output for progress tracking
let progressMessages = [];
const originalLog = console.log;
console.log = (...args) => {
const message = args.join(" ");
progressMessages.push(message);
originalLog(...args);
// Update progress based on log messages
if (message.includes("Fetching products")) {
updateProgress(35, "Fetching products...");
} else if (message.includes("Processing batch")) {
updateProgress(60, "Processing price updates...");
} else if (message.includes("Successfully updated")) {
updateProgress(90, "Finalizing updates...");
}
};
// Run the operation
await mainApp();
// Restore console.log
console.log = originalLog;
updateProgress(100, "Operation complete!");
setOperationStatus(
`${
operation === "update" ? "Price update" : "Rollback"
} completed successfully!`
);
setOperationResults({
success: true,
message: `${
operation === "update" ? "Price update" : "Rollback"
} operation completed`,
details: progressMessages.slice(-5), // Show last 5 log messages
});
process.env = originalEnv;
} catch (error) {
setOperationStatus(
`${
operation === "update" ? "Price update" : "Rollback"
} failed!`
);
setOperationResults({
success: false,
message: `Error: ${error.message}`,
details: [
"Operation could not complete",
"Check the console for detailed error logs",
"Verify your configuration and try again",
],
});
}
}
// Clear progress after a delay
setTimeout(() => {
setOperationProgress(null);
}, 2000);
} catch (error) {
setOperationStatus(`${operation} operation failed!`);
setOperationResults({
success: false,
message: `Unexpected error: ${error.message}`,
details: ["Please try again or check the console for more details"],
});
setOperationProgress(null);
}
};
// Create the main TUI application
const TuiApp = () => {
const [currentScreen, setCurrentScreen] = React.useState("main-menu");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [config, setConfig] = React.useState(loadConfiguration());
const [editingField, setEditingField] = React.useState(null);
const [tempValue, setTempValue] = React.useState("");
const [saveStatus, setSaveStatus] = React.useState("");
const [operationStatus, setOperationStatus] = React.useState("");
const [operationProgress, setOperationProgress] = React.useState(null);
const [operationResults, setOperationResults] = React.useState(null);
// Handle keyboard input
useInput((input, key) => {
if (editingField !== null) {
// Handle input editing mode
if (key.escape) {
setEditingField(null);
setTempValue("");
} else if (key.return) {
// Save the edited value
const fields = [
"shopDomain",
"accessToken",
"targetTag",
"priceAdjustment",
"operationMode",
];
const fieldName = fields[editingField];
setConfig((prev) => ({ ...prev, [fieldName]: tempValue }));
setEditingField(null);
setTempValue("");
}
return;
}
if (currentScreen === "main-menu") {
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(4, prev + 1));
} else if (key.return) {
const screens = [
"configuration",
"operation",
"scheduling",
"logs",
"tag-analysis",
];
if (selectedIndex < screens.length) {
setCurrentScreen(screens[selectedIndex]);
setSelectedIndex(0);
}
}
} else if (currentScreen === "configuration") {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(6, prev + 1));
} else if (key.return) {
if (selectedIndex < 5) {
// Edit field
const fields = [
"shopDomain",
"accessToken",
"targetTag",
"priceAdjustment",
"operationMode",
];
const fieldName = fields[selectedIndex];
setEditingField(selectedIndex);
setTempValue(config[fieldName]);
} else if (selectedIndex === 5) {
// Save configuration
const saved = saveConfiguration(config);
setSaveStatus(
saved
? "✅ Configuration saved successfully!"
: "❌ Failed to save configuration"
);
setTimeout(() => setSaveStatus(""), 3000);
} else if (selectedIndex === 6) {
// Back to menu
setCurrentScreen("main-menu");
setSelectedIndex(0);
}
}
} else if (currentScreen === "operation") {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
// Clear operation state when leaving
setOperationStatus("");
setOperationProgress(null);
setOperationResults(null);
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(3, prev + 1));
} else if (key.return) {
// Execute selected operation
const operations = ["update", "rollback", "test", "analyze"];
const selectedOperation = operations[selectedIndex];
executeOperation(
selectedOperation,
config,
setOperationStatus,
setOperationProgress,
setOperationResults
);
}
} else {
if (key.escape) {
setCurrentScreen("main-menu");
setSelectedIndex(0);
}
}
});
if (currentScreen === "main-menu") {
const menuItems = [
"⚙️ Configuration",
"🔧 Operations",
"📅 Scheduling",
"📋 View Logs",
"🏷️ Tag Analysis",
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🎉 Shopify Price Updater TUI"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Use ↑/↓ arrows to navigate, Enter to select, Esc to go back"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "blue",
padding: 1,
},
React.createElement(
Text,
{ color: "blue", bold: true },
"Main Menu"
),
...menuItems.map((item, index) =>
React.createElement(
Text,
{
key: index,
color: index === selectedIndex ? "black" : "white",
backgroundColor: index === selectedIndex ? "blue" : undefined,
marginLeft: 1,
},
`${index === selectedIndex ? "► " : " "}${item}`
)
)
),
React.createElement(
Box,
{
marginTop: 1,
borderStyle: "single",
borderColor: "green",
padding: 1,
},
React.createElement(Text, { color: "green" }, "✅ Status: Ready"),
React.createElement(Text, { color: "gray" }, "Press Ctrl+C to exit")
)
);
}
// Configuration screen with working input fields
if (currentScreen === "configuration") {
const fields = [
{
key: "shopDomain",
label: "Shopify Domain",
placeholder: "your-store.myshopify.com",
},
{
key: "accessToken",
label: "Access Token",
placeholder: "shpat_...",
secret: true,
},
{ key: "targetTag", label: "Target Tag", placeholder: "sale" },
{
key: "priceAdjustment",
label: "Price Adjustment %",
placeholder: "10",
},
{
key: "operationMode",
label: "Operation Mode",
placeholder: "update/rollback",
},
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"⚙️ Configuration"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Edit your Shopify store settings (Press Enter to edit, Esc to cancel)"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "yellow", bold: true },
"📋 Current Configuration:"
),
...fields.map((field, index) => {
const isSelected = selectedIndex === index;
const isEditing = editingField === index;
const value = config[field.key] || "";
const displayValue =
field.secret && value ? "*".repeat(value.length) : value;
return React.createElement(
Box,
{ key: field.key, marginLeft: 2, marginY: 0 },
React.createElement(
Text,
{
color: isSelected ? "blue" : "white",
backgroundColor: isSelected ? "gray" : undefined,
bold: isSelected,
},
`${isSelected ? "► " : " "}${field.label}: `
),
isEditing
? React.createElement(TextInput.default, {
value: tempValue,
placeholder: field.placeholder,
onChange: setTempValue,
mask: field.secret ? "*" : undefined,
})
: React.createElement(
Text,
{ color: value ? "green" : "red" },
value ? displayValue : "[Not configured]"
)
);
})
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "green",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{
color: selectedIndex === 5 ? "black" : "green",
backgroundColor: selectedIndex === 5 ? "green" : undefined,
bold: selectedIndex === 5,
},
`${selectedIndex === 5 ? "► " : " "}💾 Save Configuration`
),
React.createElement(
Text,
{
color: selectedIndex === 6 ? "black" : "blue",
backgroundColor: selectedIndex === 6 ? "blue" : undefined,
bold: selectedIndex === 6,
},
`${selectedIndex === 6 ? "► " : " "}🔙 Back to Menu`
)
),
saveStatus &&
React.createElement(
Text,
{
color: saveStatus.includes("✅") ? "green" : "red",
marginBottom: 1,
},
saveStatus
),
React.createElement(
Text,
{ color: "gray" },
editingField !== null
? "Type your value and press Enter to save, Esc to cancel"
: "Use ↑/↓ to navigate, Enter to edit/select, Esc to go back"
)
);
}
// Operations screen
if (currentScreen === "operation") {
const isConfigured =
config.shopDomain &&
config.accessToken &&
config.targetTag &&
config.priceAdjustment;
const operations = [
{
key: "update",
label: "Update Prices",
description: "Apply percentage adjustment to product prices",
icon: "💰",
},
{
key: "rollback",
label: "Rollback Prices",
description: "Revert prices to compare-at values",
icon: "↩️",
},
{
key: "test",
label: "Test Connection",
description: "Verify Shopify API access and credentials",
icon: "🔗",
},
{
key: "analyze",
label: "Analyze Products",
description: "Preview products that will be affected",
icon: "📊",
},
];
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"🔧 Operations"
),
React.createElement(
Text,
{ color: "gray", marginBottom: 1 },
"Select and execute price update operations"
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: isConfigured ? "green" : "red",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: isConfigured ? "green" : "red", bold: true },
isConfigured
? "✅ Configuration Status: Ready"
: "⚠️ Configuration Status: Incomplete"
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Domain: ${config.shopDomain}`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Tag: ${config.targetTag}`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Adjustment: ${config.priceAdjustment}%`
),
isConfigured &&
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
`Mode: ${config.operationMode}`
)
),
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: "blue",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "blue", bold: true },
"🚀 Select Operation:"
),
...operations.map((operation, index) => {
const isSelected = selectedIndex === index;
const isEnabled = isConfigured || operation.key === "test";
return React.createElement(
Box,
{ key: operation.key, marginLeft: 1, marginY: 0 },
React.createElement(
Text,
{
color: isSelected ? "black" : isEnabled ? "white" : "gray",
backgroundColor: isSelected ? "blue" : undefined,
bold: isSelected,
},
`${isSelected ? "► " : " "}${operation.icon} ${
operation.label
}`
),
React.createElement(
Text,
{
color: isEnabled ? "gray" : "darkGray",
marginLeft: 4,
},
operation.description
)
);
})
),
// Operation status and progress
operationStatus &&
React.createElement(
Box,
{
flexDirection: "column",
borderStyle: "single",
borderColor: operationStatus.includes("✅")
? "green"
: operationStatus.includes("❌")
? "red"
: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{
color: operationStatus.includes("✅")
? "green"
: operationStatus.includes("❌")
? "red"
: "yellow",
bold: true,
},
operationStatus
),
operationProgress &&
React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{ color: "gray" },
operationProgress.message
),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 0 },
React.createElement(Text, { color: "blue" }, "Progress: "),
React.createElement(
Text,
{ color: "white" },
"█".repeat(Math.floor(operationProgress.current / 5))
),
React.createElement(
Text,
{ color: "gray" },
"░".repeat(20 - Math.floor(operationProgress.current / 5))
),
React.createElement(
Text,
{ color: "blue", marginLeft: 1 },
`${operationProgress.current}%`
)
)
),
operationResults &&
React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{
color: operationResults.success ? "green" : "red",
bold: true,
},
operationResults.message
),
...operationResults.details.map((detail, index) =>
React.createElement(
Text,
{ key: index, color: "gray", marginLeft: 2 },
`${detail}`
)
)
)
),
// Help text
!isConfigured &&
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "yellow",
padding: 1,
marginBottom: 1,
},
React.createElement(
Text,
{ color: "yellow", bold: true },
"💡 Configuration Required:"
),
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
"Most operations require configuration. Go to Configuration first."
),
React.createElement(
Text,
{ marginLeft: 2, color: "gray" },
"You can still test your connection without full configuration."
)
),
React.createElement(
Text,
{ color: "gray" },
isConfigured
? "Use ↑/↓ to navigate, Enter to execute operation, Esc to go back"
: "Configure your settings first, or test connection. Press Esc to go back"
)
);
}
// Other screens (simplified for now)
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
`📱 ${currentScreen.toUpperCase()} Screen`
),
React.createElement(
Text,
{ color: "gray" },
"This screen is under construction"
),
React.createElement(
Box,
{
marginTop: 1,
borderStyle: "single",
borderColor: "blue",
padding: 1,
},
React.createElement(Text, { color: "blue" }, "🚧 Coming Soon:"),
React.createElement(
Text,
{ marginLeft: 2 },
"• Interactive forms and inputs"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Real-time progress tracking"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Log viewing and filtering"
),
React.createElement(
Text,
{ marginLeft: 2 },
"• Advanced scheduling options"
)
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Press Esc to return to main menu"
)
);
};
console.log("🎨 Rendering TUI...");
const { waitUntilExit } = render(React.createElement(TuiApp));
// Wait for the application to exit
return waitUntilExit();
await waitUntilExit();
} catch (error) {
console.error("Failed to start TUI application:", error);
console.error("Stack:", error.stack);
process.exit(1);
}
};

View File

@@ -1,24 +1,52 @@
const React = require("react");
const { Box, Text } = require("ink");
const { Box } = require("ink");
const AppProvider = require("./providers/AppProvider.jsx");
const ServiceProvider = require("./providers/ServiceProvider.jsx");
const Router = require("./components/Router.jsx");
const StatusBar = require("./components/StatusBar.jsx");
const HelpOverlay = require("./components/common/HelpOverlay.jsx");
const MinimumSizeWarning = require("./components/common/MinimumSizeWarning.jsx");
/**
* Main TUI Application Component
* Root component that sets up the application structure
* Requirements: 2.2, 2.5
* Requirements: 2.2, 2.5, 5.1, 5.3, 7.1, 9.2, 9.5
*/
const TuiApplication = () => {
return React.createElement(
AppProvider,
null,
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(StatusBar),
React.createElement(Router)
)
return (
<ServiceProvider>
<AppProvider>
<TuiContent />
</AppProvider>
</ServiceProvider>
);
};
/**
* TUI Content Component
* Contains the main application content and help overlay
*/
const TuiContent = () => {
const { useAppState } = require("./providers/AppProvider.jsx");
const { appState, hideHelp } = useAppState();
// Show minimum size warning if terminal is too small
if (!appState.terminalState.isMinimumSize) {
return (
<MinimumSizeWarning message={appState.terminalState.minimumSizeMessage} />
);
}
return (
<Box flexDirection="column" height="100%" position="relative">
<StatusBar />
<Router />
<HelpOverlay
isVisible={appState.uiState.helpVisible}
onClose={hideHelp}
currentScreen={appState.currentScreen}
/>
</Box>
);
};

View File

@@ -1,14 +1,14 @@
const React = require("react");
const { Box, Text } = require("ink");
const { useAppState } = require("../providers/AppProvider.jsx");
const useNavigation = require("../hooks/useNavigation.js");
// Import screen components
const MainMenuScreen = require("./screens/MainMenuScreen.jsx");
const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx");
const OperationScreen = require("./screens/OperationScreen.jsx");
const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
const LogViewerScreen = require("./screens/LogViewerScreen.jsx");
const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
const ViewLogsScreen = require("./screens/ViewLogsScreen.jsx");
// const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
/**
* Router Component
@@ -16,7 +16,7 @@ const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
* Requirements: 5.1, 5.3, 7.1
*/
const Router = () => {
const { appState } = useAppState();
const { currentScreen } = useNavigation();
// Screen components mapping
const screens = {
@@ -24,12 +24,25 @@ const Router = () => {
configuration: ConfigurationScreen,
operation: OperationScreen,
scheduling: SchedulingScreen,
logs: LogViewerScreen,
"tag-analysis": TagAnalysisScreen,
logs: ViewLogsScreen,
// "tag-analysis": TagAnalysisScreen,
};
// Get the current screen component
const CurrentScreen = screens[appState.currentScreen] || screens["main-menu"];
const CurrentScreen = screens[currentScreen] || screens["main-menu"];
// Handle case where screen component doesn't exist
if (!CurrentScreen) {
return React.createElement(
Box,
{ flexGrow: 1, justifyContent: "center", alignItems: "center" },
React.createElement(
Text,
{ color: "red" },
`Screen "${currentScreen}" not found`
)
);
}
return React.createElement(
Box,

View File

@@ -1,33 +1,227 @@
const React = require("react");
const { Box, Text } = require("ink");
const { useAppState } = require("../providers/AppProvider.jsx");
const { useState, useEffect } = React;
const useAppState = require("../hooks/useAppState.js");
const useNavigation = require("../hooks/useNavigation.js");
const { useServiceContext } = require("../providers/ServiceProvider.jsx");
/**
* StatusBar Component
* Displays global status information at the top of the application
* Shows connection status, operation progress, and current screen
* Requirements: 8.1, 8.2, 8.3
*/
const StatusBar = () => {
const { appState } = useAppState();
const { operationState, configuration } = useAppState();
const { currentScreen } = useNavigation();
const {
testConnection,
isInitialized,
error: serviceError,
} = useServiceContext();
const [connectionStatus, setConnectionStatus] = useState({
status: "disconnected",
lastChecked: null,
error: null,
});
// Get connection status (placeholder for now)
const connectionStatus = "Connected"; // Will be dynamic later
const connectionColor = "green";
// Test connection status periodically using ShopifyService
useEffect(() => {
const performConnectionTest = async () => {
try {
// Only test connection if services are initialized and we have configuration
if (!isInitialized) {
setConnectionStatus({
status: "initializing",
lastChecked: new Date(),
error: "Services initializing...",
});
return;
}
// Get operation progress
const operationProgress = appState.operationState?.progress || 0;
if (serviceError) {
setConnectionStatus({
status: "error",
lastChecked: new Date(),
error: serviceError,
});
return;
}
// Get current screen name for display
const screenNames = {
"main-menu": "Main Menu",
configuration: "Configuration",
operation: "Operation",
scheduling: "Scheduling",
logs: "Logs",
"tag-analysis": "Tag Analysis",
if (!configuration.shopDomain || !configuration.accessToken) {
setConnectionStatus({
status: "not_configured",
lastChecked: new Date(),
error: "Missing configuration",
});
return;
}
// Set connecting status
setConnectionStatus((prev) => ({
...prev,
status: "connecting",
}));
// Use ShopifyService to test connection
const isConnected = await testConnection();
setConnectionStatus({
status: isConnected ? "connected" : "disconnected",
lastChecked: new Date(),
error: isConnected ? null : "Connection failed",
});
} catch (error) {
setConnectionStatus({
status: "error",
lastChecked: new Date(),
error: error.message,
});
}
};
// Test connection immediately if services are ready
if (isInitialized) {
performConnectionTest();
}
// Test connection every 30 seconds
const interval = setInterval(() => {
if (isInitialized) {
performConnectionTest();
}
}, 30000);
return () => clearInterval(interval);
}, [
configuration.shopDomain,
configuration.accessToken,
isInitialized,
serviceError,
testConnection,
]);
// Get connection display info
const getConnectionInfo = () => {
switch (connectionStatus.status) {
case "connected":
return {
text: "Connected",
color: "green",
indicator: "●",
};
case "connecting":
return {
text: "Connecting...",
color: "yellow",
indicator: "◐",
};
case "initializing":
return {
text: "Initializing...",
color: "yellow",
indicator: "◑",
};
case "not_configured":
return {
text: "Not Configured",
color: "gray",
indicator: "○",
};
case "error":
return {
text: "Connection Error",
color: "red",
indicator: "●",
};
case "disconnected":
default:
return {
text: "Disconnected",
color: "red",
indicator: "○",
};
}
};
const currentScreenName = screenNames[appState.currentScreen] || "Unknown";
// Get operation status info
const getOperationInfo = () => {
if (!operationState) {
return null;
}
const { status, progress, type, currentProduct } = operationState;
switch (status) {
case "running":
return {
text: `${type === "rollback" ? "Rollback" : "Update"}: ${
progress || 0
}%`,
color: "blue",
indicator: "▶",
details: currentProduct ? `Processing: ${currentProduct}` : null,
};
case "completed":
return {
text: `${type === "rollback" ? "Rollback" : "Update"} Complete`,
color: "green",
indicator: "✓",
};
case "error":
return {
text: `${type === "rollback" ? "Rollback" : "Update"} Failed`,
color: "red",
indicator: "✗",
};
case "paused":
return {
text: `${type === "rollback" ? "Rollback" : "Update"} Paused`,
color: "yellow",
indicator: "⏸",
};
default:
return {
text: "Ready",
color: "gray",
indicator: "○",
};
}
};
// Get current screen name for display
const getScreenName = () => {
const screenNames = {
"main-menu": "Main Menu",
configuration: "Configuration",
operation: "Operation",
scheduling: "Scheduling",
logs: "Logs",
"tag-analysis": "Tag Analysis",
};
return screenNames[currentScreen] || "Unknown";
};
// Get system status indicator
const getSystemStatus = () => {
if (operationState?.status === "error") {
return { color: "red", text: "ERROR" };
}
if (operationState?.status === "running") {
return { color: "blue", text: "ACTIVE" };
}
if (connectionStatus.status === "error") {
return { color: "red", text: "CONN_ERR" };
}
if (connectionStatus.status === "connected") {
return { color: "green", text: "READY" };
}
return { color: "gray", text: "IDLE" };
};
const connectionInfo = getConnectionInfo();
const operationInfo = getOperationInfo();
const systemStatus = getSystemStatus();
return React.createElement(
Box,
@@ -35,20 +229,60 @@ const StatusBar = () => {
borderStyle: "single",
paddingX: 1,
justifyContent: "space-between",
height: 3,
},
// Left side: Connection and screen info
React.createElement(
Box,
null,
React.createElement(Text, { color: connectionColor }, "● "),
React.createElement(Text, null, connectionStatus),
React.createElement(Text, null, " | "),
React.createElement(Text, null, `Screen: ${currentScreenName}`)
{ flexDirection: "column" },
React.createElement(
Box,
null,
React.createElement(
Text,
{ color: connectionInfo.color },
`${connectionInfo.indicator} `
),
React.createElement(Text, null, connectionInfo.text),
React.createElement(Text, { color: "gray" }, " | "),
React.createElement(Text, null, `Screen: ${getScreenName()}`)
),
connectionStatus.error &&
React.createElement(
Text,
{ color: "red", dimColor: true },
`Error: ${connectionStatus.error.substring(0, 40)}${
connectionStatus.error.length > 40 ? "..." : ""
}`
)
),
// Right side: Operation status and system status
React.createElement(
Box,
null,
appState.operationState &&
React.createElement(Text, null, `Progress: ${operationProgress}%`)
{ flexDirection: "column", alignItems: "flex-end" },
React.createElement(
Box,
null,
operationInfo &&
React.createElement(
Text,
{ color: operationInfo.color },
`${operationInfo.indicator} ${operationInfo.text}`
),
operationInfo && React.createElement(Text, { color: "gray" }, " | "),
React.createElement(
Text,
{ color: systemStatus.color, bold: true },
systemStatus.text
)
),
operationInfo?.details &&
React.createElement(
Text,
{ color: "gray", dimColor: true },
operationInfo.details.substring(0, 30) +
(operationInfo.details.length > 30 ? "..." : "")
)
)
);
};

View File

@@ -0,0 +1,229 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
/**
* ErrorBoundary Component
* Catches and displays React errors gracefully with recovery mechanisms
* Requirements: 6.1, 10.4, 11.4
*/
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
};
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log error details
this.setState({
error: error,
errorInfo: errorInfo,
});
// Call onError callback if provided
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Log to console for debugging
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
handleRetry = () => {
this.setState((prevState) => ({
hasError: false,
error: null,
errorInfo: null,
retryCount: prevState.retryCount + 1,
}));
// Call onRetry callback if provided
if (this.props.onRetry) {
this.props.onRetry(this.state.retryCount + 1);
}
};
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
});
// Call onReset callback if provided
if (this.props.onReset) {
this.props.onReset();
}
};
render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback(
this.state.error,
this.state.errorInfo,
this.handleRetry,
this.handleReset
);
}
// Default error UI
return React.createElement(ErrorDisplay, {
error: this.state.error,
errorInfo: this.state.errorInfo,
retryCount: this.state.retryCount,
maxRetries: this.props.maxRetries || 3,
onRetry: this.handleRetry,
onReset: this.handleReset,
onExit: this.props.onExit,
showDetails: this.props.showDetails !== false,
title: this.props.title || "Application Error",
});
}
return this.props.children;
}
}
/**
* ErrorDisplay Component
* Default error display UI with keyboard interaction
*/
const ErrorDisplay = ({
error,
errorInfo,
retryCount,
maxRetries,
onRetry,
onReset,
onExit,
showDetails,
title,
}) => {
const [showFullDetails, setShowFullDetails] = React.useState(false);
useInput((input, key) => {
if (input === "r" && retryCount < maxRetries) {
onRetry();
} else if (input === "R") {
onReset();
} else if (input === "d") {
setShowFullDetails(!showFullDetails);
} else if (input === "q" || key.escape) {
if (onExit) {
onExit();
}
}
});
const getErrorMessage = () => {
if (error && error.message) {
return error.message;
}
return "An unexpected error occurred";
};
const getErrorType = () => {
if (error && error.name) {
return error.name;
}
return "Error";
};
const canRetry = retryCount < maxRetries;
return (
<Box
flexDirection="column"
padding={2}
borderStyle="single"
borderColor="red"
>
<Box marginBottom={1}>
<Text color="red" bold>
{title}
</Text>
</Box>
<Box marginBottom={1}>
<Text color="white">
{getErrorType()}: {getErrorMessage()}
</Text>
</Box>
{retryCount > 0 && (
<Box marginBottom={1}>
<Text color="yellow">
Retry attempts: {retryCount}/{maxRetries}
</Text>
</Box>
)}
{showDetails && showFullDetails && error && (
<Box
flexDirection="column"
marginBottom={1}
borderStyle="single"
borderColor="gray"
padding={1}
>
<Text color="gray" bold>
Error Details:
</Text>
<Text color="gray">{error.stack || error.toString()}</Text>
{errorInfo && errorInfo.componentStack && (
<>
<Text color="gray" bold>
Component Stack:
</Text>
<Text color="gray">{errorInfo.componentStack}</Text>
</>
)}
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<Text color="cyan" bold>
Available Actions:
</Text>
{canRetry && (
<Text color="white">
Press 'r' to retry ({maxRetries - retryCount} attempts remaining)
</Text>
)}
<Text color="white"> Press 'R' to reset and clear error state</Text>
{showDetails && (
<Text color="white">
Press 'd' to {showFullDetails ? "hide" : "show"} error details
</Text>
)}
<Text color="white"> Press 'q' or Escape to exit</Text>
</Box>
{!canRetry && (
<Box marginTop={1}>
<Text color="red">
Maximum retry attempts reached. Please reset or exit.
</Text>
</Box>
)}
</Box>
);
};
module.exports = ErrorBoundary;

View File

@@ -0,0 +1,118 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
/**
* ErrorDisplay Component
* Reusable component for consistent error messaging across TUI screens
* Requirements: 4.1, 4.5
*/
const ErrorDisplay = ({
error,
title = "Error",
onRetry,
onDismiss,
showRetry = true,
showDismiss = true,
retryText = "Press 'r' to retry",
dismissText = "Press 'd' to dismiss",
compact = false,
}) => {
const [dismissed, setDismissed] = React.useState(false);
useInput((input, key) => {
if (input === "r" && onRetry && showRetry) {
onRetry();
} else if (input === "d" && showDismiss) {
if (onDismiss) {
onDismiss();
} else {
setDismissed(true);
}
} else if (key.escape && showDismiss) {
if (onDismiss) {
onDismiss();
} else {
setDismissed(true);
}
}
});
// Don't render if dismissed locally
if (dismissed) {
return null;
}
const getErrorMessage = () => {
if (typeof error === "string") {
return error;
}
if (error && error.message) {
return error.message;
}
if (error && error.toString) {
return error.toString();
}
return "An unexpected error occurred";
};
const getErrorType = () => {
if (error && error.name) {
return error.name;
}
if (error && error.code) {
return `Error ${error.code}`;
}
return "Error";
};
if (compact) {
return (
<Box flexDirection="row" alignItems="center">
<Text color="red" bold>
{getErrorMessage()}
</Text>
{showRetry && onRetry && (
<Text color="gray" marginLeft={2}>
(r: retry)
</Text>
)}
{showDismiss && (
<Text color="gray" marginLeft={1}>
(d: dismiss)
</Text>
)}
</Box>
);
}
return (
<Box
flexDirection="column"
padding={1}
borderStyle="single"
borderColor="red"
marginBottom={1}
>
<Box marginBottom={1}>
<Text color="red" bold>
{title}
</Text>
</Box>
<Box marginBottom={1}>
<Text color="white">
{getErrorType()}: {getErrorMessage()}
</Text>
</Box>
<Box flexDirection="column">
{showRetry && onRetry && <Text color="cyan"> {retryText}</Text>}
{showDismiss && (
<Text color="cyan"> {dismissText} or press Escape</Text>
)}
</Box>
</Box>
);
};
module.exports = ErrorDisplay;

View File

@@ -0,0 +1,223 @@
/**
* Focus Indicator Component
* Provides clear focus indicators for keyboard navigation
* Requirements: 8.2, 8.3
*/
const React = require("react");
const { Box, Text } = require("ink");
const useAccessibility = require("../../hooks/useAccessibility.js");
/**
* Enhanced focus indicator component
* Wraps other components to provide clear focus visualization
*/
const FocusIndicator = ({
children,
isFocused = false,
componentType = "default",
label,
description,
role,
state = {},
...props
}) => {
const { helpers, screenReader } = useAccessibility();
// Get accessibility-aware props
const accessibilityProps = helpers.getComponentProps(componentType, {
isFocused,
...state,
});
// Get ARIA-like props for screen readers
const ariaProps = helpers.getAriaProps({
role,
label,
description,
state: { focused: isFocused, ...state },
});
// Announce focus changes to screen reader
React.useEffect(() => {
if (isFocused && label && helpers.isEnabled("screenReader")) {
const announcement = description
? `${label}, ${description}, focused`
: `${label}, focused`;
screenReader.announce(announcement, "polite");
}
}, [isFocused, label, description, helpers, screenReader]);
return (
<Box {...accessibilityProps} {...ariaProps} {...props}>
{children}
</Box>
);
};
/**
* Focus indicator for menu items
*/
const MenuItemFocusIndicator = ({
children,
isFocused,
isSelected,
item,
index,
total,
...props
}) => {
const { screenReader, helpers } = useAccessibility();
// Generate screen reader description
const screenReaderText = React.useMemo(() => {
if (!helpers.isEnabled("screenReader")) return "";
return screenReader.describeMenuItem(item, index, total, isSelected);
}, [item, index, total, isSelected, screenReader, helpers]);
return (
<FocusIndicator
isFocused={isFocused}
componentType="menu-item"
label={item.label}
description={screenReaderText}
role="menuitem"
state={{ selected: isSelected }}
{...props}
>
{children}
</FocusIndicator>
);
};
/**
* Focus indicator for form inputs
*/
const InputFocusIndicator = ({
children,
isFocused,
label,
value,
isValid = true,
errorMessage,
...props
}) => {
const { screenReader, helpers } = useAccessibility();
// Generate screen reader description
const screenReaderText = React.useMemo(() => {
if (!helpers.isEnabled("screenReader")) return "";
return screenReader.describeFormField(label, value, isValid, errorMessage);
}, [label, value, isValid, errorMessage, screenReader, helpers]);
return (
<FocusIndicator
isFocused={isFocused}
componentType="input"
label={label}
description={screenReaderText}
role="textbox"
state={{
invalid: !isValid,
hasValue: !!value,
}}
{...props}
>
{children}
</FocusIndicator>
);
};
/**
* Focus indicator for buttons
*/
const ButtonFocusIndicator = ({
children,
isFocused,
label,
disabled = false,
...props
}) => {
const { helpers } = useAccessibility();
return (
<FocusIndicator
isFocused={isFocused}
componentType="button"
label={label}
role="button"
state={{ disabled }}
{...props}
>
{children}
</FocusIndicator>
);
};
/**
* Focus indicator for progress bars
*/
const ProgressFocusIndicator = ({
children,
isFocused,
current,
total,
label,
...props
}) => {
const { screenReader, helpers } = useAccessibility();
// Generate screen reader description
const screenReaderText = React.useMemo(() => {
if (!helpers.isEnabled("screenReader")) return "";
return screenReader.describeProgress(current, total, label);
}, [current, total, label, screenReader, helpers]);
return (
<FocusIndicator
isFocused={isFocused}
componentType="progress"
label={label}
description={screenReaderText}
role="progressbar"
state={{
valueNow: current,
valueMax: total,
valueText: `${current} of ${total}`,
}}
{...props}
>
{children}
</FocusIndicator>
);
};
/**
* Screen reader only text component
* Provides text that's only announced to screen readers
*/
const ScreenReaderOnly = ({ children }) => {
const { helpers } = useAccessibility();
// Only render for screen readers
if (!helpers.isEnabled("screenReader")) {
return null;
}
// In a real implementation, this would be hidden visually but available to screen readers
// For terminal applications, we'll use a special marker
return (
<Box display="none" data-screen-reader-only="true">
<Text>{children}</Text>
</Box>
);
};
module.exports = {
FocusIndicator,
MenuItemFocusIndicator,
InputFocusIndicator,
ButtonFocusIndicator,
ProgressFocusIndicator,
ScreenReaderOnly,
};

View File

@@ -0,0 +1,324 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const TextInput = require("ink-text-input");
/**
* FormInput Component
* Enhanced input field component for forms across TUI screens
* Requirements: 4.1, 4.3
*/
const FormInput = ({
label,
value = "",
onChange,
onSubmit,
onFocus,
onBlur,
placeholder = "",
validation,
showError = true,
disabled = false,
mask,
focus = false,
width,
required = false,
type = "text",
options = [], // For select-like behavior
multiline = false,
maxLength,
helpText,
}) => {
const [isValid, setIsValid] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState("");
const [isFocused, setIsFocused] = React.useState(focus);
const [showOptions, setShowOptions] = React.useState(false);
const [selectedOptionIndex, setSelectedOptionIndex] = React.useState(0);
// Handle option selection for select-like inputs
useInput((input, key) => {
if (type === "select" && options.length > 0 && isFocused) {
if (key.upArrow) {
setSelectedOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1
);
} else if (key.downArrow) {
setSelectedOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0
);
} else if (key.return) {
const selectedOption = options[selectedOptionIndex];
const newValue =
typeof selectedOption === "object"
? selectedOption.value
: selectedOption;
if (onChange) {
onChange(newValue);
}
setShowOptions(false);
} else if (key.escape) {
setShowOptions(false);
} else if (input === " ") {
setShowOptions(!showOptions);
}
}
});
// Validate input value
const validateInput = React.useCallback(
(inputValue) => {
if (required && (!inputValue || inputValue.trim() === "")) {
setIsValid(false);
setErrorMessage("This field is required");
return false;
}
if (maxLength && inputValue.length > maxLength) {
setIsValid(false);
setErrorMessage(`Maximum length is ${maxLength} characters`);
return false;
}
if (type === "email" && inputValue) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(inputValue)) {
setIsValid(false);
setErrorMessage("Please enter a valid email address");
return false;
}
}
if (type === "number" && inputValue) {
if (isNaN(Number(inputValue))) {
setIsValid(false);
setErrorMessage("Please enter a valid number");
return false;
}
}
if (validation && typeof validation === "function") {
const validationResult = validation(inputValue);
if (typeof validationResult === "boolean") {
setIsValid(validationResult);
setErrorMessage(validationResult ? "" : "Invalid input");
return validationResult;
} else if (typeof validationResult === "object") {
setIsValid(validationResult.isValid);
setErrorMessage(validationResult.message || "");
return validationResult.isValid;
}
}
setIsValid(true);
setErrorMessage("");
return true;
},
[validation, required, maxLength, type]
);
// Handle input change
const handleChange = React.useCallback(
(newValue) => {
if (onChange) {
onChange(newValue);
}
validateInput(newValue);
},
[onChange, validateInput]
);
// Handle input submit
const handleSubmit = React.useCallback(
(submittedValue) => {
const valid = validateInput(submittedValue);
if (onSubmit && valid) {
onSubmit(submittedValue);
}
},
[onSubmit, validateInput]
);
// Handle focus events
const handleFocus = React.useCallback(() => {
setIsFocused(true);
if (type === "select" && options.length > 0) {
setShowOptions(true);
}
if (onFocus) {
onFocus();
}
}, [onFocus, type, options.length]);
const handleBlur = React.useCallback(() => {
setIsFocused(false);
setShowOptions(false);
if (onBlur) {
onBlur();
}
}, [onBlur]);
// Validate on mount and when value changes externally
React.useEffect(() => {
if (value !== undefined) {
validateInput(value);
}
}, [value, validateInput]);
// Color scheme for different states
const getInputColor = () => {
if (disabled) return "gray";
if (!isValid) return "red";
if (isFocused) return "blue";
return "white";
};
const getLabelColor = () => {
if (disabled) return "gray";
if (!isValid) return "red";
return "white";
};
const getDisplayValue = () => {
if (type === "select" && options.length > 0) {
const option = options.find(
(opt) => (typeof opt === "object" ? opt.value : opt) === value
);
return option
? typeof option === "object"
? option.label
: option
: value || placeholder;
}
return value;
};
return (
<Box flexDirection="column" width={width}>
{label && (
<Box marginBottom={0}>
<Text color={getLabelColor()} bold>
{label}
{required && <Text color="red">*</Text>}:
</Text>
</Box>
)}
{helpText && (
<Box marginBottom={0}>
<Text color="gray" dimColor>
{helpText}
</Text>
</Box>
)}
<Box marginBottom={showError && !isValid ? 0 : 1}>
{type === "select" ? (
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor={isFocused ? "blue" : "gray"}
paddingX={1}
>
<Text color={getInputColor()}>
{getDisplayValue()}
{isFocused && <Text color="blue"> </Text>}
</Text>
</Box>
{showOptions && (
<Box
flexDirection="column"
borderStyle="single"
borderColor="blue"
marginTop={0}
maxHeight={8}
>
{options.map((option, index) => {
const isSelected = index === selectedOptionIndex;
const optionLabel =
typeof option === "object" ? option.label : option;
return (
<Text
key={index}
color={isSelected ? "blue" : "white"}
backgroundColor={isSelected ? "blue" : undefined}
bold={isSelected}
>
{isSelected ? "► " : " "}
{optionLabel}
</Text>
);
})}
</Box>
)}
</Box>
) : (
<TextInput
value={value}
placeholder={placeholder}
onChange={handleChange}
onSubmit={handleSubmit}
focus={focus}
mask={mask}
showCursor={!disabled}
/>
)}
</Box>
{showError && !isValid && errorMessage && (
<Box marginBottom={1}>
<Text color="red"> {errorMessage}</Text>
</Box>
)}
{disabled && (
<Box marginBottom={1}>
<Text color="gray" dimColor>
(Field is disabled)
</Text>
</Box>
)}
{maxLength && value && (
<Box>
<Text color="gray" dimColor>
{value.length}/{maxLength} characters
</Text>
</Box>
)}
{type === "select" && isFocused && (
<Box marginTop={1}>
<Text color="cyan">
to navigate, Enter to select, Space to toggle, Esc to close
</Text>
</Box>
)}
</Box>
);
};
/**
* SimpleFormInput Component
* Minimal form input for basic use cases
*/
const SimpleFormInput = ({
label,
value,
onChange,
placeholder,
required = false,
}) => {
return (
<FormInput
label={label}
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
showError={false}
/>
);
};
module.exports = { FormInput, SimpleFormInput };

View File

@@ -0,0 +1,145 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const useHelp = require("../../hooks/useHelp.js");
/**
* Help Overlay Component
* Displays context-sensitive help information and keyboard shortcuts
* Requirements: 9.2, 9.5
*/
const HelpOverlay = ({ isVisible, onClose, currentScreen }) => {
const { getHelpTitle, getHelpDescription, getAllShortcuts } = useHelp();
// Handle keyboard input for help overlay
useInput((input, key) => {
if (!isVisible) return;
if (key.escape || input === "h" || input === "H" || input === "q") {
onClose();
}
});
if (!isVisible) {
return null;
}
const helpTitle = getHelpTitle();
const helpDescription = getHelpDescription();
const shortcuts = getAllShortcuts();
return React.createElement(
Box,
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
borderStyle: "double",
borderColor: "cyan",
padding: 2,
flexDirection: "column",
},
// Header
React.createElement(
Box,
{
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 1,
},
React.createElement(
Text,
{ bold: true, color: "cyan" },
`📖 ${helpTitle}`
),
React.createElement(
Text,
{ color: "gray" },
"Press 'h' or 'Esc' to close"
)
),
// Description
React.createElement(
Box,
{ marginBottom: 2 },
React.createElement(Text, { color: "white" }, helpDescription)
),
// Shortcuts section
React.createElement(
Box,
{ flexDirection: "column", flexGrow: 1 },
React.createElement(
Text,
{ bold: true, color: "yellow", marginBottom: 1 },
"Keyboard Shortcuts:"
),
React.createElement(
Box,
{ flexDirection: "column" },
shortcuts.map((shortcut, index) =>
React.createElement(
Box,
{
key: index,
flexDirection: "row",
marginBottom: 1,
paddingX: 1,
},
React.createElement(
Box,
{ width: 15 },
React.createElement(
Text,
{ color: "green", bold: true },
shortcut.key
)
),
React.createElement(Text, { color: "white" }, shortcut.description)
)
)
)
),
// Footer with additional tips
React.createElement(
Box,
{
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 1,
marginTop: 1,
},
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(Text, { color: "cyan", bold: true }, "💡 Tips:"),
React.createElement(
Text,
{ color: "gray" },
"• Use Tab to navigate between form fields"
),
React.createElement(
Text,
{ color: "gray" },
"• Press 'h' on any screen to get context-specific help"
),
React.createElement(
Text,
{ color: "gray" },
"• Use Esc to go back or cancel operations"
),
React.createElement(
Text,
{ color: "gray" },
"• Configuration must be complete before running operations"
)
)
)
);
};
module.exports = HelpOverlay;

View File

@@ -0,0 +1,141 @@
const React = require("react");
const { Box, Text } = require("ink");
const TextInput = require("ink-text-input");
/**
* InputField Component
* Ink-based input field with validation support and real-time feedback
* Requirements: 3.2, 6.3, 8.3
*/
const InputField = ({
label,
value = "",
onChange,
onSubmit,
placeholder = "",
validation,
showError = true,
disabled = false,
mask,
focus = false,
width,
required = false,
}) => {
const [isValid, setIsValid] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState("");
const [isFocused, setIsFocused] = React.useState(focus);
// Validate input value
const validateInput = React.useCallback(
(inputValue) => {
if (required && (!inputValue || inputValue.trim() === "")) {
setIsValid(false);
setErrorMessage("This field is required");
return false;
}
if (validation && typeof validation === "function") {
const validationResult = validation(inputValue);
if (typeof validationResult === "boolean") {
setIsValid(validationResult);
setErrorMessage(validationResult ? "" : "Invalid input");
return validationResult;
} else if (typeof validationResult === "object") {
setIsValid(validationResult.isValid);
setErrorMessage(validationResult.message || "");
return validationResult.isValid;
}
}
setIsValid(true);
setErrorMessage("");
return true;
},
[validation, required]
);
// Handle input change
const handleChange = React.useCallback(
(newValue) => {
if (onChange) {
onChange(newValue);
}
validateInput(newValue);
},
[onChange, validateInput]
);
// Handle input submit
const handleSubmit = React.useCallback(
(submittedValue) => {
const valid = validateInput(submittedValue);
if (onSubmit && valid) {
onSubmit(submittedValue);
}
},
[onSubmit, validateInput]
);
// Validate on mount and when value changes externally
React.useEffect(() => {
if (value !== undefined) {
validateInput(value);
}
}, [value, validateInput]);
// Color scheme for different states
const getInputColor = () => {
if (disabled) return "gray";
if (!isValid) return "red";
if (isFocused) return "blue";
return "white";
};
const getLabelColor = () => {
if (disabled) return "gray";
if (!isValid) return "red";
return "white";
};
return (
<Box flexDirection="column" width={width}>
{label && (
<Box marginBottom={0}>
<Text color={getLabelColor()} bold>
{label}
{required && <Text color="red">*</Text>}:
</Text>
</Box>
)}
<Box marginBottom={showError && !isValid ? 0 : 1}>
<TextInput
value={value}
placeholder={placeholder}
onChange={handleChange}
onSubmit={handleSubmit}
focus={focus}
mask={mask}
showCursor={!disabled}
/>
</Box>
{showError && !isValid && errorMessage && (
<Box marginBottom={1}>
<Text color="red"> {errorMessage}</Text>
</Box>
)}
{disabled && (
<Box marginBottom={1}>
<Text color="gray" dimColor>
(Field is disabled)
</Text>
</Box>
)}
</Box>
);
};
module.exports = InputField;

View File

@@ -0,0 +1,125 @@
const React = require("react");
const { Box, Text } = require("ink");
const Spinner = require("ink-spinner");
/**
* LoadingIndicator Component
* Reusable component for progress indication across TUI screens
* Requirements: 4.1, 4.3
*/
const LoadingIndicator = ({
text = "Loading...",
type = "dots",
color = "blue",
showSpinner = true,
showProgress = false,
progress = 0,
progressMax = 100,
compact = false,
centered = false,
}) => {
const [dots, setDots] = React.useState("");
// Animate dots if no spinner is used
React.useEffect(() => {
if (!showSpinner) {
const interval = setInterval(() => {
setDots((prev) => {
if (prev.length >= 3) return "";
return prev + ".";
});
}, 500);
return () => clearInterval(interval);
}
}, [showSpinner]);
const getProgressBar = () => {
if (!showProgress) return null;
const percentage = Math.round((progress / progressMax) * 100);
const barWidth = 20;
const filledWidth = Math.round((percentage / 100) * barWidth);
const emptyWidth = barWidth - filledWidth;
return (
<Box flexDirection="row" alignItems="center" marginLeft={2}>
<Text color="blue">{"█".repeat(filledWidth)}</Text>
<Text color="gray">{"░".repeat(emptyWidth)}</Text>
<Text color="white" marginLeft={1}>
{percentage}%
</Text>
</Box>
);
};
const content = (
<Box flexDirection="row" alignItems="center">
{showSpinner ? <Spinner type={type} /> : <Text color={color}></Text>}
<Text color="white" marginLeft={1}>
{text}
{!showSpinner && dots}
</Text>
{getProgressBar()}
</Box>
);
if (compact) {
return content;
}
if (centered) {
return (
<Box
flexDirection="column"
alignItems="center"
justifyContent="center"
padding={1}
>
{content}
</Box>
);
}
return (
<Box flexDirection="column" padding={1}>
{content}
</Box>
);
};
/**
* LoadingOverlay Component
* Full-screen loading overlay for blocking operations
*/
const LoadingOverlay = ({
text = "Loading...",
type = "dots",
color = "blue",
showProgress = false,
progress = 0,
progressMax = 100,
}) => {
return (
<Box
flexDirection="column"
alignItems="center"
justifyContent="center"
height="100%"
borderStyle="single"
borderColor="blue"
>
<LoadingIndicator
text={text}
type={type}
color={color}
showProgress={showProgress}
progress={progress}
progressMax={progressMax}
centered={true}
/>
</Box>
);
};
module.exports = { LoadingIndicator, LoadingOverlay };

View File

@@ -0,0 +1,369 @@
const React = require("react");
const { Box, Text } = require("ink");
const {
useMemoryMonitor,
useCleanup,
useAsyncOperation,
useInterval,
useEventListener,
} = require("../../hooks/useMemoryManagement.js");
/**
* Memory Optimized Component Base
* Provides a foundation for components with proper memory management
* Requirements: 4.2, 4.5
*/
/**
* Higher-order component that adds memory management capabilities
*/
const withMemoryManagement = (WrappedComponent, options = {}) => {
const {
componentName = WrappedComponent.name || "UnknownComponent",
trackMemory = true,
trackRenders = true,
memoryThreshold = 50 * 1024 * 1024, // 50MB
logInterval = 30000, // 30 seconds
} = options;
const MemoryManagedComponent = React.memo((props) => {
const { addCleanup } = useCleanup();
const { executeAsync, cancelAllOperations } = useAsyncOperation();
const { renderCount, getMemoryStats, logMemoryStats } = useMemoryMonitor(
componentName,
{ trackMemory, trackRenders, memoryThreshold, logInterval }
);
// Provide memory management utilities to wrapped component
const memoryManagementProps = {
addCleanup,
executeAsync,
cancelAllOperations,
renderCount,
getMemoryStats,
logMemoryStats,
};
return React.createElement(WrappedComponent, {
...props,
...memoryManagementProps,
});
});
MemoryManagedComponent.displayName = `withMemoryManagement(${componentName})`;
return MemoryManagedComponent;
};
/**
* Memory-optimized container component for long-running operations
*/
const MemoryOptimizedContainer = React.memo(
({
children,
componentName = "MemoryOptimizedContainer",
onMemoryWarning,
memoryCheckInterval = 10000, // 10 seconds
memoryThreshold = 100 * 1024 * 1024, // 100MB
...boxProps
}) => {
const { addCleanup } = useCleanup();
const { executeAsync } = useAsyncOperation();
const [memoryWarning, setMemoryWarning] = React.useState(null);
const [isMonitoring, setIsMonitoring] = React.useState(true);
// Monitor memory usage
const { getMemoryStats, logMemoryStats } = useMemoryMonitor(componentName, {
trackMemory: true,
trackRenders: true,
memoryThreshold,
logInterval: memoryCheckInterval,
});
// Periodic memory check
useInterval(() => {
if (!isMonitoring) return;
const stats = getMemoryStats();
if (stats && stats.current.heapUsed > memoryThreshold) {
const warning = {
message: `High memory usage detected: ${(
stats.current.heapUsed /
1024 /
1024
).toFixed(2)}MB`,
heapUsed: stats.current.heapUsed,
threshold: memoryThreshold,
timestamp: Date.now(),
};
setMemoryWarning(warning);
if (onMemoryWarning) {
onMemoryWarning(warning);
}
// Auto-clear warning after 30 seconds
setTimeout(() => {
setMemoryWarning(null);
}, 30000);
}
}, memoryCheckInterval);
// Force garbage collection (if available)
const forceGarbageCollection = React.useCallback(() => {
if (typeof global !== "undefined" && global.gc) {
try {
global.gc();
console.log(`[${componentName}] Forced garbage collection`);
} catch (error) {
console.warn(
`[${componentName}] Could not force garbage collection:`,
error.message
);
}
}
}, [componentName]);
// Memory optimization utilities
const memoryUtils = React.useMemo(
() => ({
getStats: getMemoryStats,
logStats: logMemoryStats,
forceGC: forceGarbageCollection,
clearWarning: () => setMemoryWarning(null),
toggleMonitoring: () => setIsMonitoring((prev) => !prev),
}),
[getMemoryStats, logMemoryStats, forceGarbageCollection]
);
return (
<Box flexDirection="column" {...boxProps}>
{/* Memory warning display */}
{memoryWarning && (
<Box
borderStyle="single"
borderColor="yellow"
paddingX={1}
marginBottom={1}
>
<Box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<Text color="yellow" bold>
Memory Warning
</Text>
<Text color="gray" dimColor>
{new Date(memoryWarning.timestamp).toLocaleTimeString()}
</Text>
</Box>
<Text color="yellow">{memoryWarning.message}</Text>
<Box flexDirection="row" marginTop={1}>
<Text color="gray" dimColor>
Press 'g' to force garbage collection or 'c' to clear warning
</Text>
</Box>
</Box>
)}
{/* Main content */}
{typeof children === "function" ? children(memoryUtils) : children}
</Box>
);
}
);
/**
* Memory-efficient list component with automatic cleanup
*/
const MemoryEfficientList = React.memo(
({
items = [],
renderItem,
maxCachedItems = 100,
componentName = "MemoryEfficientList",
...boxProps
}) => {
const { addCleanup } = useCleanup();
const [cachedItems, setCachedItems] = React.useState(new Map());
const [visibleRange, setVisibleRange] = React.useState({
start: 0,
end: 50,
});
// Cache management
const updateCache = React.useCallback(
(newItems) => {
setCachedItems((prevCache) => {
const newCache = new Map(prevCache);
// Add new items to cache
newItems.forEach((item, index) => {
if (newCache.size < maxCachedItems) {
newCache.set(index, item);
}
});
// Remove old items if cache is too large
if (newCache.size > maxCachedItems) {
const keysToRemove = Array.from(newCache.keys()).slice(
0,
newCache.size - maxCachedItems
);
keysToRemove.forEach((key) => newCache.delete(key));
}
return newCache;
});
},
[maxCachedItems]
);
// Update cache when items change
React.useEffect(() => {
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
updateCache(visibleItems);
}, [items, visibleRange, updateCache]);
// Clear cache on unmount
React.useEffect(() => {
addCleanup(() => {
setCachedItems(new Map());
});
}, [addCleanup]);
// Render visible items
const visibleItems = React.useMemo(() => {
return items.slice(visibleRange.start, visibleRange.end);
}, [items, visibleRange]);
return (
<Box flexDirection="column" {...boxProps}>
{visibleItems.map((item, index) => {
const actualIndex = visibleRange.start + index;
return <Box key={actualIndex}>{renderItem(item, actualIndex)}</Box>;
})}
{/* Memory stats display */}
<Box marginTop={1}>
<Text color="gray" dimColor>
Cached: {cachedItems.size}/{maxCachedItems} | Visible:{" "}
{visibleItems.length}/{items.length}
</Text>
</Box>
</Box>
);
}
);
/**
* Auto-cleanup component for managing temporary resources
*/
const AutoCleanupComponent = React.memo(
({
children,
cleanupInterval = 60000, // 1 minute
maxAge = 300000, // 5 minutes
componentName = "AutoCleanupComponent",
}) => {
const { addCleanup } = useCleanup();
const [resources, setResources] = React.useState(new Map());
// Add resource with timestamp
const addResource = React.useCallback((key, resource) => {
setResources((prev) =>
new Map(prev).set(key, {
resource,
timestamp: Date.now(),
})
);
}, []);
// Remove resource
const removeResource = React.useCallback((key) => {
setResources((prev) => {
const newMap = new Map(prev);
newMap.delete(key);
return newMap;
});
}, []);
// Cleanup old resources
const cleanupOldResources = React.useCallback(() => {
const now = Date.now();
setResources((prev) => {
const newMap = new Map();
let cleanedCount = 0;
for (const [key, { resource, timestamp }] of prev) {
if (now - timestamp < maxAge) {
newMap.set(key, { resource, timestamp });
} else {
// Cleanup resource if it has a cleanup method
if (resource && typeof resource.cleanup === "function") {
try {
resource.cleanup();
} catch (error) {
console.warn(
`[${componentName}] Error cleaning up resource:`,
error
);
}
}
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(
`[${componentName}] Cleaned up ${cleanedCount} old resources`
);
}
return newMap;
});
}, [maxAge, componentName]);
// Periodic cleanup
useInterval(cleanupOldResources, cleanupInterval);
// Cleanup all resources on unmount
React.useEffect(() => {
addCleanup(() => {
resources.forEach(({ resource }) => {
if (resource && typeof resource.cleanup === "function") {
try {
resource.cleanup();
} catch (error) {
console.warn(
`[${componentName}] Error cleaning up resource on unmount:`,
error
);
}
}
});
setResources(new Map());
});
}, [addCleanup, resources, componentName]);
const resourceUtils = React.useMemo(
() => ({
addResource,
removeResource,
cleanupOldResources,
resourceCount: resources.size,
}),
[addResource, removeResource, cleanupOldResources, resources.size]
);
return typeof children === "function" ? children(resourceUtils) : children;
}
);
module.exports = {
withMemoryManagement,
MemoryOptimizedContainer,
MemoryEfficientList,
AutoCleanupComponent,
};

View File

@@ -0,0 +1,241 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const useAccessibility = require("../../hooks/useAccessibility.js");
const {
MenuItemFocusIndicator,
ScreenReaderOnly,
} = require("./FocusIndicator.jsx");
/**
* MenuList Component
* Keyboard-navigable menu with selection highlighting and shortcuts
* Enhanced with accessibility features for screen readers and high contrast mode
* Requirements: 1.2, 8.1, 8.2, 8.3, 9.3, 9.4
*/
const MenuList = ({
items = [],
selectedIndex = 0,
onSelect,
onHighlight,
showShortcuts = true,
highlightColor = "blue",
normalColor = "white",
shortcutColor = "gray",
prefix = "► ",
normalPrefix = " ",
disabled = false,
width,
ariaLabel = "Menu",
}) => {
const [currentIndex, setCurrentIndex] = React.useState(selectedIndex);
const { helpers, colors, screenReader, keyboard } = useAccessibility();
// Update current index when selectedIndex prop changes
React.useEffect(() => {
setCurrentIndex(selectedIndex);
}, [selectedIndex]);
// Get accessible colors
const accessibleColors = React.useMemo(() => {
const colorScheme = colors.getAll();
return {
highlight: helpers.isEnabled("highContrast")
? colorScheme.selection
: highlightColor,
normal: helpers.isEnabled("highContrast")
? colorScheme.foreground
: normalColor,
shortcut: helpers.isEnabled("highContrast")
? colorScheme.info
: shortcutColor,
disabled: colorScheme.disabled,
};
}, [colors, helpers, highlightColor, normalColor, shortcutColor]);
// Announce menu changes to screen reader
React.useEffect(() => {
if (helpers.isEnabled("screenReader") && items.length > 0) {
const currentItem = items[currentIndex];
if (currentItem) {
const announcement = screenReader.describeMenuItem(
currentItem,
currentIndex,
items.length,
true
);
screenReader.announce(announcement, "polite");
}
}
}, [currentIndex, items, helpers, screenReader]);
// Handle keyboard navigation with accessibility support
useInput((input, key) => {
if (disabled || items.length === 0) return;
// Use accessibility-aware keyboard navigation
if (keyboard.isNavigationKey(key, "up")) {
const newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
setCurrentIndex(newIndex);
if (onHighlight) {
onHighlight(newIndex, items[newIndex]);
}
} else if (keyboard.isNavigationKey(key, "down")) {
const newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
setCurrentIndex(newIndex);
if (onHighlight) {
onHighlight(newIndex, items[newIndex]);
}
} else if (keyboard.isNavigationKey(key, "select")) {
if (onSelect && items[currentIndex]) {
// Announce selection to screen reader
if (helpers.isEnabled("screenReader")) {
const item = items[currentIndex];
const label =
typeof item === "string"
? item
: item.label || item.title || item.name;
screenReader.announce(`Selected ${label}`, "assertive");
}
onSelect(currentIndex, items[currentIndex]);
}
} else if (input && showShortcuts) {
// Handle shortcut keys
const shortcutItem = items.find(
(item) =>
item.shortcut && item.shortcut.toLowerCase() === input.toLowerCase()
);
if (shortcutItem) {
const shortcutIndex = items.indexOf(shortcutItem);
setCurrentIndex(shortcutIndex);
if (onSelect) {
// Announce shortcut selection to screen reader
if (helpers.isEnabled("screenReader")) {
const label =
typeof shortcutItem === "string"
? shortcutItem
: shortcutItem.label || shortcutItem.title || shortcutItem.name;
screenReader.announce(
`Selected ${label} via shortcut`,
"assertive"
);
}
onSelect(shortcutIndex, shortcutItem);
}
}
}
});
// Render individual menu item with accessibility enhancements
const renderMenuItem = (item, index) => {
const isSelected = index === currentIndex;
const isFocused = index === currentIndex;
// Use accessible colors
const itemColor = disabled
? accessibleColors.disabled
: isSelected
? accessibleColors.highlight
: accessibleColors.normal;
const itemPrefix = isSelected ? prefix : normalPrefix;
// Handle different item formats
let label = "";
let shortcut = "";
let description = "";
if (typeof item === "string") {
label = item;
} else if (typeof item === "object") {
label = item.label || item.title || item.name || "";
shortcut = item.shortcut || "";
description = item.description || "";
}
return (
<MenuItemFocusIndicator
key={index}
isFocused={isFocused}
isSelected={isSelected}
item={{ label, description, shortcut }}
index={index}
total={items.length}
>
<Box flexDirection="row" width={width}>
<Text
color={itemColor}
bold={isSelected && helpers.isEnabled("enhancedFocus")}
>
{itemPrefix}
{label}
</Text>
{showShortcuts && shortcut && (
<Text color={accessibleColors.shortcut}> ({shortcut})</Text>
)}
{description && (
<Text
color={accessibleColors.shortcut}
dimColor={!helpers.isEnabled("highContrast")}
>
{" "}
- {description}
</Text>
)}
</Box>
</MenuItemFocusIndicator>
);
};
// Handle empty items
if (!items || items.length === 0) {
return (
<Box>
<Text color="gray" dimColor>
No menu items available
</Text>
</Box>
);
}
// Generate keyboard shortcut descriptions for accessibility
const availableActions = ["up", "down", "select"];
const shortcutDescription = keyboard.describeShortcuts(availableActions);
return (
<Box
flexDirection="column"
width={width}
data-role="menu"
data-label={ariaLabel}
>
{/* Screen reader announcement for menu */}
<ScreenReaderOnly>
{`${ariaLabel} with ${items.length} items. ${shortcutDescription}`}
</ScreenReaderOnly>
{items.map((item, index) => renderMenuItem(item, index))}
{showShortcuts && !disabled && (
<Box marginTop={1}>
<Text
color={accessibleColors.shortcut}
dimColor={!helpers.isEnabled("highContrast")}
>
Use arrows to navigate, Enter to select
{items.some((item) => item.shortcut) && ", or press shortcut keys"}
</Text>
</Box>
)}
{/* Screen reader instructions */}
<ScreenReaderOnly>
{`End of ${ariaLabel}. Current selection: ${currentIndex + 1} of ${
items.length
}`}
</ScreenReaderOnly>
</Box>
);
};
module.exports = MenuList;

View File

@@ -0,0 +1,60 @@
const React = require("react");
const { Box, Text } = require("ink");
/**
* MinimumSizeWarning Component
* Displays a warning when terminal is too small
* Requirements: 10.1, 10.2, 10.5
*/
const MinimumSizeWarning = ({ message }) => {
return (
<Box
flexDirection="column"
alignItems="center"
justifyContent="center"
height="100%"
padding={2}
>
<Box
flexDirection="column"
alignItems="center"
borderStyle="double"
borderColor="yellow"
padding={2}
width={60}
>
<Text color="yellow" bold>
{message.title}
</Text>
<Box marginY={1}>
<Text>{message.message}</Text>
</Box>
<Box flexDirection="column" alignItems="center" marginY={1}>
<Text color="red">{message.current}</Text>
<Text color="green">{message.required}</Text>
</Box>
{message.details.length > 0 && (
<Box flexDirection="column" alignItems="center" marginTop={1}>
<Text color="gray">Issues:</Text>
{message.details.map((detail, index) => (
<Text key={index} color="gray">
{detail}
</Text>
))}
</Box>
)}
<Box marginTop={2}>
<Text color="gray" dimColor>
Press Ctrl+C to exit
</Text>
</Box>
</Box>
</Box>
);
};
module.exports = MinimumSizeWarning;

View File

@@ -0,0 +1,422 @@
/**
* Modern Interactive Box Component
* Enhanced interactive component with mouse support
* Requirements: 12.1, 12.2, 12.3
*/
const React = require("react");
const { Box, Text, useInput } = require("ink");
const useModernTerminal = require("../../hooks/useModernTerminal.js");
/**
* Interactive box with mouse and keyboard support
*/
const ModernInteractiveBox = ({
children,
onSelect,
onHover,
onFocus,
onBlur,
isSelected = false,
isFocused = false,
isHovered = false,
label = "",
bounds = { x: 0, y: 0, width: 20, height: 3 },
enableMouse = true,
enableKeyboard = true,
style = "rounded",
...props
}) => {
const { colors, unicode, mouse, capabilities } = useModernTerminal();
const [localHovered, setLocalHovered] = React.useState(false);
const [localFocused, setLocalFocused] = React.useState(false);
// Mouse interaction setup
React.useEffect(() => {
if (!enableMouse || !capabilities.mouseInteraction) {
return;
}
// Enable mouse tracking when component mounts
mouse.enable();
const handleMouseEvent = (event) => {
const { x, y, action, button } = event.detail;
if (mouse.isWithinBounds(x, y, bounds)) {
if (action === "press" && button === 0) {
// Left click
if (onSelect) onSelect();
}
if (!localHovered) {
setLocalHovered(true);
if (onHover) onHover(true);
}
} else {
if (localHovered) {
setLocalHovered(false);
if (onHover) onHover(false);
}
}
};
// Listen for mouse events
if (typeof window !== "undefined") {
window.addEventListener("terminalMouse", handleMouseEvent);
return () => {
window.removeEventListener("terminalMouse", handleMouseEvent);
mouse.disable();
};
}
}, [
enableMouse,
capabilities.mouseInteraction,
bounds,
localHovered,
onSelect,
onHover,
mouse,
]);
// Keyboard interaction
useInput((input, key) => {
if (!enableKeyboard) return;
if (key.return || key.space) {
if (onSelect) onSelect();
}
if (key.tab) {
if (localFocused) {
setLocalFocused(false);
if (onBlur) onBlur();
} else {
setLocalFocused(true);
if (onFocus) onFocus();
}
}
});
// Determine current state
const currentHovered = isHovered || localHovered;
const currentFocused = isFocused || localFocused;
const currentSelected = isSelected;
// Generate border style based on state and capabilities
const getBorderStyle = () => {
if (!capabilities.enhancedUnicode) {
// ASCII fallback
return {
borderStyle: "single",
borderColor: currentSelected
? "blue"
: currentFocused
? "cyan"
: currentHovered
? "yellow"
: "gray",
};
}
// Enhanced Unicode borders
const borderChars =
style === "rounded"
? {
topLeft: unicode.box.roundedTopLeft,
topRight: unicode.box.roundedTopRight,
bottomLeft: unicode.box.roundedBottomLeft,
bottomRight: unicode.box.roundedBottomRight,
horizontal: unicode.box.horizontal,
vertical: unicode.box.vertical,
}
: style === "double"
? {
topLeft: unicode.box.doubleTopLeft,
topRight: unicode.box.doubleTopRight,
bottomLeft: unicode.box.doubleBottomLeft,
bottomRight: unicode.box.doubleBottomRight,
horizontal: unicode.box.doubleHorizontal,
vertical: unicode.box.doubleVertical,
}
: {
topLeft: unicode.box.topLeft,
topRight: unicode.box.topRight,
bottomLeft: unicode.box.bottomLeft,
bottomRight: unicode.box.bottomRight,
horizontal: unicode.box.horizontal,
vertical: unicode.box.vertical,
};
let borderColor = "gray";
if (currentSelected) {
borderColor = capabilities.trueColor
? colors.getInkColor("#0080FF")
: "blue";
} else if (currentFocused) {
borderColor = capabilities.trueColor
? colors.getInkColor("#00FFFF")
: "cyan";
} else if (currentHovered) {
borderColor = capabilities.trueColor
? colors.getInkColor("#FFFF00")
: "yellow";
}
return {
borderStyle: "single", // Ink will handle the actual rendering
borderColor,
};
};
// Generate background color based on state
const getBackgroundColor = () => {
if (!capabilities.trueColor) {
return undefined; // Use terminal default
}
if (currentSelected) {
return colors.getInkColor("#001133");
} else if (currentFocused) {
return colors.getInkColor("#003333");
} else if (currentHovered) {
return colors.getInkColor("#333300");
}
return undefined;
};
// Generate status indicators
const generateStatusIndicators = () => {
const indicators = [];
if (currentSelected) {
const icon = unicode.getChar("symbols", "pointer", "►");
indicators.push(
<Text key="selected" color="blue" marginRight={1}>
{icon}
</Text>
);
}
if (currentFocused && !currentSelected) {
const icon = unicode.getChar("symbols", "circle", "○");
indicators.push(
<Text key="focused" color="cyan" marginRight={1}>
{icon}
</Text>
);
}
if (currentHovered && !currentFocused && !currentSelected) {
const icon = unicode.getChar("symbols", "middleDot", "·");
indicators.push(
<Text key="hovered" color="yellow" marginRight={1}>
{icon}
</Text>
);
}
return indicators;
};
// Generate interaction hints
const generateHints = () => {
const hints = [];
if (enableMouse && capabilities.mouseInteraction) {
hints.push("Click to select");
}
if (enableKeyboard) {
hints.push("Enter/Space to select");
hints.push("Tab to focus");
}
if (hints.length === 0) return null;
return (
<Text color="gray" dimColor>
{hints.join(" • ")}
</Text>
);
};
const borderStyle = getBorderStyle();
const backgroundColor = getBackgroundColor();
return (
<Box
flexDirection="column"
{...borderStyle}
backgroundColor={backgroundColor}
padding={1}
{...props}
>
{label && (
<Box flexDirection="row" alignItems="center" marginBottom={1}>
{generateStatusIndicators()}
<Text bold={currentSelected || currentFocused}>{label}</Text>
</Box>
)}
<Box flexDirection="column">{children}</Box>
{(currentHovered || currentFocused) && (
<Box marginTop={1}>{generateHints()}</Box>
)}
</Box>
);
};
/**
* Interactive button with modern styling
*/
const ModernInteractiveButton = ({
label = "Button",
onPress,
disabled = false,
variant = "primary",
size = "medium",
icon,
...props
}) => {
const { colors, unicode, capabilities } = useModernTerminal();
const [isPressed, setIsPressed] = React.useState(false);
// Button variants
const variants = {
primary: {
color: capabilities.trueColor ? "#FFFFFF" : "white",
backgroundColor: capabilities.trueColor ? "#0080FF" : "blue",
hoverColor: capabilities.trueColor ? "#FFFFFF" : "white",
hoverBackgroundColor: capabilities.trueColor ? "#0099FF" : "cyan",
},
secondary: {
color: capabilities.trueColor ? "#000000" : "black",
backgroundColor: capabilities.trueColor ? "#CCCCCC" : "gray",
hoverColor: capabilities.trueColor ? "#000000" : "black",
hoverBackgroundColor: capabilities.trueColor ? "#DDDDDD" : "white",
},
success: {
color: capabilities.trueColor ? "#FFFFFF" : "white",
backgroundColor: capabilities.trueColor ? "#00AA00" : "green",
hoverColor: capabilities.trueColor ? "#FFFFFF" : "white",
hoverBackgroundColor: capabilities.trueColor ? "#00CC00" : "green",
},
danger: {
color: capabilities.trueColor ? "#FFFFFF" : "white",
backgroundColor: capabilities.trueColor ? "#CC0000" : "red",
hoverColor: capabilities.trueColor ? "#FFFFFF" : "white",
hoverBackgroundColor: capabilities.trueColor ? "#DD0000" : "red",
},
};
const variantStyle = variants[variant] || variants.primary;
// Size configurations
const sizes = {
small: { paddingX: 1, paddingY: 0 },
medium: { paddingX: 2, paddingY: 1 },
large: { paddingX: 3, paddingY: 1 },
};
const sizeStyle = sizes[size] || sizes.medium;
const handlePress = () => {
if (disabled) return;
setIsPressed(true);
setTimeout(() => setIsPressed(false), 100);
if (onPress) onPress();
};
const generateIcon = () => {
if (!icon) return null;
const iconChar =
typeof icon === "string" ? unicode.getChar("symbols", icon, icon) : icon;
return <Text marginRight={1}>{iconChar}</Text>;
};
return (
<ModernInteractiveBox onSelect={handlePress} style="rounded" {...props}>
<Box
flexDirection="row"
alignItems="center"
justifyContent="center"
paddingX={sizeStyle.paddingX}
paddingY={sizeStyle.paddingY}
backgroundColor={
isPressed
? variantStyle.hoverBackgroundColor
: variantStyle.backgroundColor
}
>
{generateIcon()}
<Text
color={isPressed ? variantStyle.hoverColor : variantStyle.color}
bold={!disabled}
dimColor={disabled}
>
{label}
</Text>
</Box>
</ModernInteractiveBox>
);
};
/**
* Interactive list with mouse and keyboard navigation
*/
const ModernInteractiveList = ({
items = [],
selectedIndex = 0,
onSelect,
onHighlight,
enableMouse = true,
...props
}) => {
const [currentIndex, setCurrentIndex] = React.useState(selectedIndex);
const handleItemSelect = (index) => {
setCurrentIndex(index);
if (onSelect) onSelect(index, items[index]);
};
const handleItemHover = (index, isHovered) => {
if (isHovered && onHighlight) {
onHighlight(index, items[index]);
}
};
return (
<Box flexDirection="column" {...props}>
{items.map((item, index) => (
<ModernInteractiveBox
key={index}
label={typeof item === "string" ? item : item.label}
isSelected={index === currentIndex}
onSelect={() => handleItemSelect(index)}
onHover={(hovered) => handleItemHover(index, hovered)}
enableMouse={enableMouse}
bounds={{ x: 0, y: index * 3, width: 40, height: 3 }}
marginBottom={1}
>
{typeof item === "object" && item.description && (
<Text color="gray">{item.description}</Text>
)}
</ModernInteractiveBox>
))}
</Box>
);
};
module.exports = {
ModernInteractiveBox,
ModernInteractiveButton,
ModernInteractiveList,
};

View File

@@ -0,0 +1,253 @@
/**
* Modern Progress Bar Component
* Enhanced progress bar with true color and Unicode support
* Requirements: 12.1, 12.2, 12.3
*/
const React = require("react");
const { Box, Text } = require("ink");
const useModernTerminal = require("../../hooks/useModernTerminal.js");
/**
* Modern progress bar with enhanced features
*/
const ModernProgressBar = ({
progress = 0,
total = 100,
width = 40,
label = "",
showPercentage = true,
showNumbers = false,
color = "#00FF00",
backgroundColor = "#333333",
style = "blocks",
animated = false,
...props
}) => {
const { colors, unicode, utils, capabilities } = useModernTerminal();
const [animationFrame, setAnimationFrame] = React.useState(0);
// Calculate progress percentage
const percentage = Math.min(100, Math.max(0, (progress / total) * 100));
const filled = Math.round((percentage / 100) * width);
const empty = width - filled;
// Animation effect
React.useEffect(() => {
if (!animated || !capabilities.enhancedUnicode) return;
const interval = setInterval(() => {
setAnimationFrame((frame) => (frame + 1) % 8);
}, 150);
return () => clearInterval(interval);
}, [animated, capabilities.enhancedUnicode]);
// Generate progress bar content
const generateProgressBar = () => {
if (style === "blocks" && capabilities.enhancedUnicode) {
// Use Unicode block characters
const fullChar = unicode.getChar("progress", "full", "█");
const emptyChar = unicode.getChar("progress", "empty", "░");
let progressContent = "";
if (capabilities.trueColor) {
// Use true colors
const fillColor = colors.getInkColor(color);
const bgColor = colors.getInkColor(backgroundColor);
progressContent = (
<>
<Text color={fillColor}>{fullChar.repeat(filled)}</Text>
<Text color={bgColor}>{emptyChar.repeat(empty)}</Text>
</>
);
} else {
// Fallback to standard colors
progressContent = (
<>
<Text color="green">{fullChar.repeat(filled)}</Text>
<Text color="gray">{emptyChar.repeat(empty)}</Text>
</>
);
}
return progressContent;
}
// ASCII fallback
const fillChar = "#";
const emptyChar = "-";
return (
<>
<Text color="green">{fillChar.repeat(filled)}</Text>
<Text color="gray">{emptyChar.repeat(empty)}</Text>
</>
);
};
// Generate animated spinner if enabled
const generateSpinner = () => {
if (!animated) return null;
const spinnerChar = utils.createSpinner(animationFrame);
return (
<Text color="cyan" marginRight={1}>
{spinnerChar}
</Text>
);
};
// Generate percentage display
const generatePercentage = () => {
if (!showPercentage) return null;
const percentText = `${Math.round(percentage)}%`;
return <Text marginLeft={1}>{percentText}</Text>;
};
// Generate numbers display
const generateNumbers = () => {
if (!showNumbers) return null;
const numbersText = `${progress}/${total}`;
return (
<Text color="gray" marginLeft={1}>
({numbersText})
</Text>
);
};
return (
<Box flexDirection="column" {...props}>
{label && <Text marginBottom={1}>{label}</Text>}
<Box flexDirection="row" alignItems="center">
{generateSpinner()}
<Box flexDirection="row">{generateProgressBar()}</Box>
{generatePercentage()}
{generateNumbers()}
</Box>
</Box>
);
};
/**
* Circular progress indicator using Unicode characters
*/
const ModernCircularProgress = ({
progress = 0,
total = 100,
size = "medium",
color = "#00FF00",
showPercentage = true,
...props
}) => {
const { colors, unicode, capabilities } = useModernTerminal();
const percentage = Math.min(100, Math.max(0, (progress / total) * 100));
// Size configurations
const sizeConfig = {
small: { radius: 1, chars: ["○", "◐", "◑", "◒", "●"] },
medium: { radius: 2, chars: ["○", "◔", "◑", "◕", "●"] },
large: { radius: 3, chars: ["○", "◔", "◑", "◕", "●"] },
};
const config = sizeConfig[size] || sizeConfig.medium;
const charIndex = Math.floor((percentage / 100) * (config.chars.length - 1));
const progressChar = config.chars[charIndex];
const displayColor = capabilities.trueColor
? colors.getInkColor(color)
: "green";
return (
<Box flexDirection="row" alignItems="center" {...props}>
<Text color={displayColor} fontSize={size === "large" ? 2 : 1}>
{progressChar}
</Text>
{showPercentage && <Text marginLeft={1}>{Math.round(percentage)}%</Text>}
</Box>
);
};
/**
* Multi-segment progress bar
*/
const ModernSegmentedProgress = ({
segments = [],
width = 40,
showLabels = true,
...props
}) => {
const { colors, unicode, capabilities } = useModernTerminal();
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const generateSegments = () => {
let currentPosition = 0;
return segments.map((segment, index) => {
const segmentWidth = Math.round((segment.value / total) * width);
const char = capabilities.enhancedUnicode
? unicode.getChar("progress", "full", "█")
: "#";
const segmentColor = capabilities.trueColor
? colors.getInkColor(segment.color || "#00FF00")
: segment.color || "green";
currentPosition += segmentWidth;
return (
<Text key={index} color={segmentColor}>
{char.repeat(segmentWidth)}
</Text>
);
});
};
const generateLabels = () => {
if (!showLabels) return null;
return (
<Box flexDirection="row" marginTop={1} gap={2}>
{segments.map((segment, index) => (
<Box key={index} flexDirection="row" alignItems="center">
<Text
color={
capabilities.trueColor
? colors.getInkColor(segment.color || "#00FF00")
: segment.color || "green"
}
>
</Text>
<Text marginLeft={1} color="gray">
{segment.label} ({segment.value})
</Text>
</Box>
))}
</Box>
);
};
return (
<Box flexDirection="column" {...props}>
<Box flexDirection="row">{generateSegments()}</Box>
{generateLabels()}
</Box>
);
};
module.exports = {
ModernProgressBar,
ModernCircularProgress,
ModernSegmentedProgress,
};

View File

@@ -0,0 +1,410 @@
/**
* Modern Status Indicator Component
* Enhanced status indicators with true color and Unicode support
* Requirements: 12.1, 12.2, 12.3
*/
const React = require("react");
const { Box, Text } = require("ink");
const useModernTerminal = require("../../hooks/useModernTerminal.js");
/**
* Modern status indicator with enhanced visuals
*/
const ModernStatusIndicator = ({
status = "idle",
label = "",
showLabel = true,
size = "medium",
animated = false,
customColor,
customIcon,
...props
}) => {
const { colors, unicode, utils, capabilities } = useModernTerminal();
const [animationFrame, setAnimationFrame] = React.useState(0);
// Status configurations
const statusConfig = {
success: {
color: "#00FF00",
icon: "checkMark",
fallback: "✓",
label: "Success",
},
error: {
color: "#FF0000",
icon: "crossMark",
fallback: "✗",
label: "Error",
},
warning: {
color: "#FFFF00",
icon: "warning",
fallback: "!",
label: "Warning",
},
info: {
color: "#00FFFF",
icon: "info",
fallback: "i",
label: "Info",
},
loading: {
color: "#0080FF",
icon: "spinner",
fallback: "...",
label: "Loading",
animated: true,
},
idle: {
color: "#808080",
icon: "circle",
fallback: "○",
label: "Idle",
},
connected: {
color: "#00FF00",
icon: "filledCircle",
fallback: "●",
label: "Connected",
},
disconnected: {
color: "#FF0000",
icon: "circle",
fallback: "○",
label: "Disconnected",
},
processing: {
color: "#FF8000",
icon: "spinner",
fallback: "⟳",
label: "Processing",
animated: true,
},
};
const config = statusConfig[status] || statusConfig.idle;
const shouldAnimate = animated || config.animated;
// Animation effect
React.useEffect(() => {
if (!shouldAnimate) return;
const interval = setInterval(() => {
setAnimationFrame((frame) => (frame + 1) % 8);
}, 200);
return () => clearInterval(interval);
}, [shouldAnimate]);
// Generate status icon
const generateIcon = () => {
let icon;
if (customIcon) {
icon = customIcon;
} else if (config.icon === "spinner" && shouldAnimate) {
icon = utils.createSpinner(animationFrame);
} else {
icon = unicode.getChar("symbols", config.icon, config.fallback);
}
const iconColor =
customColor ||
(capabilities.trueColor
? colors.getInkColor(config.color)
: config.color.toLowerCase().replace("#", ""));
const sizeStyle = {
small: {},
medium: { bold: true },
large: { bold: true },
};
return (
<Text color={iconColor} {...sizeStyle[size]}>
{icon}
</Text>
);
};
// Generate status label
const generateLabel = () => {
if (!showLabel) return null;
const labelText = label || config.label;
const labelColor = capabilities.trueColor
? colors.getInkColor("#FFFFFF")
: "white";
return (
<Text color={labelColor} marginLeft={1}>
{labelText}
</Text>
);
};
return (
<Box flexDirection="row" alignItems="center" {...props}>
{generateIcon()}
{generateLabel()}
</Box>
);
};
/**
* Connection status indicator with pulse animation
*/
const ModernConnectionStatus = ({
isConnected = false,
label = "",
showDetails = false,
details = {},
...props
}) => {
const { colors, unicode, capabilities } = useModernTerminal();
const [pulseFrame, setPulseFrame] = React.useState(0);
// Pulse animation for connected state
React.useEffect(() => {
if (!isConnected) return;
const interval = setInterval(() => {
setPulseFrame((frame) => (frame + 1) % 6);
}, 300);
return () => clearInterval(interval);
}, [isConnected]);
const generateConnectionIcon = () => {
if (isConnected) {
// Pulsing connected indicator
const intensity = Math.sin((pulseFrame / 6) * Math.PI * 2) * 0.3 + 0.7;
const baseColor = capabilities.trueColor ? "#00FF00" : "green";
// For true color terminals, we could adjust brightness
// For now, just use the base color
const icon = unicode.getChar("symbols", "filledCircle", "●");
return (
<Text color={colors.getInkColor(baseColor)} bold>
{icon}
</Text>
);
} else {
// Disconnected indicator
const icon = unicode.getChar("symbols", "circle", "○");
const color = capabilities.trueColor
? colors.getInkColor("#FF0000")
: "red";
return <Text color={color}>{icon}</Text>;
}
};
const generateDetails = () => {
if (!showDetails || !details) return null;
return (
<Box flexDirection="column" marginLeft={2}>
{Object.entries(details).map(([key, value]) => (
<Text key={key} color="gray">
{key}: {value}
</Text>
))}
</Box>
);
};
return (
<Box flexDirection="column" {...props}>
<Box flexDirection="row" alignItems="center">
{generateConnectionIcon()}
<Text marginLeft={1}>
{label || (isConnected ? "Connected" : "Disconnected")}
</Text>
</Box>
{generateDetails()}
</Box>
);
};
/**
* Multi-state status indicator
*/
const ModernMultiStateIndicator = ({
states = [],
currentState = 0,
showProgress = false,
orientation = "horizontal",
...props
}) => {
const { colors, unicode, capabilities } = useModernTerminal();
const generateStateIndicators = () => {
return states.map((state, index) => {
const isActive = index === currentState;
const isCompleted = index < currentState;
const isPending = index > currentState;
let icon, color;
if (isCompleted) {
icon = unicode.getChar("symbols", "checkMark", "✓");
color = capabilities.trueColor
? colors.getInkColor("#00FF00")
: "green";
} else if (isActive) {
icon = unicode.getChar("symbols", "pointer", "►");
color = capabilities.trueColor ? colors.getInkColor("#0080FF") : "blue";
} else {
icon = unicode.getChar("symbols", "circle", "○");
color = capabilities.trueColor ? colors.getInkColor("#808080") : "gray";
}
const connector =
index < states.length - 1 ? (
<Text color="gray" marginX={1}>
{orientation === "horizontal" ? "─" : "│"}
</Text>
) : null;
return (
<React.Fragment key={index}>
<Box
flexDirection={orientation === "horizontal" ? "row" : "column"}
alignItems="center"
>
<Text color={color}>{icon}</Text>
<Text marginLeft={1} color={isActive ? "white" : "gray"}>
{state.label || `State ${index + 1}`}
</Text>
</Box>
{connector}
</React.Fragment>
);
});
};
const generateProgress = () => {
if (!showProgress) return null;
const progress = ((currentState + 1) / states.length) * 100;
return (
<Box marginTop={1}>
<Text color="gray">
Progress: {Math.round(progress)}% ({currentState + 1}/{states.length})
</Text>
</Box>
);
};
return (
<Box flexDirection="column" {...props}>
<Box flexDirection={orientation === "horizontal" ? "row" : "column"}>
{generateStateIndicators()}
</Box>
{generateProgress()}
</Box>
);
};
/**
* Health status indicator with metrics
*/
const ModernHealthIndicator = ({
health = "unknown",
metrics = {},
showMetrics = false,
thresholds = {},
...props
}) => {
const { colors, unicode, utils, capabilities } = useModernTerminal();
// Health status configurations
const healthConfig = {
healthy: {
color: "#00FF00",
icon: "checkMark",
label: "Healthy",
},
degraded: {
color: "#FFFF00",
icon: "warning",
label: "Degraded",
},
unhealthy: {
color: "#FF0000",
icon: "crossMark",
label: "Unhealthy",
},
unknown: {
color: "#808080",
icon: "info",
label: "Unknown",
},
};
const config = healthConfig[health] || healthConfig.unknown;
const generateHealthIcon = () => {
const icon = unicode.getChar("symbols", config.icon, "?");
const color = capabilities.trueColor
? colors.getInkColor(config.color)
: config.color.toLowerCase().replace("#", "");
return (
<Text color={color} bold>
{icon}
</Text>
);
};
const generateMetrics = () => {
if (!showMetrics || !metrics) return null;
return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{Object.entries(metrics).map(([key, value]) => {
const threshold = thresholds[key];
let metricColor = "white";
if (threshold) {
if (value > threshold.critical) {
metricColor = "red";
} else if (value > threshold.warning) {
metricColor = "yellow";
} else {
metricColor = "green";
}
}
return (
<Text key={key} color={metricColor}>
{key}: {value}
</Text>
);
})}
</Box>
);
};
return (
<Box flexDirection="column" {...props}>
<Box flexDirection="row" alignItems="center">
{generateHealthIcon()}
<Text marginLeft={1}>{config.label}</Text>
</Box>
{generateMetrics()}
</Box>
);
};
module.exports = {
ModernStatusIndicator,
ModernConnectionStatus,
ModernMultiStateIndicator,
ModernHealthIndicator,
};

View File

@@ -0,0 +1,325 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const useAccessibility = require("../../hooks/useAccessibility.js");
const {
MenuItemFocusIndicator,
ScreenReaderOnly,
} = require("./FocusIndicator.jsx");
/**
* Optimized MenuList Component with React.memo and performance enhancements
* Requirements: 4.1, 4.3, 4.4
*/
// Memoized menu item component to prevent unnecessary re-renders
const MemoizedMenuItem = React.memo(
({
item,
index,
isSelected,
isFocused,
showShortcuts,
accessibleColors,
prefix,
normalPrefix,
width,
helpers,
}) => {
const itemColor = accessibleColors.disabled
? accessibleColors.disabled
: isSelected
? accessibleColors.highlight
: accessibleColors.normal;
const itemPrefix = isSelected ? prefix : normalPrefix;
// Handle different item formats
let label = "";
let shortcut = "";
let description = "";
if (typeof item === "string") {
label = item;
} else if (typeof item === "object") {
label = item.label || item.title || item.name || "";
shortcut = item.shortcut || "";
description = item.description || "";
}
return (
<MenuItemFocusIndicator
isFocused={isFocused}
isSelected={isSelected}
item={{ label, description, shortcut }}
index={index}
>
<Box flexDirection="row" width={width}>
<Text
color={itemColor}
bold={isSelected && helpers.isEnabled("enhancedFocus")}
>
{itemPrefix}
{label}
</Text>
{showShortcuts && shortcut && (
<Text color={accessibleColors.shortcut}> ({shortcut})</Text>
)}
{description && (
<Text
color={accessibleColors.shortcut}
dimColor={!helpers.isEnabled("highContrast")}
>
{" "}
- {description}
</Text>
)}
</Box>
</MenuItemFocusIndicator>
);
}
);
// Debounced selection handler to prevent rapid state updates
const useDebouncedSelection = (callback, delay = 50) => {
const timeoutRef = React.useRef(null);
return React.useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
);
};
const OptimizedMenuList = React.memo(
({
items = [],
selectedIndex = 0,
onSelect,
onHighlight,
showShortcuts = true,
highlightColor = "blue",
normalColor = "white",
shortcutColor = "gray",
prefix = "► ",
normalPrefix = " ",
disabled = false,
width,
ariaLabel = "Menu",
}) => {
const [currentIndex, setCurrentIndex] = React.useState(selectedIndex);
const { helpers, colors, screenReader, keyboard } = useAccessibility();
// Debounced handlers to prevent rapid updates
const debouncedOnHighlight = useDebouncedSelection(onHighlight, 50);
const debouncedScreenReaderAnnounce = useDebouncedSelection(
(announcement) => screenReader.announce(announcement, "polite"),
100
);
// Update current index when selectedIndex prop changes
React.useEffect(() => {
setCurrentIndex(selectedIndex);
}, [selectedIndex]);
// Memoized accessible colors to prevent recalculation
const accessibleColors = React.useMemo(() => {
const colorScheme = colors.getAll();
return {
highlight: helpers.isEnabled("highContrast")
? colorScheme.selection
: highlightColor,
normal: helpers.isEnabled("highContrast")
? colorScheme.foreground
: normalColor,
shortcut: helpers.isEnabled("highContrast")
? colorScheme.info
: shortcutColor,
disabled: colorScheme.disabled,
};
}, [colors, helpers, highlightColor, normalColor, shortcutColor]);
// Memoized keyboard shortcut descriptions
const shortcutDescription = React.useMemo(() => {
const availableActions = ["up", "down", "select"];
return keyboard.describeShortcuts(availableActions);
}, [keyboard]);
// Announce menu changes to screen reader with debouncing
React.useEffect(() => {
if (helpers.isEnabled("screenReader") && items.length > 0) {
const currentItem = items[currentIndex];
if (currentItem) {
const announcement = screenReader.describeMenuItem(
currentItem,
currentIndex,
items.length,
true
);
debouncedScreenReaderAnnounce(announcement);
}
}
}, [
currentIndex,
items,
helpers,
screenReader,
debouncedScreenReaderAnnounce,
]);
// Optimized navigation handler with debouncing
const handleNavigation = React.useCallback(
(newIndex) => {
setCurrentIndex(newIndex);
if (debouncedOnHighlight) {
debouncedOnHighlight(newIndex, items[newIndex]);
}
},
[items, debouncedOnHighlight]
);
// Handle keyboard navigation with accessibility support
useInput(
React.useCallback(
(input, key) => {
if (disabled || items.length === 0) return;
// Use accessibility-aware keyboard navigation
if (keyboard.isNavigationKey(key, "up")) {
const newIndex =
currentIndex > 0 ? currentIndex - 1 : items.length - 1;
handleNavigation(newIndex);
} else if (keyboard.isNavigationKey(key, "down")) {
const newIndex =
currentIndex < items.length - 1 ? currentIndex + 1 : 0;
handleNavigation(newIndex);
} else if (keyboard.isNavigationKey(key, "select")) {
if (onSelect && items[currentIndex]) {
// Announce selection to screen reader
if (helpers.isEnabled("screenReader")) {
const item = items[currentIndex];
const label =
typeof item === "string"
? item
: item.label || item.title || item.name;
screenReader.announce(`Selected ${label}`, "assertive");
}
onSelect(currentIndex, items[currentIndex]);
}
} else if (input && showShortcuts) {
// Handle shortcut keys
const shortcutItem = items.find(
(item) =>
item.shortcut &&
item.shortcut.toLowerCase() === input.toLowerCase()
);
if (shortcutItem) {
const shortcutIndex = items.indexOf(shortcutItem);
setCurrentIndex(shortcutIndex);
if (onSelect) {
// Announce shortcut selection to screen reader
if (helpers.isEnabled("screenReader")) {
const label =
typeof shortcutItem === "string"
? shortcutItem
: shortcutItem.label ||
shortcutItem.title ||
shortcutItem.name;
screenReader.announce(
`Selected ${label} via shortcut`,
"assertive"
);
}
onSelect(shortcutIndex, shortcutItem);
}
}
}
},
[
disabled,
items,
keyboard,
currentIndex,
handleNavigation,
onSelect,
helpers,
screenReader,
showShortcuts,
]
)
);
// Handle empty items
if (!items || items.length === 0) {
return (
<Box>
<Text color="gray" dimColor>
No menu items available
</Text>
</Box>
);
}
return (
<Box
flexDirection="column"
width={width}
data-role="menu"
data-label={ariaLabel}
>
{/* Screen reader announcement for menu */}
<ScreenReaderOnly>
{`${ariaLabel} with ${items.length} items. ${shortcutDescription}`}
</ScreenReaderOnly>
{/* Render menu items with memoization */}
{items.map((item, index) => (
<MemoizedMenuItem
key={`${index}-${
typeof item === "string" ? item : item.label || item.id || index
}`}
item={item}
index={index}
isSelected={index === currentIndex}
isFocused={index === currentIndex}
showShortcuts={showShortcuts}
accessibleColors={accessibleColors}
prefix={prefix}
normalPrefix={normalPrefix}
width={width}
helpers={helpers}
/>
))}
{showShortcuts && !disabled && (
<Box marginTop={1}>
<Text
color={accessibleColors.shortcut}
dimColor={!helpers.isEnabled("highContrast")}
>
Use arrows to navigate, Enter to select
{items.some((item) => item.shortcut) &&
", or press shortcut keys"}
</Text>
</Box>
)}
{/* Screen reader instructions */}
<ScreenReaderOnly>
{`End of ${ariaLabel}. Current selection: ${currentIndex + 1} of ${
items.length
}`}
</ScreenReaderOnly>
</Box>
);
}
);
module.exports = OptimizedMenuList;

View File

@@ -0,0 +1,261 @@
const React = require("react");
const { Box, Text } = require("ink");
/**
* Optimized ProgressBar Component with React.memo and performance enhancements
* Minimizes re-renders and provides smooth progress updates
* Requirements: 4.1, 4.3, 4.4
*/
// Memoized progress bar segments to prevent unnecessary re-renders
const ProgressSegment = React.memo(
({ filled, empty, color, backgroundColor = "gray" }) => (
<Box flexDirection="row">
{filled > 0 && <Text color={color}>{"█".repeat(filled)}</Text>}
{empty > 0 && <Text color={backgroundColor}>{"░".repeat(empty)}</Text>}
</Box>
)
);
// Memoized label component
const ProgressLabel = React.memo(
({
label,
progress,
showPercentage = true,
labelColor = "white",
percentageColor = "blue",
}) => (
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
<Text color={labelColor}>{label}</Text>
{showPercentage && (
<Text color={percentageColor}>{Math.round(progress)}%</Text>
)}
</Box>
)
);
// Debounced progress updates to prevent excessive re-renders
const useDebouncedProgress = (progress, delay = 100) => {
const [debouncedProgress, setDebouncedProgress] = React.useState(progress);
const timeoutRef = React.useRef(null);
React.useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setDebouncedProgress(progress);
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [progress, delay]);
return debouncedProgress;
};
// Smooth progress animation hook
const useSmoothProgress = (targetProgress, animationSpeed = 50) => {
const [currentProgress, setCurrentProgress] = React.useState(0);
const animationRef = React.useRef(null);
React.useEffect(() => {
if (animationRef.current) {
clearInterval(animationRef.current);
}
if (Math.abs(targetProgress - currentProgress) > 0.1) {
animationRef.current = setInterval(() => {
setCurrentProgress((prev) => {
const diff = targetProgress - prev;
if (Math.abs(diff) < 0.5) {
clearInterval(animationRef.current);
return targetProgress;
}
return prev + diff * 0.1; // Smooth interpolation
});
}, animationSpeed);
}
return () => {
if (animationRef.current) {
clearInterval(animationRef.current);
}
};
}, [targetProgress, currentProgress, animationSpeed]);
return currentProgress;
};
const OptimizedProgressBar = React.memo(
({
progress = 0,
label = "",
color = "blue",
backgroundColor = "gray",
width = 40,
showPercentage = true,
showLabel = true,
labelColor = "white",
percentageColor = "blue",
animate = false,
animationSpeed = 50,
debounceDelay = 100,
style = "filled", // "filled", "blocks", "dots"
...boxProps
}) => {
// Clamp progress between 0 and 100
const clampedProgress = Math.max(0, Math.min(100, progress));
// Apply debouncing if specified
const debouncedProgress =
debounceDelay > 0
? useDebouncedProgress(clampedProgress, debounceDelay)
: clampedProgress;
// Apply smooth animation if specified
const finalProgress = animate
? useSmoothProgress(debouncedProgress, animationSpeed)
: debouncedProgress;
// Memoized progress bar calculations
const progressCalculations = React.useMemo(() => {
const filled = Math.round((finalProgress / 100) * width);
const empty = width - filled;
return { filled, empty };
}, [finalProgress, width]);
// Memoized progress characters based on style
const progressChars = React.useMemo(() => {
switch (style) {
case "blocks":
return { filled: "█", empty: "░" };
case "dots":
return { filled: "●", empty: "○" };
case "filled":
default:
return { filled: "█", empty: "░" };
}
}, [style]);
// Custom progress bar rendering for different styles
const renderProgressBar = React.useMemo(() => {
const { filled, empty } = progressCalculations;
return (
<Box flexDirection="row">
{filled > 0 && (
<Text color={color}>{progressChars.filled.repeat(filled)}</Text>
)}
{empty > 0 && (
<Text color={backgroundColor}>
{progressChars.empty.repeat(empty)}
</Text>
)}
</Box>
);
}, [progressCalculations, color, backgroundColor, progressChars]);
return (
<Box flexDirection="column" {...boxProps}>
{/* Label and percentage */}
{showLabel && (
<ProgressLabel
label={label}
progress={finalProgress}
showPercentage={showPercentage}
labelColor={labelColor}
percentageColor={percentageColor}
/>
)}
{/* Progress bar */}
{renderProgressBar}
{/* Additional progress info for accessibility */}
{showPercentage && (
<Box justifyContent="center" marginTop={0}>
<Text color="gray" dimColor>
{Math.round(finalProgress)}% complete
</Text>
</Box>
)}
</Box>
);
}
);
// Multi-progress bar component for multiple concurrent operations
const MultiProgressBar = React.memo(
({
progressItems = [],
width = 40,
showLabels = true,
showPercentages = true,
animate = false,
...boxProps
}) => {
// Memoized progress items to prevent unnecessary re-renders
const memoizedItems = React.useMemo(() => {
return progressItems.map((item, index) => ({
...item,
key: item.key || `progress-${index}`,
color:
item.color ||
["blue", "green", "yellow", "cyan", "magenta"][index % 5],
}));
}, [progressItems]);
return (
<Box flexDirection="column" {...boxProps}>
{memoizedItems.map((item) => (
<OptimizedProgressBar
key={item.key}
progress={item.progress}
label={item.label}
color={item.color}
width={width}
showLabel={showLabels}
showPercentage={showPercentages}
animate={animate}
marginBottom={1}
/>
))}
</Box>
);
}
);
// Circular progress indicator for indeterminate progress
const CircularProgress = React.memo(
({ size = 3, color = "blue", speed = 200, ...boxProps }) => {
const [frame, setFrame] = React.useState(0);
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
React.useEffect(() => {
const interval = setInterval(() => {
setFrame((prev) => (prev + 1) % frames.length);
}, speed);
return () => clearInterval(interval);
}, [speed, frames.length]);
return (
<Box {...boxProps}>
<Text color={color}>{frames[frame]}</Text>
</Box>
);
}
);
// Export components and utilities
OptimizedProgressBar.Multi = MultiProgressBar;
OptimizedProgressBar.Circular = CircularProgress;
module.exports = OptimizedProgressBar;

View File

@@ -0,0 +1,203 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
/**
* Pagination Component
* Reusable component for navigating large datasets across TUI screens
* Requirements: 4.1, 4.3
*/
const Pagination = ({
currentPage = 0,
totalPages = 1,
totalItems = 0,
itemsPerPage = 10,
onPageChange,
showItemCount = true,
showPageNumbers = true,
showNavigation = true,
compact = false,
disabled = false,
}) => {
useInput((input, key) => {
if (disabled || !onPageChange) return;
if (key.leftArrow || input === "h") {
if (currentPage > 0) {
onPageChange(currentPage - 1);
}
} else if (key.rightArrow || input === "l") {
if (currentPage < totalPages - 1) {
onPageChange(currentPage + 1);
}
} else if (key.home || input === "g") {
if (currentPage !== 0) {
onPageChange(0);
}
} else if (key.end || input === "G") {
if (currentPage !== totalPages - 1) {
onPageChange(totalPages - 1);
}
}
});
const getItemRange = () => {
const start = currentPage * itemsPerPage + 1;
const end = Math.min((currentPage + 1) * itemsPerPage, totalItems);
return { start, end };
};
const getPageNumbers = () => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i);
}
const pages = [];
const current = currentPage;
// Always show first page
pages.push(0);
if (current > 3) {
pages.push("...");
}
// Show pages around current
const start = Math.max(1, current - 1);
const end = Math.min(totalPages - 2, current + 1);
for (let i = start; i <= end; i++) {
if (!pages.includes(i)) {
pages.push(i);
}
}
if (current < totalPages - 4) {
pages.push("...");
}
// Always show last page
if (totalPages > 1 && !pages.includes(totalPages - 1)) {
pages.push(totalPages - 1);
}
return pages;
};
const canGoPrevious = currentPage > 0;
const canGoNext = currentPage < totalPages - 1;
const { start, end } = getItemRange();
if (totalPages <= 1 && !showItemCount) {
return null;
}
if (compact) {
return (
<Box flexDirection="row" alignItems="center">
{showPageNumbers && (
<Text color="white">
{currentPage + 1}/{totalPages}
</Text>
)}
{showItemCount && totalItems > 0 && (
<Text color="gray" marginLeft={2}>
({start}-{end} of {totalItems})
</Text>
)}
{showNavigation && (
<Text color="cyan" marginLeft={2}>
{canGoPrevious ? "←" : " "} {canGoNext ? "→" : " "}
</Text>
)}
</Box>
);
}
return (
<Box flexDirection="column" marginTop={1}>
{showItemCount && totalItems > 0 && (
<Box marginBottom={1}>
<Text color="gray">
Showing {start}-{end} of {totalItems} items
</Text>
</Box>
)}
<Box flexDirection="row" alignItems="center">
{showNavigation && (
<Box flexDirection="row" alignItems="center" marginRight={2}>
<Text color={canGoPrevious && !disabled ? "cyan" : "gray"}>
{canGoPrevious ? "← Prev" : " Prev"}
</Text>
<Text color="gray" marginLeft={1} marginRight={1}>
|
</Text>
<Text color={canGoNext && !disabled ? "cyan" : "gray"}>
{canGoNext ? "Next →" : "Next "}
</Text>
</Box>
)}
{showPageNumbers && totalPages > 1 && (
<Box flexDirection="row" alignItems="center">
<Text color="gray">Pages: </Text>
{getPageNumbers().map((page, index) => {
if (page === "...") {
return (
<Text key={`ellipsis-${index}`} color="gray" marginLeft={1}>
...
</Text>
);
}
const isCurrentPage = page === currentPage;
return (
<Text
key={page}
color={isCurrentPage ? "blue" : "white"}
bold={isCurrentPage}
marginLeft={1}
>
{page + 1}
</Text>
);
})}
</Box>
)}
</Box>
{showNavigation && !disabled && (
<Box marginTop={1}>
<Text color="cyan">
Navigation: (arrows), h/l (vim), g/G (first/last)
</Text>
</Box>
)}
</Box>
);
};
/**
* SimplePagination Component
* Minimal pagination for simple use cases
*/
const SimplePagination = ({
currentPage = 0,
totalPages = 1,
onPageChange,
disabled = false,
}) => {
return (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
showItemCount={false}
showPageNumbers={false}
compact={true}
disabled={disabled}
/>
);
};
module.exports = { Pagination, SimplePagination };

View File

@@ -0,0 +1,47 @@
const React = require("react");
const { Box } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const {
getResponsiveDimensions,
getResponsiveSpacing,
shouldHideOnSmallScreen,
} = require("../../utils/responsiveLayout.js");
/**
* ResponsiveContainer Component
* Provides responsive layout container with automatic sizing
* Requirements: 10.2, 10.3, 10.4
*/
const ResponsiveContainer = ({
children,
componentType = "default",
hideOnSmall = false,
padding = true,
...boxProps
}) => {
const { appState } = useAppState();
const { layoutConfig } = appState.terminalState;
// Check if component should be hidden on small screens
if (hideOnSmall && shouldHideOnSmallScreen(layoutConfig, componentType)) {
return null;
}
// Get responsive dimensions
const dimensions = getResponsiveDimensions(layoutConfig, componentType);
const spacing = getResponsiveSpacing(layoutConfig);
return (
<Box
width={dimensions.width}
height={dimensions.height}
padding={padding ? spacing.padding : 0}
margin={spacing.margin}
{...boxProps}
>
{children}
</Box>
);
};
module.exports = ResponsiveContainer;

View File

@@ -0,0 +1,54 @@
const React = require("react");
const { Box } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const {
getColumnLayout,
getResponsiveSpacing,
} = require("../../utils/responsiveLayout.js");
/**
* ResponsiveGrid Component
* Provides responsive grid layout that adapts to screen size
* Requirements: 10.2, 10.3, 10.4
*/
const ResponsiveGrid = ({
items = [],
renderItem,
minItemWidth = 20,
gap = true,
...boxProps
}) => {
const { appState } = useAppState();
const { layoutConfig } = appState.terminalState;
// Get column layout configuration
const columnLayout = getColumnLayout(layoutConfig, items.length);
const spacing = getResponsiveSpacing(layoutConfig);
// Ensure item width is not smaller than minimum
const itemWidth = Math.max(columnLayout.itemWidth, minItemWidth);
// Group items into rows
const rows = [];
for (let i = 0; i < items.length; i += columnLayout.columns) {
rows.push(items.slice(i, i + columnLayout.columns));
}
return (
<Box flexDirection="column" gap={gap ? spacing.gap : 0} {...boxProps}>
{rows.map((row, rowIndex) => (
<Box key={rowIndex} flexDirection="row" gap={gap ? spacing.gap : 0}>
{row.map((item, colIndex) => (
<Box key={colIndex} width={itemWidth} flexShrink={0}>
{renderItem(item, rowIndex * columnLayout.columns + colIndex)}
</Box>
))}
{/* Fill remaining columns with empty space */}
{row.length < columnLayout.columns && <Box flexGrow={1} />}
</Box>
))}
</Box>
);
};
module.exports = ResponsiveGrid;

View File

@@ -0,0 +1,50 @@
const React = require("react");
const { Text } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const {
getTextTruncationLength,
getAdaptiveFontStyle,
} = require("../../utils/responsiveLayout.js");
/**
* ResponsiveText Component
* Provides text with automatic truncation and adaptive styling
* Requirements: 10.2, 10.3, 10.4
*/
const ResponsiveText = ({
children,
maxWidth,
truncate = true,
styleType = "normal",
showEllipsis = true,
...textProps
}) => {
const { appState } = useAppState();
const { layoutConfig } = appState.terminalState;
// Get adaptive font style
const adaptiveStyle = getAdaptiveFontStyle(layoutConfig, styleType);
// Calculate truncation length
const containerWidth = maxWidth || layoutConfig.maxContentWidth;
const truncationLength = truncate
? getTextTruncationLength(layoutConfig, containerWidth)
: null;
// Process text content
let displayText = String(children || "");
if (truncate && truncationLength && displayText.length > truncationLength) {
const ellipsis = showEllipsis ? "..." : "";
displayText =
displayText.substring(0, truncationLength - ellipsis.length) + ellipsis;
}
return (
<Text {...adaptiveStyle} {...textProps}>
{displayText}
</Text>
);
};
module.exports = ResponsiveText;

View File

@@ -0,0 +1,107 @@
const React = require("react");
const { Box, Text } = require("ink");
const { useState, useEffect } = React;
const { useAppState } = require("../../providers/AppProvider.jsx");
const { getScrollableDimensions } = require("../../utils/responsiveLayout.js");
/**
* ScrollableContainer Component
* Provides scrollable content with pagination for large datasets
* Requirements: 10.2, 10.3, 10.4
*/
const ScrollableContainer = ({
items = [],
renderItem,
itemHeight = 1,
showScrollIndicators = true,
...boxProps
}) => {
const { appState } = useAppState();
const { layoutConfig } = appState.terminalState;
const [scrollPosition, setScrollPosition] = useState(0);
// Calculate scrollable dimensions
const scrollDimensions = getScrollableDimensions(
layoutConfig,
items.length,
itemHeight
);
const { visibleItems, needsScrolling, scrollHeight } = scrollDimensions;
// Calculate visible items based on scroll position
const startIndex = scrollPosition;
const endIndex = Math.min(startIndex + visibleItems, items.length);
const visibleItemsList = items.slice(startIndex, endIndex);
// Scroll handlers
const scrollUp = () => {
if (scrollPosition > 0) {
setScrollPosition(Math.max(0, scrollPosition - 1));
}
};
const scrollDown = () => {
const maxScroll = Math.max(0, items.length - visibleItems);
if (scrollPosition < maxScroll) {
setScrollPosition(Math.min(maxScroll, scrollPosition + 1));
}
};
// Reset scroll position when items change
useEffect(() => {
setScrollPosition(0);
}, [items.length]);
// Expose scroll functions to parent if needed
useEffect(() => {
if (appState.uiState.scrollUp) {
scrollUp();
}
if (appState.uiState.scrollDown) {
scrollDown();
}
}, [appState.uiState.scrollUp, appState.uiState.scrollDown]);
return (
<Box flexDirection="column" height={scrollHeight} {...boxProps}>
{/* Scroll indicator - top */}
{needsScrolling && showScrollIndicators && scrollPosition > 0 && (
<Box justifyContent="center">
<Text color="gray" dimColor>
More items above ({scrollPosition} hidden)
</Text>
</Box>
)}
{/* Visible items */}
<Box flexDirection="column" flexGrow={1}>
{visibleItemsList.map((item, index) => (
<Box key={startIndex + index} height={itemHeight}>
{renderItem(item, startIndex + index)}
</Box>
))}
</Box>
{/* Scroll indicator - bottom */}
{needsScrolling && showScrollIndicators && endIndex < items.length && (
<Box justifyContent="center">
<Text color="gray" dimColor>
More items below ({items.length - endIndex} hidden)
</Text>
</Box>
)}
{/* Scroll help text */}
{needsScrolling && showScrollIndicators && (
<Box justifyContent="center" marginTop={1}>
<Text color="gray" dimColor>
Use / or j/k to scroll {startIndex + 1}-{endIndex} of{" "}
{items.length}
</Text>
</Box>
)}
</Box>
);
};
module.exports = ScrollableContainer;

View File

@@ -0,0 +1,312 @@
const React = require("react");
const { Box, Text } = require("ink");
const { useState, useEffect, useMemo, useCallback } = React;
const { useAppState } = require("../../providers/AppProvider.jsx");
const { getScrollableDimensions } = require("../../utils/responsiveLayout.js");
/**
* Virtual Scrollable Container Component with performance optimizations
* Implements virtual scrolling for large datasets to improve performance
* Requirements: 4.1, 4.3, 4.4
*/
// Memoized scroll indicator component
const ScrollIndicator = React.memo(({ direction, count, hidden }) => (
<Box justifyContent="center">
<Text color="gray" dimColor>
{direction === "up" ? "↑" : "↓"} More items{" "}
{direction === "up" ? "above" : "below"} ({hidden} hidden)
</Text>
</Box>
));
// Memoized item wrapper to prevent unnecessary re-renders
const VirtualizedItem = React.memo(
({ item, index, renderItem, itemHeight }) => (
<Box key={index} height={itemHeight}>
{renderItem(item, index)}
</Box>
)
);
// Debounced scroll handler
const useDebouncedScroll = (callback, delay = 16) => {
const timeoutRef = React.useRef(null);
return useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
);
};
const VirtualScrollableContainer = React.memo(
({
items = [],
renderItem,
itemHeight = 1,
showScrollIndicators = true,
overscan = 5, // Number of items to render outside visible area for smooth scrolling
...boxProps
}) => {
const { appState } = useAppState();
const { layoutConfig } = appState.terminalState;
const [scrollPosition, setScrollPosition] = useState(0);
// Memoized scroll dimensions calculation
const scrollDimensions = useMemo(() => {
return getScrollableDimensions(layoutConfig, items.length, itemHeight);
}, [layoutConfig, items.length, itemHeight]);
const { visibleItems, needsScrolling, scrollHeight } = scrollDimensions;
// Calculate virtual scrolling parameters
const virtualScrollParams = useMemo(() => {
const maxScroll = Math.max(0, items.length - visibleItems);
const startIndex = Math.max(0, scrollPosition - overscan);
const endIndex = Math.min(
items.length,
scrollPosition + visibleItems + overscan
);
return {
maxScroll,
startIndex,
endIndex,
renderStartIndex: scrollPosition,
renderEndIndex: Math.min(scrollPosition + visibleItems, items.length),
};
}, [scrollPosition, visibleItems, items.length, overscan]);
// Get visible items with virtual scrolling
const visibleItemsList = useMemo(() => {
const { startIndex, endIndex } = virtualScrollParams;
return items.slice(startIndex, endIndex);
}, [items, virtualScrollParams]);
// Debounced scroll handlers
const debouncedSetScrollPosition = useDebouncedScroll(
setScrollPosition,
16
);
// Optimized scroll handlers
const scrollUp = useCallback(() => {
if (scrollPosition > 0) {
const newPosition = Math.max(0, scrollPosition - 1);
debouncedSetScrollPosition(newPosition);
}
}, [scrollPosition, debouncedSetScrollPosition]);
const scrollDown = useCallback(() => {
const { maxScroll } = virtualScrollParams;
if (scrollPosition < maxScroll) {
const newPosition = Math.min(maxScroll, scrollPosition + 1);
debouncedSetScrollPosition(newPosition);
}
}, [scrollPosition, virtualScrollParams, debouncedSetScrollPosition]);
// Page-based scrolling for better performance with large datasets
const scrollPageUp = useCallback(() => {
const pageSize = Math.floor(visibleItems * 0.8); // 80% of visible items
const newPosition = Math.max(0, scrollPosition - pageSize);
debouncedSetScrollPosition(newPosition);
}, [scrollPosition, visibleItems, debouncedSetScrollPosition]);
const scrollPageDown = useCallback(() => {
const { maxScroll } = virtualScrollParams;
const pageSize = Math.floor(visibleItems * 0.8);
const newPosition = Math.min(maxScroll, scrollPosition + pageSize);
debouncedSetScrollPosition(newPosition);
}, [
scrollPosition,
virtualScrollParams,
visibleItems,
debouncedSetScrollPosition,
]);
// Jump to specific position
const scrollToIndex = useCallback(
(index) => {
const { maxScroll } = virtualScrollParams;
const targetPosition = Math.max(0, Math.min(maxScroll, index));
setScrollPosition(targetPosition);
},
[virtualScrollParams]
);
// Reset scroll position when items change significantly
useEffect(() => {
const { maxScroll } = virtualScrollParams;
if (scrollPosition > maxScroll) {
setScrollPosition(maxScroll);
}
}, [items.length, virtualScrollParams, scrollPosition]);
// Expose scroll functions to parent if needed
useEffect(() => {
if (appState.uiState.scrollUp) {
scrollUp();
}
if (appState.uiState.scrollDown) {
scrollDown();
}
if (appState.uiState.scrollPageUp) {
scrollPageUp();
}
if (appState.uiState.scrollPageDown) {
scrollPageDown();
}
}, [
appState.uiState.scrollUp,
appState.uiState.scrollDown,
appState.uiState.scrollPageUp,
appState.uiState.scrollPageDown,
scrollUp,
scrollDown,
scrollPageUp,
scrollPageDown,
]);
// Memoized scroll indicators
const topScrollIndicator = useMemo(() => {
if (!needsScrolling || !showScrollIndicators || scrollPosition === 0) {
return null;
}
return (
<ScrollIndicator
direction="up"
count={scrollPosition}
hidden={scrollPosition}
/>
);
}, [needsScrolling, showScrollIndicators, scrollPosition]);
const bottomScrollIndicator = useMemo(() => {
const { renderEndIndex } = virtualScrollParams;
if (
!needsScrolling ||
!showScrollIndicators ||
renderEndIndex >= items.length
) {
return null;
}
return (
<ScrollIndicator
direction="down"
count={items.length - renderEndIndex}
hidden={items.length - renderEndIndex}
/>
);
}, [
needsScrolling,
showScrollIndicators,
virtualScrollParams,
items.length,
]);
// Memoized help text
const scrollHelpText = useMemo(() => {
if (!needsScrolling || !showScrollIndicators) return null;
const { renderStartIndex, renderEndIndex } = virtualScrollParams;
return (
<Box justifyContent="center" marginTop={1}>
<Text color="gray" dimColor>
Use / or j/k to scroll PgUp/PgDn for pages {" "}
{renderStartIndex + 1}-{renderEndIndex} of {items.length}
</Text>
</Box>
);
}, [
needsScrolling,
showScrollIndicators,
virtualScrollParams,
items.length,
]);
// Handle empty items
if (!items || items.length === 0) {
return (
<Box flexDirection="column" height={scrollHeight} {...boxProps}>
<Box justifyContent="center" alignItems="center" flexGrow={1}>
<Text color="gray" dimColor>
No items to display
</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column" height={scrollHeight} {...boxProps}>
{/* Top scroll indicator */}
{topScrollIndicator}
{/* Virtual scrolled items */}
<Box flexDirection="column" flexGrow={1}>
{/* Spacer for items above visible area */}
{virtualScrollParams.startIndex > 0 && (
<Box height={virtualScrollParams.startIndex * itemHeight} />
)}
{/* Render visible items */}
{visibleItemsList.map((item, index) => {
const actualIndex = virtualScrollParams.startIndex + index;
return (
<VirtualizedItem
key={`virtual-${actualIndex}`}
item={item}
index={actualIndex}
renderItem={renderItem}
itemHeight={itemHeight}
/>
);
})}
{/* Spacer for items below visible area */}
{virtualScrollParams.endIndex < items.length && (
<Box
height={
(items.length - virtualScrollParams.endIndex) * itemHeight
}
/>
)}
</Box>
{/* Bottom scroll indicator */}
{bottomScrollIndicator}
{/* Scroll help text */}
{scrollHelpText}
</Box>
);
}
);
// Export scroll utilities for external use
VirtualScrollableContainer.scrollUtils = {
calculateOptimalOverscan: (itemCount, visibleCount) => {
// Calculate optimal overscan based on dataset size
if (itemCount < 100) return 2;
if (itemCount < 1000) return 5;
return 10;
},
calculateItemHeight: (content) => {
// Estimate item height based on content
if (typeof content === "string") {
return Math.ceil(content.length / 80) || 1;
}
return 1;
},
};
module.exports = VirtualScrollableContainer;

View File

@@ -0,0 +1,15 @@
// Export all reusable TUI components
const ErrorDisplay = require("./ErrorDisplay.jsx");
const { LoadingIndicator, LoadingOverlay } = require("./LoadingIndicator.jsx");
const { Pagination, SimplePagination } = require("./Pagination.jsx");
const { FormInput, SimpleFormInput } = require("./FormInput.jsx");
module.exports = {
ErrorDisplay,
LoadingIndicator,
LoadingOverlay,
Pagination,
SimplePagination,
FormInput,
SimpleFormInput,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +1,47 @@
const React = require("react");
const { Box, Text, useInput, useApp } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const SelectInput = require("ink-select-input").default;
const LogReaderService = require("../../../services/logReader");
/**
* Log Viewer Screen Component
* Displays application logs with filtering and navigation capabilities
* Requirements: 7.5, 7.6
* Displays application logs with pagination, filtering and navigation capabilities
* Requirements: 6.1, 6.4, 10.3
*/
const LogViewerScreen = () => {
const { appState, navigateBack } = useAppState();
const { exit } = useApp();
// Mock log data (in a real implementation, this would read from log files)
const mockLogs = [
{
timestamp: new Date(Date.now() - 1000 * 60 * 5),
level: "INFO",
message: "Application started",
details: "Shopify Price Updater v1.0.0",
},
{
timestamp: new Date(Date.now() - 1000 * 60 * 4),
level: "INFO",
message: "Configuration loaded",
details: "Target tag: 'sale', Adjustment: 10%",
},
{
timestamp: new Date(Date.now() - 1000 * 60 * 3),
level: "INFO",
message: "Testing Shopify connection",
details: "Connecting to store...",
},
{
timestamp: new Date(Date.now() - 1000 * 60 * 2),
level: "SUCCESS",
message: "Connection successful",
details: "API version: 2023-01",
},
{
timestamp: new Date(Date.now() - 1000 * 60 * 1),
level: "INFO",
message: "Fetching products",
details: "Query: tag:sale",
},
{
timestamp: new Date(Date.now() - 1000 * 60 * 1),
level: "INFO",
message: "Products found",
details: "Found 15 products with tag 'sale'",
},
{
timestamp: new Date(Date.now() - 1000 * 30),
level: "INFO",
message: "Starting price updates",
details: "Processing 15 products, 42 variants total",
},
{
timestamp: new Date(Date.now() - 1000 * 25),
level: "INFO",
message: "Updating product",
details: "Product: 'Summer T-Shirt' - New price: $22.00",
},
{
timestamp: new Date(Date.now() - 1000 * 20),
level: "INFO",
message: "Updating product",
details: "Product: 'Winter Jacket' - New price: $110.00",
},
{
timestamp: new Date(Date.now() - 1000 * 15),
level: "WARNING",
message: "Price update failed",
details: "Product: 'Limited Edition Sneaker' - Insufficient permissions",
},
{
timestamp: new Date(Date.now() - 1000 * 10),
level: "INFO",
message: "Updating product",
details: "Product: 'Casual Jeans' - New price: $45.00",
},
{
timestamp: new Date(Date.now() - 1000 * 5),
level: "SUCCESS",
message: "Operation completed",
details: "Updated 40 of 42 variants (95.2% success rate)",
},
];
// Initialize log reader service
const [logReader] = React.useState(() => new LogReaderService());
// State for log viewing
const [logs, setLogs] = React.useState(mockLogs);
const [filteredLogs, setFilteredLogs] = React.useState(mockLogs);
const [filterLevel, setFilterLevel] = React.useState("ALL");
const [selectedLog, setSelectedLog] = React.useState(null);
// State for log viewing with pagination
const [logData, setLogData] = React.useState({
entries: [],
pagination: {
currentPage: 0,
pageSize: 10,
totalEntries: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
startIndex: 1,
endIndex: 0,
},
filters: {
levelFilter: "ALL",
searchTerm: "",
},
});
const [selectedLog, setSelectedLog] = React.useState(0);
const [showDetails, setShowDetails] = React.useState(false);
const [scrollPosition, setScrollPosition] = React.useState(0);
const [maxScroll, setMaxScroll] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [stats, setStats] = React.useState(null);
const [autoRefresh, setAutoRefresh] = React.useState(true);
const [lastRefresh, setLastRefresh] = React.useState(new Date());
const [refreshing, setRefreshing] = React.useState(false);
// Filter options
const filterOptions = [
@@ -106,17 +52,99 @@ const LogViewerScreen = () => {
{ value: "SUCCESS", label: "Success" },
];
// Filter logs based on selected level
const filterLogs = () => {
if (filterLevel === "ALL") {
setFilteredLogs(logs);
} else {
setFilteredLogs(logs.filter((log) => log.level === filterLevel));
// Load log data with current filters and pagination
const loadLogData = React.useCallback(
async (options = {}, isAutoRefresh = false) => {
try {
if (isAutoRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
const loadOptions = {
page: logData.pagination.currentPage,
pageSize: logData.pagination.pageSize,
levelFilter: logData.filters.levelFilter,
searchTerm: logData.filters.searchTerm,
...options,
};
const result = await logReader.getPaginatedEntries(loadOptions);
setLogData(result);
// Reset selection if current selection is out of bounds
if (selectedLog >= result.entries.length) {
setSelectedLog(Math.max(0, result.entries.length - 1));
}
setShowDetails(false);
setLastRefresh(new Date());
} catch (err) {
setError(`Failed to load logs: ${err.message}`);
} finally {
setLoading(false);
setRefreshing(false);
}
},
[
logReader,
logData.pagination.currentPage,
logData.pagination.pageSize,
logData.filters.levelFilter,
logData.filters.searchTerm,
selectedLog,
]
);
// Load statistics
const loadStats = React.useCallback(async () => {
try {
const statistics = await logReader.getLogStatistics();
setStats(statistics);
} catch (err) {
console.warn("Failed to load log statistics:", err.message);
}
};
}, [logReader]);
// Initial load
React.useEffect(() => {
loadLogData();
loadStats();
}, []);
// Auto-refresh functionality with file watching
React.useEffect(() => {
if (!autoRefresh) return;
const cleanup = logReader.watchFile(() => {
loadLogData({}, true); // Mark as auto-refresh
loadStats();
});
return cleanup;
}, [logReader, loadLogData, loadStats, autoRefresh]);
// Periodic refresh as backup (every 30 seconds)
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
// Only refresh if not currently loading
if (!loading && !refreshing) {
logReader.clearCache();
loadLogData({}, true);
loadStats();
}
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [logReader, loadLogData, loadStats, autoRefresh, loading, refreshing]);
// Handle keyboard input
useInput((input, key) => {
if (loading) return; // Ignore input while loading
if (key.escape) {
// Go back to main menu
navigateBack();
@@ -128,29 +156,80 @@ const LogViewerScreen = () => {
}
} else if (key.downArrow) {
// Navigate down in log list
if (selectedLog < filteredLogs.length - 1) {
if (selectedLog < logData.entries.length - 1) {
setSelectedLog(selectedLog + 1);
setShowDetails(false);
}
} else if (key.leftArrow) {
// Previous page
if (logData.pagination.hasPreviousPage) {
loadLogData({ page: logData.pagination.currentPage - 1 });
}
} else if (key.rightArrow) {
// Next page
if (logData.pagination.hasNextPage) {
loadLogData({ page: logData.pagination.currentPage + 1 });
}
} else if (key.return || key.enter) {
// Toggle log details
if (selectedLog !== null) {
if (selectedLog < logData.entries.length) {
setShowDetails(!showDetails);
}
} else if (key.r) {
} else if (key.r || input === "r") {
// Refresh logs
setLogs(mockLogs);
setFilteredLogs(mockLogs);
setSelectedLog(null);
setShowDetails(false);
logReader.clearCache();
loadLogData();
loadStats();
} else if (input >= "1" && input <= "5") {
// Quick filter by number
const filterMap = {
1: "ALL",
2: "ERROR",
3: "WARNING",
4: "INFO",
5: "SUCCESS",
};
const newFilter = filterMap[input];
if (newFilter !== logData.filters.levelFilter) {
loadLogData({
levelFilter: newFilter,
page: 0, // Reset to first page when filtering
});
}
} else if (input === "s") {
// Toggle search mode (simplified - cycle through common search terms)
const searchTerms = ["", "error", "update", "rollback", "product"];
const currentIndex = searchTerms.indexOf(logData.filters.searchTerm);
const nextIndex = (currentIndex + 1) % searchTerms.length;
const newSearchTerm = searchTerms[nextIndex];
loadLogData({
searchTerm: newSearchTerm,
page: 0, // Reset to first page when searching
});
} else if (input === "c") {
// Clear all filters
loadLogData({
levelFilter: "ALL",
searchTerm: "",
page: 0,
});
} else if (input === "a") {
// Toggle auto-refresh
setAutoRefresh(!autoRefresh);
} else if (key.pageUp) {
// Jump to first page
if (logData.pagination.currentPage > 0) {
loadLogData({ page: 0 });
}
} else if (key.pageDown) {
// Jump to last page
if (logData.pagination.currentPage < logData.pagination.totalPages - 1) {
loadLogData({ page: logData.pagination.totalPages - 1 });
}
}
});
// Handle filter change
const handleFilterChange = (option) => {
setFilterLevel(option.value);
};
// Get log level color
const getLogLevelColor = (level) => {
switch (level) {
@@ -176,22 +255,91 @@ const LogViewerScreen = () => {
});
};
// Format date for display
const formatDate = (date) => {
return date.toLocaleDateString([], {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// Truncate text for display
const truncateText = (text, maxLength = 60) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
};
// Show loading state
if (loading && logData.entries.length === 0) {
return React.createElement(
Box,
{
flexDirection: "column",
padding: 2,
justifyContent: "center",
alignItems: "center",
},
React.createElement(Text, { color: "blue" }, "Loading logs..."),
React.createElement(
Text,
{ color: "gray" },
"Please wait while we read the log files"
)
);
}
// Show error state
if (error) {
return React.createElement(
Box,
{
flexDirection: "column",
padding: 2,
justifyContent: "center",
alignItems: "center",
},
React.createElement(
Text,
{ color: "red", bold: true },
"Error Loading Logs"
),
React.createElement(Text, { color: "gray" }, error),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Press 'r' to retry or Esc to go back"
)
);
}
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
// Header
// Header with statistics
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 2 },
React.createElement(Text, { bold: true, color: "cyan" }, "📋 Log Viewer"),
React.createElement(
Text,
{ color: "gray" },
"View application logs and operation history"
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ color: "gray" },
"View application logs and operation history"
),
stats &&
React.createElement(
Text,
{ color: "gray" },
`${stats.totalEntries} entries | ${stats.operations.total} operations`
)
)
),
// Filter controls
// Filter and pagination controls
React.createElement(
Box,
{
@@ -204,36 +352,54 @@ const LogViewerScreen = () => {
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Text,
{ bold: true, color: "blue" },
"Log Filters:"
),
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(Text, { color: "white" }, "Level: "),
React.createElement(SelectInput, {
items: filterOptions,
selectedIndex: filterOptions.findIndex(
(opt) => opt.value === filterLevel
{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(
Text,
{ color: "white", bold: true },
"Filter: "
),
onSelect: handleFilterChange,
itemComponent: ({ label, isSelected }) =>
React.createElement(
Text,
{
color: isSelected ? "blue" : "white",
bold: isSelected,
},
label
),
})
React.createElement(
Text,
{ color: "blue" },
logData.filters.levelFilter
),
React.createElement(
Text,
{ color: "gray", marginLeft: 2 },
"(1-5 to change)"
)
),
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(Text, { color: "white", bold: true }, "Page: "),
React.createElement(
Text,
{ color: "blue" },
`${logData.pagination.currentPage + 1}/${
logData.pagination.totalPages
}`
),
React.createElement(
Text,
{ color: "gray", marginLeft: 2 },
"(←/→ to navigate)"
)
)
),
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
`Showing ${filteredLogs.length} of ${logs.length} log entries`
{ color: "gray" },
`Showing ${logData.pagination.startIndex}-${logData.pagination.endIndex} of ${logData.pagination.totalEntries} entries`
)
)
),
@@ -246,58 +412,74 @@ const LogViewerScreen = () => {
borderStyle: "single",
borderColor: "gray",
flexDirection: "column",
minHeight: 10,
},
filteredLogs.map((log, index) => {
const isSelected = selectedLog === index;
const isHighlighted = isSelected && !showDetails;
return React.createElement(
Box,
{
key: index,
borderStyle: "single",
borderColor: isSelected ? "blue" : "transparent",
paddingX: 1,
paddingY: 0.5,
backgroundColor: isHighlighted ? "blue" : undefined,
},
React.createElement(
logData.entries.length === 0
? React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
{ justifyContent: "center", alignItems: "center", padding: 2 },
React.createElement(
Text,
{
color: getLogLevelColor(log.level),
bold: true,
width: 8,
},
log.level
{ color: "gray" },
"No log entries found"
),
React.createElement(
Text,
{
color: isHighlighted ? "white" : "gray",
width: 8,
},
formatTimestamp(log.timestamp)
),
React.createElement(
Text,
{
color: isHighlighted ? "white" : "white",
flexGrow: 1,
},
log.message
{ color: "gray" },
"Try changing the filter or refresh with 'r'"
)
)
);
})
)
: logData.entries.map((log, index) => {
const isSelected = selectedLog === index;
const isHighlighted = isSelected && !showDetails;
return React.createElement(
Box,
{
key: log.id || index,
borderStyle: isSelected ? "single" : "none",
borderColor: isSelected ? "blue" : "transparent",
paddingX: 1,
paddingY: 0,
backgroundColor: isHighlighted ? "blue" : undefined,
},
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(
Text,
{
color: getLogLevelColor(log.level),
bold: true,
width: 8,
},
log.level
),
React.createElement(
Text,
{
color: isHighlighted ? "white" : "gray",
width: 12,
},
formatDate(log.timestamp)
),
React.createElement(
Text,
{
color: isHighlighted ? "white" : "white",
flexGrow: 1,
},
truncateText(log.message, 50)
)
)
);
})
),
// Log details (when selected)
showDetails &&
selectedLog !== null &&
filteredLogs[selectedLog] &&
selectedLog < logData.entries.length &&
logData.entries[selectedLog] &&
React.createElement(
Box,
{
@@ -306,6 +488,7 @@ const LogViewerScreen = () => {
paddingX: 1,
paddingY: 1,
marginTop: 2,
maxHeight: 8,
},
React.createElement(
Box,
@@ -325,8 +508,19 @@ const LogViewerScreen = () => {
),
React.createElement(
Text,
{ color: getLogLevelColor(filteredLogs[selectedLog].level) },
filteredLogs[selectedLog].level
{ color: getLogLevelColor(logData.entries[selectedLog].level) },
logData.entries[selectedLog].level
),
React.createElement(Text, { color: "gray", marginLeft: 2 }, "|"),
React.createElement(
Text,
{ color: "white", bold: true, marginLeft: 1 },
"Type: "
),
React.createElement(
Text,
{ color: "cyan" },
logData.entries[selectedLog].type || "unknown"
)
),
React.createElement(
@@ -336,7 +530,7 @@ const LogViewerScreen = () => {
React.createElement(
Text,
{ color: "gray" },
filteredLogs[selectedLog].timestamp.toLocaleString()
logData.entries[selectedLog].timestamp.toLocaleString()
)
),
React.createElement(
@@ -350,23 +544,24 @@ const LogViewerScreen = () => {
React.createElement(
Text,
{ color: "white" },
filteredLogs[selectedLog].message
logData.entries[selectedLog].message
)
),
React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
logData.entries[selectedLog].details &&
React.createElement(
Text,
{ color: "white", bold: true },
"Details:"
),
React.createElement(
Text,
{ color: "gray", italic: true },
filteredLogs[selectedLog].details
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{ color: "white", bold: true },
"Details:"
),
React.createElement(
Text,
{ color: "gray", italic: true },
truncateText(logData.entries[selectedLog].details, 200)
)
)
)
)
),
@@ -378,13 +573,40 @@ const LogViewerScreen = () => {
marginTop: 2,
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 2,
paddingTop: 1,
},
React.createElement(Text, { color: "gray" }, "Controls:"),
React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate logs"),
React.createElement(Text, { color: "gray" }, " Enter - View details"),
React.createElement(Text, { color: "gray" }, " R - Refresh logs"),
React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Text,
{ color: "gray" },
"Navigation: ↑/↓ entries | ←/→ pages | Enter details"
),
React.createElement(
Text,
{ color: "gray" },
"Filters: 1=All 2=Error 3=Warning 4=Info 5=Success"
)
),
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Text,
{ color: "gray" },
"Search: S cycle terms | C clear | A auto-refresh"
),
React.createElement(
Text,
{ color: "gray" },
"Actions: R refresh | PgUp/PgDn jump | Esc back"
)
)
)
),
// Status bar
@@ -394,15 +616,34 @@ const LogViewerScreen = () => {
borderStyle: "single",
borderColor: "gray",
paddingX: 1,
paddingY: 0.5,
paddingY: 0,
marginTop: 1,
},
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
`Selected: ${
selectedLog !== null ? `Log #${selectedLog + 1}` : "None"
} | Details: ${showDetails ? "Visible" : "Hidden"}`
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ color: "gray" },
`Entry ${selectedLog + 1}/${logData.entries.length} | Details: ${
showDetails ? "ON" : "OFF"
}`
),
React.createElement(
Text,
{ color: loading ? "yellow" : refreshing ? "cyan" : "gray" },
loading
? "Loading..."
: refreshing
? "Refreshing..."
: `Filter: ${logData.filters.levelFilter}${
logData.filters.searchTerm
? ` | Search: "${logData.filters.searchTerm}"`
: ""
} | Auto: ${
autoRefresh ? "ON" : "OFF"
} | ${lastRefresh.toLocaleTimeString()}`
)
)
)
);

View File

@@ -1,6 +1,13 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const {
createKeyboardHandler,
navigationKeys,
} = require("../../utils/keyboardHandlers.js");
const ResponsiveContainer = require("../common/ResponsiveContainer.jsx");
const ResponsiveText = require("../common/ResponsiveText.jsx");
const ScrollableContainer = require("../common/ScrollableContainer.jsx");
/**
* Main Menu Screen Component
@@ -8,7 +15,15 @@ const { useAppState } = require("../../providers/AppProvider.jsx");
* Requirements: 5.1, 5.3, 7.1
*/
const MainMenuScreen = () => {
const { appState, navigateTo, updateUIState } = useAppState();
const {
appState,
navigateTo,
navigateBack,
updateUIState,
toggleHelp,
showHelp,
hideHelp,
} = useAppState();
// Menu items configuration
const menuItems = [
@@ -40,161 +55,206 @@ const MainMenuScreen = () => {
{ id: "exit", label: "Exit", description: "Quit the application" },
];
// Handle keyboard input
useInput((input, key) => {
if (key.upArrow) {
// Navigate up in menu
const newIndex = Math.max(0, appState.uiState.selectedMenuIndex - 1);
updateUIState({ selectedMenuIndex: newIndex });
} else if (key.downArrow) {
// Navigate down in menu
const newIndex = Math.min(
menuItems.length - 1,
appState.uiState.selectedMenuIndex + 1
);
updateUIState({ selectedMenuIndex: newIndex });
} else if (key.return || key.enter || input === " ") {
// Select menu item
// Create screen-specific keyboard handler
const screenKeyboardHandler = (input, key) => {
// Handle menu navigation
const wasNavigationHandled = navigationKeys.handleMenuNavigation(
key,
appState.uiState.selectedMenuIndex,
menuItems.length - 1,
(newIndex) => updateUIState({ selectedMenuIndex: newIndex })
);
if (wasNavigationHandled) return;
// Handle menu selection
if (key.return || key.enter || input === " ") {
const selectedItem = menuItems[appState.uiState.selectedMenuIndex];
if (selectedItem.id === "exit") {
// Exit the application
process.exit(0);
} else {
// Navigate to selected screen
navigateTo(selectedItem.id);
}
} else if (input === "q" || input === "Q") {
// Quick exit with 'q'
process.exit(0);
}
});
};
// Use global keyboard handler with screen-specific handler
const context = {
appState,
navigateTo,
navigateBack,
updateUIState,
toggleHelp,
showHelp,
hideHelp,
};
useInput(createKeyboardHandler(screenKeyboardHandler, context));
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
Text,
{ bold: true, color: "cyan" },
"🛍️ Shopify Price Updater"
),
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
"Terminal User Interface"
)
),
// Welcome message
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
Text,
{ color: "green", fontSize: "small" },
"Shopify Price Updater TUI"
),
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
"Arrow keys: Navigate | Enter: Select"
)
),
// Menu items
ResponsiveContainer,
{ componentType: "menu" },
React.createElement(
Box,
{ flexDirection: "column" },
menuItems.map((item, index) => {
const isSelected = index === appState.uiState.selectedMenuIndex;
const isConfigured = appState.configuration.isValid;
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
ResponsiveText,
{ styleType: "title" },
"🛍️ Shopify Price Updater"
),
React.createElement(
ResponsiveText,
{ styleType: "subtitle" },
"Terminal User Interface"
)
),
return React.createElement(
// Welcome message
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
ResponsiveText,
{ styleType: "emphasis" },
"Shopify Price Updater TUI"
),
React.createElement(
ResponsiveText,
{ styleType: "normal" },
"Arrow keys: Navigate | Enter: Select"
)
),
// Menu items with scrollable container
React.createElement(ScrollableContainer, {
items: menuItems,
itemHeight: 3,
renderItem: (item, index) => {
const isSelected = index === appState.uiState.selectedMenuIndex;
const isConfigured = appState.configuration.isValid;
return React.createElement(
Box,
{
borderStyle: "single",
borderColor: isSelected ? "blue" : "gray",
paddingX: 1,
paddingY: 1,
flexDirection: "column",
},
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(
ResponsiveText,
{
styleType: isSelected ? "emphasis" : "normal",
bold: isSelected,
},
`${isSelected ? "▶" : " "} ${item.label}`
),
// Configuration status indicator
item.id === "operation" &&
!isConfigured &&
React.createElement(
Box,
{ marginLeft: 2 },
React.createElement(
ResponsiveText,
{ styleType: "error" },
"⚠️ Not Configured"
)
)
),
React.createElement(
ResponsiveText,
{
styleType: "subtitle",
truncate: true,
},
` ${item.description}`
)
);
},
}),
// Footer with instructions (hide on small screens)
React.createElement(
ResponsiveContainer,
{
componentType: "secondary-info",
hideOnSmall: true,
padding: false,
},
React.createElement(
Box,
{
key: item.id,
borderStyle: "single",
borderColor: isSelected ? "blue" : "gray",
paddingX: 1,
paddingY: 1,
marginBottom: 1,
flexDirection: "column",
marginTop: 2,
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 1,
},
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center" },
React.createElement(
Text,
{
bold: isSelected,
color: isSelected ? "blue" : "white",
},
`${isSelected ? "▶" : " "} ${item.label}`
),
// Configuration status indicator
item.id === "operation" &&
!isConfigured &&
React.createElement(
Box,
{ marginLeft: 2 },
React.createElement(Text, { color: "red" }, "⚠️ Not Configured")
)
ResponsiveText,
{ styleType: "subtitle" },
"Navigation:"
),
React.createElement(
Text,
{
color: isSelected ? "cyan" : "gray",
italic: true,
},
` ${item.description}`
ResponsiveText,
{ styleType: "normal" },
" ↑/↓ - Navigate menu"
),
React.createElement(
ResponsiveText,
{ styleType: "normal" },
" Enter/Space - Select item"
),
React.createElement(
ResponsiveText,
{ styleType: "normal" },
" h - Show help"
),
React.createElement(
ResponsiveText,
{ styleType: "normal" },
" q - Quick exit"
),
React.createElement(
ResponsiveText,
{ styleType: "normal" },
" Esc - Back (when available)"
)
);
})
),
// Footer with instructions
React.createElement(
Box,
{
flexDirection: "column",
marginTop: 3,
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 2,
},
React.createElement(Text, { color: "gray" }, "Navigation:"),
React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate menu"),
React.createElement(
Text,
{ color: "gray" },
" Enter/Space - Select item"
)
),
React.createElement(Text, { color: "gray" }, " q - Quick exit"),
React.createElement(
Text,
{ color: "gray" },
" Esc - Back (when available)"
)
),
// Configuration status
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between", marginTop: 2 },
// Configuration status
React.createElement(
Text,
{ color: appState.configuration.isValid ? "green" : "red" },
`Configuration: ${
appState.configuration.isValid ? "✓ Complete" : "⚠ Incomplete"
}`
),
React.createElement(
Text,
{ color: "gray" },
`Mode: ${appState.configuration.operationMode.toUpperCase()}`
Box,
{ flexDirection: "row", justifyContent: "space-between", marginTop: 1 },
React.createElement(
ResponsiveText,
{
styleType: appState.configuration.isValid ? "success" : "error",
truncate: true,
maxWidth: 30,
},
`Configuration: ${
appState.configuration.isValid ? "✓ Complete" : "⚠ Incomplete"
}`
),
React.createElement(
ResponsiveText,
{
styleType: "normal",
truncate: true,
maxWidth: 20,
},
`Mode: ${appState.configuration.operationMode.toUpperCase()}`
)
)
)
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,582 @@
const React = require("react");
const { Box, Text, useInput, useApp } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const LogReaderService = require("../../../services/logReader");
const VirtualScrollableContainer = require("../common/VirtualScrollableContainer.jsx");
/**
* Optimized Log Viewer Screen Component with virtual scrolling and performance enhancements
* Requirements: 4.1, 4.3, 4.4, 6.1, 6.4, 10.3
*/
// Memoized log entry component to prevent unnecessary re-renders
const LogEntry = React.memo(
({
log,
index,
isSelected,
isHighlighted,
getLogLevelColor,
formatDate,
truncateText,
}) => (
<Box
borderStyle={isSelected ? "single" : "none"}
borderColor={isSelected ? "blue" : "transparent"}
paddingX={1}
paddingY={0}
backgroundColor={isHighlighted ? "blue" : undefined}
>
<Box flexDirection="row" alignItems="center">
<Text color={getLogLevelColor(log.level)} bold={true} width={8}>
{log.level}
</Text>
<Text color={isHighlighted ? "white" : "gray"} width={12}>
{formatDate(log.timestamp)}
</Text>
<Text color={isHighlighted ? "white" : "white"} flexGrow={1}>
{truncateText(log.message, 50)}
</Text>
</Box>
</Box>
)
);
// Memoized log details component
const LogDetails = React.memo(({ log, getLogLevelColor }) => (
<Box
borderStyle="single"
borderColor="green"
paddingX={1}
paddingY={1}
marginTop={2}
maxHeight={8}
>
<Box flexDirection="column">
<Text bold={true} color="green">
Log Details:
</Text>
<Box flexDirection="row" alignItems="center">
<Text color="white" bold={true}>
Level:
</Text>
<Text color={getLogLevelColor(log.level)}>{log.level}</Text>
<Text color="gray" marginLeft={2}>
|
</Text>
<Text color="white" bold={true} marginLeft={1}>
Type:
</Text>
<Text color="cyan">{log.type || "unknown"}</Text>
</Box>
<Box flexDirection="row" alignItems="center">
<Text color="white" bold={true}>
Time:{" "}
</Text>
<Text color="gray">{log.timestamp.toLocaleString()}</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
<Text color="white" bold={true}>
Message:
</Text>
<Text color="white">{log.message}</Text>
</Box>
{log.details && (
<Box flexDirection="column" marginTop={1}>
<Text color="white" bold={true}>
Details:
</Text>
<Text color="gray" italic={true}>
{log.details.length > 200
? log.details.substring(0, 200) + "..."
: log.details}
</Text>
</Box>
)}
</Box>
</Box>
));
// Debounced state update hook
const useDebouncedState = (initialValue, delay = 100) => {
const [value, setValue] = React.useState(initialValue);
const [debouncedValue, setDebouncedValue] = React.useState(initialValue);
const timeoutRef = React.useRef(null);
const updateValue = React.useCallback(
(newValue) => {
setValue(newValue);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setDebouncedValue(newValue);
}, delay);
},
[delay]
);
return [value, debouncedValue, updateValue];
};
const OptimizedLogViewerScreen = React.memo(() => {
const { appState, navigateBack } = useAppState();
const { exit } = useApp();
// Initialize log reader service with memoization
const [logReader] = React.useState(() => new LogReaderService());
// Optimized state management with debouncing
const [logData, setLogData] = React.useState({
entries: [],
pagination: {
currentPage: 0,
pageSize: 50, // Increased for better virtual scrolling performance
totalEntries: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
startIndex: 1,
endIndex: 0,
},
filters: {
levelFilter: "ALL",
searchTerm: "",
},
});
const [selectedLog, setSelectedLog] = useDebouncedState(0, 50);
const [showDetails, setShowDetails] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [stats, setStats] = React.useState(null);
const [autoRefresh, setAutoRefresh] = React.useState(true);
const [lastRefresh, setLastRefresh] = React.useState(new Date());
const [refreshing, setRefreshing] = React.useState(false);
// Memoized filter options
const filterOptions = React.useMemo(
() => [
{ value: "ALL", label: "All Levels" },
{ value: "ERROR", label: "Errors" },
{ value: "WARNING", label: "Warnings" },
{ value: "INFO", label: "Info" },
{ value: "SUCCESS", label: "Success" },
],
[]
);
// Memoized utility functions
const getLogLevelColor = React.useCallback((level) => {
switch (level) {
case "ERROR":
return "red";
case "WARNING":
return "yellow";
case "INFO":
return "blue";
case "SUCCESS":
return "green";
default:
return "white";
}
}, []);
const formatTimestamp = React.useCallback((date) => {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}, []);
const formatDate = React.useCallback((date) => {
return date.toLocaleDateString([], {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}, []);
const truncateText = React.useCallback((text, maxLength = 60) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}, []);
// Optimized load log data function with memoization
const loadLogData = React.useCallback(
async (options = {}, isAutoRefresh = false) => {
try {
if (isAutoRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
const loadOptions = {
page: logData.pagination.currentPage,
pageSize: logData.pagination.pageSize,
levelFilter: logData.filters.levelFilter,
searchTerm: logData.filters.searchTerm,
...options,
};
const result = await logReader.getPaginatedEntries(loadOptions);
setLogData(result);
// Reset selection if current selection is out of bounds
if (selectedLog >= result.entries.length) {
setSelectedLog(Math.max(0, result.entries.length - 1));
}
setShowDetails(false);
setLastRefresh(new Date());
} catch (err) {
setError(`Failed to load logs: ${err.message}`);
} finally {
setLoading(false);
setRefreshing(false);
}
},
[
logReader,
logData.pagination.currentPage,
logData.pagination.pageSize,
logData.filters.levelFilter,
logData.filters.searchTerm,
selectedLog,
]
);
// Optimized load statistics function
const loadStats = React.useCallback(async () => {
try {
const statistics = await logReader.getLogStatistics();
setStats(statistics);
} catch (err) {
console.warn("Failed to load log statistics:", err.message);
}
}, [logReader]);
// Initial load with optimization
React.useEffect(() => {
const loadInitialData = async () => {
await Promise.all([loadLogData(), loadStats()]);
};
loadInitialData();
}, []);
// Optimized auto-refresh with file watching
React.useEffect(() => {
if (!autoRefresh) return;
const cleanup = logReader.watchFile(() => {
loadLogData({}, true);
loadStats();
});
return cleanup;
}, [logReader, loadLogData, loadStats, autoRefresh]);
// Optimized periodic refresh
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
if (!loading && !refreshing) {
logReader.clearCache();
loadLogData({}, true);
loadStats();
}
}, 30000);
return () => clearInterval(interval);
}, [logReader, loadLogData, loadStats, autoRefresh, loading, refreshing]);
// Optimized keyboard input handler with debouncing
const handleKeyboardInput = React.useCallback(
(input, key) => {
if (loading) return;
if (key.escape) {
navigateBack();
} else if (key.upArrow) {
if (selectedLog > 0) {
setSelectedLog(selectedLog - 1);
setShowDetails(false);
}
} else if (key.downArrow) {
if (selectedLog < logData.entries.length - 1) {
setSelectedLog(selectedLog + 1);
setShowDetails(false);
}
} else if (key.leftArrow) {
if (logData.pagination.hasPreviousPage) {
loadLogData({ page: logData.pagination.currentPage - 1 });
}
} else if (key.rightArrow) {
if (logData.pagination.hasNextPage) {
loadLogData({ page: logData.pagination.currentPage + 1 });
}
} else if (key.return || key.enter) {
if (selectedLog < logData.entries.length) {
setShowDetails(!showDetails);
}
} else if (key.r || input === "r") {
logReader.clearCache();
loadLogData();
loadStats();
} else if (input >= "1" && input <= "5") {
const filterMap = {
1: "ALL",
2: "ERROR",
3: "WARNING",
4: "INFO",
5: "SUCCESS",
};
const newFilter = filterMap[input];
if (newFilter !== logData.filters.levelFilter) {
loadLogData({ levelFilter: newFilter, page: 0 });
}
} else if (input === "s") {
const searchTerms = ["", "error", "update", "rollback", "product"];
const currentIndex = searchTerms.indexOf(logData.filters.searchTerm);
const nextIndex = (currentIndex + 1) % searchTerms.length;
const newSearchTerm = searchTerms[nextIndex];
loadLogData({ searchTerm: newSearchTerm, page: 0 });
} else if (input === "c") {
loadLogData({ levelFilter: "ALL", searchTerm: "", page: 0 });
} else if (input === "a") {
setAutoRefresh(!autoRefresh);
} else if (key.pageUp) {
if (logData.pagination.currentPage > 0) {
loadLogData({ page: 0 });
}
} else if (key.pageDown) {
if (
logData.pagination.currentPage <
logData.pagination.totalPages - 1
) {
loadLogData({ page: logData.pagination.totalPages - 1 });
}
}
},
[
loading,
navigateBack,
selectedLog,
logData,
showDetails,
loadLogData,
loadStats,
logReader,
autoRefresh,
]
);
useInput(handleKeyboardInput);
// Memoized render function for log entries
const renderLogEntry = React.useCallback(
(log, index) => {
const isSelected = selectedLog === index;
const isHighlighted = isSelected && !showDetails;
return (
<LogEntry
log={log}
index={index}
isSelected={isSelected}
isHighlighted={isHighlighted}
getLogLevelColor={getLogLevelColor}
formatDate={formatDate}
truncateText={truncateText}
/>
);
},
[selectedLog, showDetails, getLogLevelColor, formatDate, truncateText]
);
// Show loading state
if (loading && logData.entries.length === 0) {
return (
<Box
flexDirection="column"
padding={2}
justifyContent="center"
alignItems="center"
>
<Text color="blue">Loading logs...</Text>
<Text color="gray">Please wait while we read the log files</Text>
</Box>
);
}
// Show error state
if (error) {
return (
<Box
flexDirection="column"
padding={2}
justifyContent="center"
alignItems="center"
>
<Text color="red" bold={true}>
Error Loading Logs
</Text>
<Text color="gray">{error}</Text>
<Text color="gray" marginTop={1}>
Press 'r' to retry or Esc to go back
</Text>
</Box>
);
}
return (
<Box flexDirection="column" padding={2} flexGrow={1}>
{/* Header with statistics */}
<Box flexDirection="column" marginBottom={2}>
<Text bold={true} color="cyan">
📋 Log Viewer
</Text>
<Box flexDirection="row" justifyContent="space-between">
<Text color="gray">View application logs and operation history</Text>
{stats && (
<Text color="gray">
{stats.totalEntries} entries | {stats.operations.total} operations
</Text>
)}
</Box>
</Box>
{/* Filter and pagination controls */}
<Box
borderStyle="single"
borderColor="blue"
paddingX={1}
paddingY={1}
marginBottom={2}
>
<Box flexDirection="column">
<Box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<Box flexDirection="row" alignItems="center">
<Text color="white" bold={true}>
Filter:{" "}
</Text>
<Text color="blue">{logData.filters.levelFilter}</Text>
<Text color="gray" marginLeft={2}>
(1-5 to change)
</Text>
</Box>
<Box flexDirection="row" alignItems="center">
<Text color="white" bold={true}>
Page:{" "}
</Text>
<Text color="blue">
{logData.pagination.currentPage + 1}/
{logData.pagination.totalPages}
</Text>
<Text color="gray" marginLeft={2}>
(/ to navigate)
</Text>
</Box>
</Box>
<Text color="gray">
Showing {logData.pagination.startIndex}-
{logData.pagination.endIndex} of {logData.pagination.totalEntries}{" "}
entries
</Text>
</Box>
</Box>
{/* Virtual scrolled log list */}
<VirtualScrollableContainer
items={logData.entries}
renderItem={renderLogEntry}
itemHeight={1}
showScrollIndicators={true}
overscan={10}
borderStyle="single"
borderColor="gray"
flexGrow={1}
minHeight={10}
/>
{/* Log details */}
{showDetails &&
selectedLog < logData.entries.length &&
logData.entries[selectedLog] && (
<LogDetails
log={logData.entries[selectedLog]}
getLogLevelColor={getLogLevelColor}
/>
)}
{/* Instructions */}
<Box
flexDirection="column"
marginTop={2}
borderTopStyle="single"
borderColor="gray"
paddingTop={1}
>
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="column">
<Text color="gray">
Navigation: / entries | / pages | Enter details
</Text>
<Text color="gray">
Filters: 1=All 2=Error 3=Warning 4=Info 5=Success
</Text>
</Box>
<Box flexDirection="column">
<Text color="gray">
Search: S cycle terms | C clear | A auto-refresh
</Text>
<Text color="gray">
Actions: R refresh | PgUp/PgDn jump | Esc back
</Text>
</Box>
</Box>
</Box>
{/* Status bar */}
<Box
borderStyle="single"
borderColor="gray"
paddingX={1}
paddingY={0}
marginTop={1}
>
<Box flexDirection="row" justifyContent="space-between">
<Text color="gray">
Entry {selectedLog + 1}/{logData.entries.length} | Details:{" "}
{showDetails ? "ON" : "OFF"}
</Text>
<Text color={loading ? "yellow" : refreshing ? "cyan" : "gray"}>
{loading
? "Loading..."
: refreshing
? "Refreshing..."
: `Filter: ${logData.filters.levelFilter}${
logData.filters.searchTerm
? ` | Search: "${logData.filters.searchTerm}"`
: ""
} | Auto: ${
autoRefresh ? "ON" : "OFF"
} | ${lastRefresh.toLocaleTimeString()}`}
</Text>
</Box>
</Box>
</Box>
);
});
module.exports = OptimizedLogViewerScreen;

File diff suppressed because it is too large Load Diff

View File

@@ -2,69 +2,29 @@ const React = require("react");
const { Box, Text, useInput, useApp } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const SelectInput = require("ink-select-input").default;
const TagAnalysisService = require("../../../services/tagAnalysis");
/**
* Tag Analysis Screen Component
* Analyzes product tags and provides insights for price update operations
* Requirements: 7.7, 7.8
* Requirements: 7.1, 7.2, 7.3
*/
const TagAnalysisScreen = () => {
const { appState, navigateBack } = useAppState();
const { exit } = useApp();
// Mock tag analysis data
const mockTagAnalysis = {
totalProducts: 150,
tagCounts: [
{ tag: "sale", count: 45, percentage: 30.0 },
{ tag: "new", count: 32, percentage: 21.3 },
{ tag: "featured", count: 28, percentage: 18.7 },
{ tag: "clearance", count: 22, percentage: 14.7 },
{ tag: "limited", count: 15, percentage: 10.0 },
{ tag: "seasonal", count: 8, percentage: 5.3 },
],
priceRanges: {
sale: { min: 9.99, max: 199.99, average: 59.5 },
new: { min: 19.99, max: 299.99, average: 89.75 },
featured: { min: 29.99, max: 399.99, average: 129.5 },
clearance: { min: 4.99, max: 149.99, average: 39.25 },
limited: { min: 49.99, max: 499.99, average: 199.5 },
seasonal: { min: 14.99, max: 249.99, average: 74.25 },
},
recommendations: [
{
type: "high_impact",
title: "High-Impact Tags",
description:
"Tags with many products that would benefit most from price updates",
tags: ["sale", "clearance"],
reason:
"These tags have the highest product counts and are most likely to need price adjustments",
},
{
type: "high_value",
title: "High-Value Tags",
description: "Tags with products having higher average prices",
tags: ["limited", "featured"],
reason:
"These tags contain premium products where price adjustments have the most financial impact",
},
{
type: "caution",
title: "Use Caution",
description: "Tags that may require special handling",
tags: ["new", "seasonal"],
reason:
"These tags may have products with special pricing strategies that shouldn't be automatically adjusted",
},
],
};
// State for tag analysis
const [analysisData, setAnalysisData] = React.useState(mockTagAnalysis);
const [analysisData, setAnalysisData] = React.useState(null);
const [selectedTag, setSelectedTag] = React.useState(null);
const [showDetails, setShowDetails] = React.useState(false);
const [analysisType, setAnalysisType] = React.useState("overview");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const [sampleProducts, setSampleProducts] = React.useState([]);
const [loadingSamples, setLoadingSamples] = React.useState(false);
// Initialize tag analysis service
const tagAnalysisService = React.useMemo(() => new TagAnalysisService(), []);
// Analysis type options
const analysisOptions = [
@@ -74,33 +34,84 @@ const TagAnalysisScreen = () => {
{ value: "recommendations", label: "Recommendations" },
];
// Load tag analysis data on component mount
React.useEffect(() => {
loadTagAnalysis();
}, []);
// Load tag analysis data
const loadTagAnalysis = async () => {
setLoading(true);
setError(null);
try {
const analysis = await tagAnalysisService.getTagAnalysis();
setAnalysisData(analysis);
setSelectedTag(null);
setShowDetails(false);
setSampleProducts([]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Load sample products for selected tag
const loadSampleProducts = async (tag) => {
if (!tag) return;
setLoadingSamples(true);
try {
const samples = await tagAnalysisService.getSampleProductsForTag(tag, 3);
setSampleProducts(samples);
} catch (err) {
setSampleProducts([]);
} finally {
setLoadingSamples(false);
}
};
// Handle keyboard input
useInput((input, key) => {
if (loading) return; // Ignore input while loading
if (key.escape) {
// Go back to main menu
navigateBack();
} else if (key.upArrow) {
} else if (key.upArrow && analysisData) {
// Navigate up in list
if (selectedTag > 0) {
if (selectedTag === null) {
setSelectedTag(analysisData.tagCounts.length - 1);
} else if (selectedTag > 0) {
setSelectedTag(selectedTag - 1);
setShowDetails(false);
}
} else if (key.downArrow) {
// Navigate down in list
if (selectedTag < analysisData.tagCounts.length - 1) {
setSelectedTag(selectedTag + 1);
setShowDetails(false);
}
} else if (key.return || key.enter) {
// Toggle tag details
if (selectedTag !== null) {
setShowDetails(!showDetails);
}
} else if (key.r) {
// Refresh analysis
setAnalysisData(mockTagAnalysis);
setSelectedTag(null);
setShowDetails(false);
setSampleProducts([]);
} else if (key.downArrow && analysisData) {
// Navigate down in list
if (selectedTag === null) {
setSelectedTag(0);
} else if (selectedTag < analysisData.tagCounts.length - 1) {
setSelectedTag(selectedTag + 1);
}
setShowDetails(false);
setSampleProducts([]);
} else if ((key.return || key.enter) && analysisData) {
// Toggle tag details and load samples
if (selectedTag !== null) {
const newShowDetails = !showDetails;
setShowDetails(newShowDetails);
if (newShowDetails) {
const tagName = analysisData.tagCounts[selectedTag].tag;
loadSampleProducts(tagName);
} else {
setSampleProducts([]);
}
}
} else if (input === "r" || input === "R") {
// Refresh analysis
loadTagAnalysis();
}
});
@@ -117,9 +128,30 @@ const TagAnalysisScreen = () => {
return "green";
};
// Render overview section
const renderOverview = () =>
// Render loading state
const renderLoading = () =>
React.createElement(
Box,
{ flexDirection: "column", alignItems: "center", justifyContent: "center", height: 10 },
React.createElement(Text, { color: "blue" }, "🔄 Loading tag analysis..."),
React.createElement(Text, { color: "gray" }, "This may take a moment...")
);
// Render error state
const renderError = () =>
React.createElement(
Box,
{ flexDirection: "column", alignItems: "center", justifyContent: "center", height: 10 },
React.createElement(Text, { color: "red", bold: true }, "❌ Error loading tag analysis"),
React.createElement(Text, { color: "white" }, error),
React.createElement(Text, { color: "gray", marginTop: 1 }, "Press 'R' to retry")
);
// Render overview section
const renderOverview = () => {
if (!analysisData) return null;
return React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
@@ -155,6 +187,11 @@ const TagAnalysisScreen = () => {
`Most Common Tag: ${analysisData.tagCounts[0]?.tag || "N/A"} (${
analysisData.tagCounts[0]?.count || 0
} products)`
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
`Last Updated: ${new Date(analysisData.analyzedAt).toLocaleString()}`
)
)
),
@@ -226,8 +263,10 @@ const TagAnalysisScreen = () => {
);
// Render pricing analysis
const renderPricingAnalysis = () =>
React.createElement(
const renderPricingAnalysis = () => {
if (!analysisData) return null;
return React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
@@ -269,7 +308,7 @@ const TagAnalysisScreen = () => {
color: isSelected ? "white" : "gray",
fontSize: "small",
},
`${tagInfo.count} products`
`${tagInfo.count} products (${analysisData.priceRanges[tagInfo.tag]?.count || 0} variants)`
),
React.createElement(
Box,
@@ -280,14 +319,14 @@ const TagAnalysisScreen = () => {
color: isSelected ? "white" : "cyan",
bold: true,
},
"Range: "
"Range: $"
),
React.createElement(
Text,
{
color: isSelected ? "white" : "white",
},
`$${priceRange.min} - $${priceRange.max}`
`${priceRange.min.toFixed(2)} - $${priceRange.max.toFixed(2)}`
)
),
React.createElement(
@@ -299,7 +338,7 @@ const TagAnalysisScreen = () => {
color: isSelected ? "white" : "cyan",
bold: true,
},
"Avg: "
"Avg: $"
),
React.createElement(
Text,
@@ -313,10 +352,13 @@ const TagAnalysisScreen = () => {
);
})
);
};
// Render recommendations
const renderRecommendations = () =>
React.createElement(
const renderRecommendations = () => {
if (!analysisData) return null;
return React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
@@ -331,8 +373,14 @@ const TagAnalysisScreen = () => {
return "green";
case "high_value":
return "blue";
case "optimal":
return "magenta";
case "consistency":
return "red";
case "caution":
return "yellow";
case "low_count":
return "gray";
default:
return "white";
}
@@ -344,13 +392,33 @@ const TagAnalysisScreen = () => {
return "⭐";
case "high_value":
return "💎";
case "optimal":
return "🎯";
case "consistency":
return "⚖️";
case "caution":
return "⚠️";
case "low_count":
return "🔍";
default:
return "";
}
};
const getPriorityBadge = (priority) => {
const colors = {
high: "red",
medium: "yellow",
low: "blue",
info: "gray"
};
return React.createElement(
Text,
{ color: colors[priority] || "white", bold: true },
`[${priority.toUpperCase()}]`
);
};
return React.createElement(
Box,
{
@@ -364,6 +432,7 @@ const TagAnalysisScreen = () => {
React.createElement(
Box,
{ flexDirection: "column" },
// Header with icon, title, and priority
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center", marginBottom: 1 },
@@ -373,31 +442,96 @@ const TagAnalysisScreen = () => {
color: getTypeColor(rec.type),
bold: true,
},
`${getTypeIcon(rec.type)} ${rec.title}`
`${getTypeIcon(rec.type)} ${rec.title} `
),
getPriorityBadge(rec.priority),
rec.actionable && React.createElement(
Text,
{ color: "green", marginLeft: 1 },
"[ACTIONABLE]"
)
),
// Description
React.createElement(
Text,
{ color: "white", marginBottom: 1 },
rec.description
),
// Tags
React.createElement(
Text,
{ color: "gray", italic: true, marginBottom: 1 },
rec.reason
Box,
{ flexDirection: "row", marginBottom: 1 },
React.createElement(
Text,
{ color: "cyan", bold: true },
"Tags: "
),
React.createElement(
Text,
{ color: "white" },
rec.tags.join(", ")
)
),
// Estimated impact
rec.estimatedImpact && React.createElement(
Box,
{ flexDirection: "row", marginBottom: 1 },
React.createElement(
Text,
{ color: "green", bold: true },
"Impact: "
),
React.createElement(
Text,
{ color: "white" },
rec.estimatedImpact
)
),
// Detailed information for some recommendation types
rec.details && rec.details.length > 0 && React.createElement(
Box,
{ flexDirection: "column", marginTop: 1, marginLeft: 2 },
React.createElement(
Text,
{ color: "gray", bold: true },
"Details:"
),
rec.details.slice(0, 3).map((detail, detailIdx) =>
React.createElement(
Text,
{ key: detailIdx, color: "gray" },
rec.type === 'high_impact' ?
`${detail.tag}: ${detail.count} products (${detail.percentage.toFixed(1)}%)` :
rec.type === 'high_value' ?
`${detail.tag}: $${detail.averagePrice.toFixed(2)} avg, ${detail.count} products` :
rec.type === 'optimal' ?
`${detail.tag}: Score ${detail.score.toFixed(1)}, ${detail.count} products` :
rec.type === 'consistency' ?
`${detail.tag}: ${detail.issue} (${detail.variationRatio}x variation)` :
rec.type === 'caution' ?
`${detail.tag}: ${detail.count} products (${detail.riskLevel} risk)` :
`${detail.tag}: ${detail.count} products`
)
)
),
// Reason
React.createElement(
Text,
{ color: "cyan", bold: true },
"Tags: " + rec.tags.join(", ")
{ color: "gray", italic: true, marginTop: 1 },
rec.reason
)
)
);
})
);
};
// Render current analysis view
const renderCurrentView = () => {
if (loading) return renderLoading();
if (error) return renderError();
if (!analysisData) return renderError();
switch (analysisType) {
case "overview":
return renderOverview();
@@ -599,7 +733,39 @@ const TagAnalysisScreen = () => {
].average.toFixed(2)}`
)
)
),
// Sample products section
sampleProducts.length > 0 && React.createElement(
Box,
{ flexDirection: "column", marginTop: 1 },
React.createElement(
Text,
{ color: "white", bold: true },
"Sample Products:"
),
sampleProducts.map((product, idx) =>
React.createElement(
Box,
{ key: idx, flexDirection: "column", marginLeft: 2, marginTop: 0.5 },
React.createElement(
Text,
{ color: "cyan" },
`${product.title}`
),
product.variants.length > 0 && React.createElement(
Text,
{ color: "gray", marginLeft: 2 },
`Price: $${product.variants[0].price}${product.variants[0].compareAtPrice ? ` (was $${product.variants[0].compareAtPrice})` : ''}`
)
)
)
),
// Loading indicator for samples
loadingSamples && React.createElement(
Box,
{ flexDirection: "row", alignItems: "center", marginTop: 1 },
React.createElement(Text, { color: "blue" }, "🔄 Loading sample products...")
)
)
),
@@ -615,7 +781,7 @@ const TagAnalysisScreen = () => {
},
React.createElement(Text, { color: "gray" }, "Controls:"),
React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate items"),
React.createElement(Text, { color: "gray" }, " Enter - View details"),
React.createElement(Text, { color: "gray" }, " Enter - View details & sample products"),
React.createElement(Text, { color: "gray" }, " R - Refresh analysis"),
React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
),

View File

@@ -0,0 +1,525 @@
const React = require("react");
const { Box, Text, useInput } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const { useServices } = require("../../hooks/useServices.js");
const { LoadingIndicator } = require("../common/LoadingIndicator.jsx");
const ErrorDisplay = require("../common/ErrorDisplay.jsx");
const { Pagination } = require("../common/Pagination.jsx");
/**
* View Logs Screen Component
* Log file list view with keyboard navigation and metadata display
* Requirements: 2.1, 2.8, 4.1, 4.2
*/
const ViewLogsScreen = () => {
const { navigateBack } = useAppState();
const { getLogFiles, readLogFile } = useServices();
// State management for log files, selected file, and content
const [logFiles, setLogFiles] = React.useState([]);
const [selectedFileIndex, setSelectedFileIndex] = React.useState(0);
const [selectedFile, setSelectedFile] = React.useState(null);
const [logContent, setLogContent] = React.useState("");
const [parsedLogs, setParsedLogs] = React.useState([]);
const [currentPage, setCurrentPage] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [loadingContent, setLoadingContent] = React.useState(false);
const [contentError, setContentError] = React.useState(null);
const [viewMode, setViewMode] = React.useState("parsed"); // "parsed" or "raw"
// Load log files on component mount
React.useEffect(() => {
const loadLogFiles = async () => {
try {
setLoading(true);
setError(null);
const files = await getLogFiles();
setLogFiles(files);
// Auto-select the main Progress.md file if it exists
const mainLogIndex = files.findIndex((file) => file.isMainLog);
if (mainLogIndex !== -1) {
setSelectedFileIndex(mainLogIndex);
}
} catch (err) {
setError(`Failed to discover log files: ${err.message}`);
} finally {
setLoading(false);
}
};
loadLogFiles();
}, [getLogFiles]);
// Load content for selected file
const loadFileContent = React.useCallback(
async (file) => {
if (!file) return;
try {
setLoadingContent(true);
setContentError(null);
setCurrentPage(0); // Reset pagination
const content = await readLogFile(file.filename);
setLogContent(content);
// Parse the content into structured log entries
const { parseLogContent } = useServices();
const parsed = parseLogContent(content);
setParsedLogs(parsed);
setSelectedFile(file);
} catch (err) {
setContentError(`Failed to read log file: ${err.message}`);
setLogContent("");
setParsedLogs([]);
} finally {
setLoadingContent(false);
}
},
[readLogFile, useServices]
);
// Helper function to format file size
const formatFileSize = (bytes) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
// Helper function to format date
const formatDate = (date) => {
try {
return date.toLocaleString();
} catch (error) {
return "Invalid date";
}
};
// Helper function to get relative time
const getRelativeTime = (date) => {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
// Keyboard navigation for log file selection
useInput((input, key) => {
if (loading) return; // Ignore input while loading
if (key.escape) {
navigateBack();
} else if (key.upArrow) {
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedFileIndex((prev) => Math.min(logFiles.length - 1, prev + 1));
} else if (key.return) {
// Load content for selected file
if (logFiles.length > 0 && selectedFileIndex < logFiles.length) {
loadFileContent(logFiles[selectedFileIndex]);
}
} else if (input === "r") {
// Refresh log files list
setLoading(true);
setError(null);
getLogFiles()
.then(setLogFiles)
.catch((err) =>
setError(`Failed to discover log files: ${err.message}`)
)
.finally(() => setLoading(false));
}
});
// Show loading state
if (loading) {
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
React.createElement(Text, { bold: true, color: "cyan" }, "📋 View Logs"),
React.createElement(LoadingIndicator, {
message: "Discovering log files...",
})
);
}
// Show error state
if (error) {
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
React.createElement(Text, { bold: true, color: "cyan" }, "📋 View Logs"),
React.createElement(ErrorDisplay, {
error: { message: error },
onRetry: () => {
setError(null);
setLoading(true);
getLogFiles()
.then(setLogFiles)
.catch((err) =>
setError(`Failed to discover log files: ${err.message}`)
)
.finally(() => setLoading(false));
},
})
);
}
// Show file content view if a file is selected
if (selectedFile) {
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 2 },
React.createElement(
Text,
{ bold: true, color: "cyan" },
"📄 Log File Content"
),
React.createElement(
Text,
{ color: "gray" },
`Viewing: ${selectedFile.filename}`
)
),
// File metadata
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "blue",
paddingX: 2,
paddingY: 1,
marginBottom: 2,
},
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ color: "white", bold: true },
selectedFile.filename
),
React.createElement(
Text,
{ color: selectedFile.isMainLog ? "green" : "gray" },
selectedFile.isMainLog ? "MAIN LOG" : "ARCHIVE"
)
),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 1 },
React.createElement(
Box,
{ flexDirection: "column", width: "50%" },
React.createElement(
Text,
{ color: "cyan" },
`Size: ${formatFileSize(selectedFile.size)}`
),
React.createElement(
Text,
{ color: "cyan" },
`Operations: ${selectedFile.operationCount}`
)
),
React.createElement(
Box,
{ flexDirection: "column", width: "50%" },
React.createElement(
Text,
{ color: "cyan" },
`Created: ${getRelativeTime(selectedFile.createdAt)}`
),
React.createElement(
Text,
{ color: "cyan" },
`Modified: ${getRelativeTime(selectedFile.modifiedAt)}`
)
)
)
)
),
// Content display
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "gray",
paddingX: 1,
paddingY: 1,
flexGrow: 1,
marginBottom: 2,
},
loadingContent
? React.createElement(LoadingIndicator, {
message: "Loading file content...",
})
: contentError
? React.createElement(ErrorDisplay, {
error: { message: contentError },
onRetry: () => loadFileContent(selectedFile),
})
: logContent
? React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Text,
{ color: "white", wrap: "wrap" },
logContent.substring(0, 2000) // Show first 2000 characters
),
logContent.length > 2000 &&
React.createElement(
Text,
{ color: "yellow", marginTop: 1 },
`... (${logContent.length - 2000} more characters)`
)
)
: React.createElement(
Text,
{ color: "gray" },
"File is empty or could not be read"
)
),
// Instructions
React.createElement(
Box,
{
flexDirection: "column",
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 1,
},
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
" Esc - Back to file list R - Refresh content"
)
)
);
}
return React.createElement(
Box,
{ flexDirection: "column", padding: 2, flexGrow: 1 },
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 2 },
React.createElement(Text, { bold: true, color: "cyan" }, "📋 View Logs"),
React.createElement(
Text,
{ color: "gray" },
"Select a log file to view its contents and operation history"
)
),
// Log file list view
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "blue",
paddingX: 1,
paddingY: 1,
marginBottom: 2,
flexGrow: 1,
},
React.createElement(
Box,
{ flexDirection: "column" },
React.createElement(
Text,
{ bold: true, color: "blue", marginBottom: 1 },
`📁 Available Log Files (${logFiles.length})`
),
logFiles.length === 0
? React.createElement(
Box,
{ justifyContent: "center", alignItems: "center", padding: 2 },
React.createElement(
Text,
{ color: "gray" },
"No log files found"
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Log files are created when operations are performed"
),
React.createElement(
Text,
{ color: "gray", marginTop: 1 },
"Run some price update operations to generate logs"
)
)
: React.createElement(
Box,
{ flexDirection: "column" },
logFiles.map((file, index) => {
const isSelected = selectedFileIndex === index;
return React.createElement(
Box,
{
key: file.filename,
flexDirection: "column",
paddingY: 1,
paddingX: 1,
backgroundColor: isSelected ? "blue" : undefined,
borderStyle: isSelected ? "single" : "none",
borderColor: isSelected ? "cyan" : "gray",
marginBottom: 1,
},
// File name and status
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{
color: isSelected ? "white" : "white",
bold: isSelected,
},
`${isSelected ? "► " : " "}${file.filename}`
),
React.createElement(
Text,
{ color: file.isMainLog ? "green" : "gray", bold: true },
file.isMainLog ? "MAIN" : "ARCHIVE"
)
),
// File metadata
React.createElement(
Box,
{
flexDirection: "row",
justifyContent: "space-between",
marginTop: 1,
},
React.createElement(
Box,
{ flexDirection: "row" },
React.createElement(
Text,
{
color: isSelected ? "gray" : "gray",
marginLeft: isSelected ? 2 : 2,
},
`${formatFileSize(file.size)}`
),
React.createElement(
Text,
{
color: isSelected ? "gray" : "gray",
marginLeft: 2,
},
`${file.operationCount} ops`
)
),
React.createElement(
Text,
{
color: isSelected ? "gray" : "gray",
},
getRelativeTime(file.modifiedAt)
)
),
// Creation date
React.createElement(
Text,
{
color: isSelected ? "gray" : "gray",
marginLeft: isSelected ? 2 : 2,
marginTop: 1,
},
`Created: ${formatDate(file.createdAt)}`
)
);
})
)
)
),
// Instructions
React.createElement(
Box,
{
flexDirection: "column",
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 1,
},
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
React.createElement(
Box,
{ flexDirection: "row", marginTop: 1 },
React.createElement(
Box,
{ flexDirection: "column", width: "50%" },
React.createElement(Text, { color: "gray" }, " ↑/↓ - Select file"),
React.createElement(Text, { color: "gray" }, " Enter - View content")
),
React.createElement(
Box,
{ flexDirection: "column", width: "50%" },
React.createElement(Text, { color: "gray" }, " R - Refresh list"),
React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
)
)
),
// Status bar
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "gray",
paddingX: 1,
paddingY: 0,
marginTop: 1,
},
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Text,
{ color: "gray" },
logFiles.length > 0
? `File ${selectedFileIndex + 1}/${logFiles.length}`
: "No files available"
),
React.createElement(
Text,
{ color: "gray" },
"Select a file to view detailed log content"
)
)
)
);
};
module.exports = ViewLogsScreen;

View File

@@ -0,0 +1,259 @@
/**
* Accessibility hook for managing accessibility features
* Provides screen reader support, high contrast mode, and focus management
* Requirements: 8.1, 8.2, 8.3
*/
const { useState, useEffect, useCallback } = require("react");
const {
AccessibilityConfig,
ScreenReaderUtils,
getAccessibleColors,
FocusManager,
KeyboardNavigation,
AccessibilityAnnouncer,
} = require("../utils/accessibility.js");
/**
* Custom hook for accessibility features
*/
const useAccessibility = () => {
// Accessibility state
const [accessibilityState, setAccessibilityState] = useState({
isScreenReaderActive: AccessibilityConfig.isScreenReaderActive(),
isHighContrastMode: AccessibilityConfig.isHighContrastMode(),
shouldShowEnhancedFocus: AccessibilityConfig.shouldShowEnhancedFocus(),
prefersReducedMotion: AccessibilityConfig.prefersReducedMotion(),
colors: getAccessibleColors(),
});
// Update accessibility state when environment changes
useEffect(() => {
const updateAccessibilityState = () => {
setAccessibilityState({
isScreenReaderActive: AccessibilityConfig.isScreenReaderActive(),
isHighContrastMode: AccessibilityConfig.isHighContrastMode(),
shouldShowEnhancedFocus: AccessibilityConfig.shouldShowEnhancedFocus(),
prefersReducedMotion: AccessibilityConfig.prefersReducedMotion(),
colors: getAccessibleColors(),
});
};
// Listen for environment variable changes (in development)
if (process.env.NODE_ENV === "development") {
const interval = setInterval(updateAccessibilityState, 1000);
return () => clearInterval(interval);
}
}, []);
// Screen reader utilities
const screenReader = {
/**
* Announce message to screen reader
*/
announce: useCallback((message, priority = "polite") => {
AccessibilityAnnouncer.announce(message, priority);
}, []),
/**
* Describe menu item for screen reader
*/
describeMenuItem: useCallback((item, index, total, isSelected) => {
return ScreenReaderUtils.describeMenuItem(item, index, total, isSelected);
}, []),
/**
* Describe progress for screen reader
*/
describeProgress: useCallback((current, total, label) => {
return ScreenReaderUtils.describeProgress(current, total, label);
}, []),
/**
* Describe status for screen reader
*/
describeStatus: useCallback((status, details) => {
return ScreenReaderUtils.describeStatus(status, details);
}, []),
/**
* Describe form field for screen reader
*/
describeFormField: useCallback((label, value, isValid, errorMessage) => {
return ScreenReaderUtils.describeFormField(
label,
value,
isValid,
errorMessage
);
}, []),
};
// Focus management utilities
const focus = {
/**
* Get focus indicator props for component
*/
getFocusProps: useCallback((isFocused, componentType = "default") => {
return FocusManager.getFocusProps(isFocused, componentType);
}, []),
/**
* Get selection indicator props
*/
getSelectionProps: useCallback((isSelected) => {
return FocusManager.getSelectionProps(isSelected);
}, []),
};
// Keyboard navigation utilities
const keyboard = {
/**
* Check if key matches navigation action
*/
isNavigationKey: useCallback((key, action) => {
return KeyboardNavigation.isNavigationKey(key, action);
}, []),
/**
* Get keyboard shortcut descriptions
*/
describeShortcuts: useCallback((availableActions) => {
return KeyboardNavigation.describeShortcuts(availableActions);
}, []),
};
// Color utilities
const colors = {
/**
* Get accessible color for specific purpose
*/
get: useCallback(
(colorType) => {
return (
accessibilityState.colors[colorType] ||
accessibilityState.colors.foreground
);
},
[accessibilityState.colors]
),
/**
* Get all accessible colors
*/
getAll: useCallback(() => {
return accessibilityState.colors;
}, [accessibilityState.colors]),
};
// Accessibility helpers
const helpers = {
/**
* Check if accessibility feature is enabled
*/
isEnabled: useCallback(
(feature) => {
switch (feature) {
case "screenReader":
return accessibilityState.isScreenReaderActive;
case "highContrast":
return accessibilityState.isHighContrastMode;
case "enhancedFocus":
return accessibilityState.shouldShowEnhancedFocus;
case "reducedMotion":
return accessibilityState.prefersReducedMotion;
default:
return false;
}
},
[accessibilityState]
),
/**
* Get accessibility-aware component props
*/
getComponentProps: useCallback(
(componentType, state = {}) => {
const props = {};
// Add focus props if component is focusable
if (state.isFocused !== undefined) {
Object.assign(
props,
focus.getFocusProps(state.isFocused, componentType)
);
}
// Add selection props if component is selectable
if (state.isSelected !== undefined) {
Object.assign(props, focus.getSelectionProps(state.isSelected));
}
// Add high contrast colors if enabled
if (accessibilityState.isHighContrastMode) {
if (!props.color && !state.isSelected) {
props.color = accessibilityState.colors.foreground;
}
if (!props.backgroundColor && componentType === "input") {
props.backgroundColor = accessibilityState.colors.background;
}
}
return props;
},
[accessibilityState, focus]
),
/**
* Generate ARIA-like attributes for screen readers
*/
getAriaProps: useCallback(
(element) => {
if (!accessibilityState.isScreenReaderActive) {
return {};
}
const ariaProps = {};
// Add role information
if (element.role) {
ariaProps["data-role"] = element.role;
}
// Add label information
if (element.label) {
ariaProps["data-label"] = element.label;
}
// Add description
if (element.description) {
ariaProps["data-description"] = element.description;
}
// Add state information
if (element.state) {
Object.keys(element.state).forEach((key) => {
ariaProps[`data-${key}`] = element.state[key];
});
}
return ariaProps;
},
[accessibilityState.isScreenReaderActive]
),
};
return {
// State
state: accessibilityState,
// Utilities
screenReader,
focus,
keyboard,
colors,
helpers,
};
};
module.exports = useAccessibility;

View File

@@ -0,0 +1,32 @@
const { useContext } = require("react");
const { AppContext } = require("../providers/AppProvider.jsx");
/**
* Custom hook for accessing application state
* Requirements: 5.1, 5.3, 7.1
*/
const useAppState = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error("useAppState must be used within an AppProvider");
}
return {
// State access
appState: context.appState,
currentScreen: context.appState.currentScreen,
navigationHistory: context.appState.navigationHistory,
configuration: context.appState.configuration,
operationState: context.appState.operationState,
uiState: context.appState.uiState,
// State updaters
setAppState: context.setAppState,
updateConfiguration: context.updateConfiguration,
updateOperationState: context.updateOperationState,
updateUIState: context.updateUIState,
};
};
module.exports = useAppState;

71
src/tui/hooks/useHelp.js Normal file
View File

@@ -0,0 +1,71 @@
const { useContext } = require("react");
const { AppContext } = require("../providers/AppProvider.jsx");
const { helpSystem } = require("../utils/keyboardHandlers.js");
/**
* Custom hook for help system functionality
* Requirements: 9.2, 9.5
*/
const useHelp = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error("useHelp must be used within an AppProvider");
}
const { appState, toggleHelp, showHelp, hideHelp } = context;
return {
// Help state
isHelpVisible: appState.uiState.helpVisible,
currentScreen: appState.currentScreen,
// Help actions
toggleHelp,
showHelp,
hideHelp,
// Help content utilities
getScreenShortcuts: () =>
helpSystem.getScreenShortcuts(appState.currentScreen),
getGlobalShortcuts: () => helpSystem.getGlobalShortcuts(),
getAllShortcuts: () => [
...helpSystem.getScreenShortcuts(appState.currentScreen),
...helpSystem.getGlobalShortcuts(),
],
// Help system utilities
isHelpAvailable: () => true, // Help is always available
getHelpTitle: () => {
const screenTitles = {
"main-menu": "Main Menu Help",
configuration: "Configuration Help",
operation: "Operation Help",
scheduling: "Scheduling Help",
logs: "Log Viewer Help",
"tag-analysis": "Tag Analysis Help",
};
return screenTitles[appState.currentScreen] || "General Help";
},
getHelpDescription: () => {
const descriptions = {
"main-menu":
"Use the main menu to navigate to different sections of the application.",
configuration:
"Configure your Shopify store credentials and operation parameters.",
operation:
"Execute price update or rollback operations on your products.",
scheduling: "Schedule operations to run at specific times.",
logs: "View and search through operation logs and history.",
"tag-analysis":
"Analyze product tags and get recommendations for targeting.",
};
return (
descriptions[appState.currentScreen] ||
"General keyboard shortcuts and navigation."
);
},
};
};
module.exports = useHelp;

View File

@@ -0,0 +1,436 @@
const React = require("react");
/**
* Memory Management Hook
* Provides utilities for proper cleanup and memory leak prevention
* Requirements: 4.2, 4.5
*/
/**
* Hook for managing event listeners with automatic cleanup
*/
const useEventListener = (eventName, handler, element = null, options = {}) => {
const savedHandler = React.useRef();
// Update ref.current value if handler changes
React.useEffect(() => {
savedHandler.current = handler;
}, [handler]);
React.useEffect(() => {
// Define the listening target
const targetElement =
element || (typeof window !== "undefined" ? window : null);
if (!targetElement?.addEventListener) return;
// Create event listener that calls handler function stored in ref
const eventListener = (event) => savedHandler.current(event);
// Add event listener
targetElement.addEventListener(eventName, eventListener, options);
// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, eventListener, options);
};
}, [eventName, element, options]);
};
/**
* Hook for managing intervals with automatic cleanup
*/
const useInterval = (callback, delay, immediate = false) => {
const savedCallback = React.useRef();
const intervalId = React.useRef(null);
// Remember the latest callback
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval
React.useEffect(() => {
if (delay === null) return;
const tick = () => savedCallback.current();
if (immediate) {
tick();
}
intervalId.current = setInterval(tick, delay);
return () => {
if (intervalId.current) {
clearInterval(intervalId.current);
intervalId.current = null;
}
};
}, [delay, immediate]);
// Provide manual control
const start = React.useCallback(() => {
if (!intervalId.current && delay !== null) {
intervalId.current = setInterval(() => savedCallback.current(), delay);
}
}, [delay]);
const stop = React.useCallback(() => {
if (intervalId.current) {
clearInterval(intervalId.current);
intervalId.current = null;
}
}, []);
const restart = React.useCallback(() => {
stop();
start();
}, [start, stop]);
return { start, stop, restart, isRunning: intervalId.current !== null };
};
/**
* Hook for managing timeouts with automatic cleanup
*/
const useTimeout = (callback, delay) => {
const savedCallback = React.useRef();
const timeoutId = React.useRef(null);
// Remember the latest callback
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the timeout
React.useEffect(() => {
if (delay === null) return;
timeoutId.current = setTimeout(() => savedCallback.current(), delay);
return () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
timeoutId.current = null;
}
};
}, [delay]);
// Provide manual control
const clear = React.useCallback(() => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
timeoutId.current = null;
}
}, []);
const reset = React.useCallback(() => {
clear();
if (delay !== null) {
timeoutId.current = setTimeout(() => savedCallback.current(), delay);
}
}, [delay, clear]);
return { clear, reset, isActive: timeoutId.current !== null };
};
/**
* Hook for managing async operations with cleanup
*/
const useAsyncOperation = () => {
const isMountedRef = React.useRef(true);
const activeOperations = React.useRef(new Set());
React.useEffect(() => {
return () => {
isMountedRef.current = false;
// Cancel all active operations
activeOperations.current.forEach((operation) => {
if (operation.cancel) {
operation.cancel();
}
});
activeOperations.current.clear();
};
}, []);
const executeAsync = React.useCallback(
async (asyncFunction, onSuccess, onError) => {
if (!isMountedRef.current) return;
const operation = {
id: Date.now() + Math.random(),
cancel: null,
};
// Create cancellable promise
const cancellablePromise = new Promise((resolve, reject) => {
operation.cancel = () => reject(new Error("Operation cancelled"));
asyncFunction().then(resolve).catch(reject);
});
activeOperations.current.add(operation);
try {
const result = await cancellablePromise;
if (isMountedRef.current && onSuccess) {
onSuccess(result);
}
return result;
} catch (error) {
if (
isMountedRef.current &&
onError &&
error.message !== "Operation cancelled"
) {
onError(error);
}
throw error;
} finally {
activeOperations.current.delete(operation);
}
},
[]
);
const cancelAllOperations = React.useCallback(() => {
activeOperations.current.forEach((operation) => {
if (operation.cancel) {
operation.cancel();
}
});
activeOperations.current.clear();
}, []);
return {
executeAsync,
cancelAllOperations,
isMounted: () => isMountedRef.current,
activeOperationsCount: activeOperations.current.size,
};
};
/**
* Hook for managing component memory usage
*/
const useMemoryMonitor = (componentName, options = {}) => {
const {
trackRenders = true,
trackMemory = true,
logInterval = 30000, // 30 seconds
memoryThreshold = 50 * 1024 * 1024, // 50MB
} = options;
const renderCount = React.useRef(0);
const memorySnapshots = React.useRef([]);
const lastLogTime = React.useRef(Date.now());
// Track renders
React.useEffect(() => {
if (trackRenders) {
renderCount.current++;
}
});
// Track memory usage
React.useEffect(() => {
if (!trackMemory) return;
const interval = setInterval(() => {
if (typeof process !== "undefined" && process.memoryUsage) {
const usage = process.memoryUsage();
memorySnapshots.current.push({
timestamp: Date.now(),
...usage,
});
// Keep only last 100 snapshots
if (memorySnapshots.current.length > 100) {
memorySnapshots.current.shift();
}
// Log warnings if memory usage is high
const currentTime = Date.now();
if (currentTime - lastLogTime.current > logInterval) {
if (usage.heapUsed > memoryThreshold) {
console.warn(
`[${componentName}] High memory usage detected: ${(
usage.heapUsed /
1024 /
1024
).toFixed(2)}MB`
);
}
lastLogTime.current = currentTime;
}
}
}, 5000); // Check every 5 seconds
return () => clearInterval(interval);
}, [trackMemory, componentName, logInterval, memoryThreshold]);
const getMemoryStats = React.useCallback(() => {
if (memorySnapshots.current.length === 0) return null;
const latest = memorySnapshots.current[memorySnapshots.current.length - 1];
const oldest = memorySnapshots.current[0];
return {
current: latest,
growth: {
heapUsed: latest.heapUsed - oldest.heapUsed,
heapTotal: latest.heapTotal - oldest.heapTotal,
duration: latest.timestamp - oldest.timestamp,
},
renderCount: renderCount.current,
snapshots: memorySnapshots.current.length,
};
}, []);
const logMemoryStats = React.useCallback(() => {
const stats = getMemoryStats();
if (!stats) return;
console.log(`[${componentName}] Memory Stats:`, {
currentHeapUsed: `${(stats.current.heapUsed / 1024 / 1024).toFixed(2)}MB`,
heapGrowth: `${(stats.growth.heapUsed / 1024 / 1024).toFixed(2)}MB`,
renderCount: stats.renderCount,
duration: `${(stats.growth.duration / 1000).toFixed(1)}s`,
});
}, [componentName, getMemoryStats]);
return {
renderCount: renderCount.current,
getMemoryStats,
logMemoryStats,
};
};
/**
* Hook for managing large object references with weak references
*/
const useWeakRef = (initialValue = null) => {
const weakRefMap = React.useRef(new WeakMap());
const keyRef = React.useRef({});
const setValue = React.useCallback((value) => {
if (value === null || value === undefined) {
weakRefMap.current.delete(keyRef.current);
} else {
weakRefMap.current.set(keyRef.current, value);
}
}, []);
const getValue = React.useCallback(() => {
return weakRefMap.current.get(keyRef.current) || null;
}, []);
// Set initial value
React.useEffect(() => {
if (initialValue !== null) {
setValue(initialValue);
}
}, [initialValue, setValue]);
return [getValue, setValue];
};
/**
* Hook for managing cleanup functions
*/
const useCleanup = () => {
const cleanupFunctions = React.useRef([]);
const addCleanup = React.useCallback((cleanupFn) => {
if (typeof cleanupFn === "function") {
cleanupFunctions.current.push(cleanupFn);
}
}, []);
const runCleanup = React.useCallback(() => {
cleanupFunctions.current.forEach((cleanupFn) => {
try {
cleanupFn();
} catch (error) {
console.error("Error during cleanup:", error);
}
});
cleanupFunctions.current = [];
}, []);
// Run cleanup on unmount
React.useEffect(() => {
return runCleanup;
}, [runCleanup]);
return { addCleanup, runCleanup };
};
/**
* Hook for managing resource pools (e.g., object pools, connection pools)
*/
const useResourcePool = (createResource, resetResource, maxSize = 10) => {
const pool = React.useRef([]);
const activeResources = React.useRef(new Set());
const acquire = React.useCallback(() => {
let resource;
if (pool.current.length > 0) {
resource = pool.current.pop();
if (resetResource) {
resetResource(resource);
}
} else {
resource = createResource();
}
activeResources.current.add(resource);
return resource;
}, [createResource, resetResource]);
const release = React.useCallback(
(resource) => {
if (activeResources.current.has(resource)) {
activeResources.current.delete(resource);
if (pool.current.length < maxSize) {
pool.current.push(resource);
}
}
},
[maxSize]
);
const clear = React.useCallback(() => {
pool.current = [];
activeResources.current.clear();
}, []);
// Clear pool on unmount
React.useEffect(() => {
return clear;
}, [clear]);
return {
acquire,
release,
clear,
poolSize: pool.current.length,
activeCount: activeResources.current.size,
};
};
module.exports = {
useEventListener,
useInterval,
useTimeout,
useAsyncOperation,
useMemoryMonitor,
useWeakRef,
useCleanup,
useResourcePool,
};

View File

@@ -0,0 +1,382 @@
/**
* Modern Terminal Features Hook
* Provides access to true color, enhanced Unicode, and mouse interaction
* Requirements: 12.1, 12.2, 12.3
*/
const { useState, useEffect, useCallback } = require("react");
const {
TerminalCapabilities,
TrueColorUtils,
UnicodeChars,
MouseUtils,
FeatureDetection,
} = require("../utils/modernTerminal.js");
/**
* Custom hook for modern terminal features
*/
const useModernTerminal = () => {
// Terminal capabilities state
const [capabilities, setCapabilities] = useState(() =>
FeatureDetection.getAvailableFeatures()
);
// Optimal configuration based on capabilities
const [config, setConfig] = useState(() =>
FeatureDetection.getOptimalConfig()
);
// Mouse state
const [mouseEnabled, setMouseEnabled] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
// Update capabilities when terminal changes (in development)
useEffect(() => {
if (process.env.NODE_ENV === "development") {
const updateCapabilities = () => {
const newCapabilities = FeatureDetection.getAvailableFeatures();
const newConfig = FeatureDetection.getOptimalConfig();
setCapabilities(newCapabilities);
setConfig(newConfig);
};
// Check for changes periodically in development
const interval = setInterval(updateCapabilities, 5000);
return () => clearInterval(interval);
}
}, []);
// Mouse event handling
useEffect(() => {
if (!mouseEnabled || !capabilities.mouseInteraction) {
return;
}
const handleMouseData = (data) => {
const mouseEvent = MouseUtils.parseMouseEvent(data.toString());
if (mouseEvent) {
setMousePosition({ x: mouseEvent.x, y: mouseEvent.y });
// Emit custom mouse event for components to handle
if (typeof window !== "undefined" && window.dispatchEvent) {
window.dispatchEvent(
new CustomEvent("terminalMouse", {
detail: mouseEvent,
})
);
}
}
};
// Listen for raw input data
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.on("data", handleMouseData);
return () => {
process.stdin.setRawMode(false);
process.stdin.off("data", handleMouseData);
};
}
}, [mouseEnabled, capabilities.mouseInteraction]);
// True color utilities
const colors = {
/**
* Get true color or fallback
*/
rgb: useCallback((r, g, b) => {
return TrueColorUtils.rgb(r, g, b);
}, []),
/**
* Get true color background or fallback
*/
rgbBg: useCallback((r, g, b) => {
return TrueColorUtils.rgbBg(r, g, b);
}, []),
/**
* Get hex color or fallback
*/
hex: useCallback((hexColor) => {
return TrueColorUtils.hex(hexColor);
}, []),
/**
* Get hex background color or fallback
*/
hexBg: useCallback((hexColor) => {
return TrueColorUtils.hexBg(hexColor);
}, []),
/**
* Get Ink-compatible color
*/
getInkColor: useCallback((hexColor) => {
return TrueColorUtils.getInkColor(hexColor);
}, []),
/**
* Check if true color is supported
*/
supportsTrueColor: useCallback(() => {
return capabilities.trueColor;
}, [capabilities.trueColor]),
};
// Unicode character utilities
const unicode = {
/**
* Get Unicode character with fallback
*/
getChar: useCallback((category, name, fallback) => {
return UnicodeChars.getChar(category, name, fallback);
}, []),
/**
* Get box drawing characters
*/
box: UnicodeChars.box,
/**
* Get progress characters
*/
progress: UnicodeChars.progress,
/**
* Get symbol characters
*/
symbols: UnicodeChars.symbols,
/**
* Get emoji characters
*/
emoji: UnicodeChars.emoji,
/**
* Check if enhanced Unicode is supported
*/
supportsEnhanced: useCallback(() => {
return capabilities.enhancedUnicode;
}, [capabilities.enhancedUnicode]),
};
// Mouse interaction utilities
const mouse = {
/**
* Enable mouse tracking
*/
enable: useCallback(() => {
if (capabilities.mouseInteraction) {
const success = MouseUtils.enableMouse();
setMouseEnabled(success);
return success;
}
return false;
}, [capabilities.mouseInteraction]),
/**
* Disable mouse tracking
*/
disable: useCallback(() => {
if (capabilities.mouseInteraction) {
const success = MouseUtils.disableMouse();
setMouseEnabled(false);
return success;
}
return false;
}, [capabilities.mouseInteraction]),
/**
* Get current mouse position
*/
getPosition: useCallback(() => {
return mousePosition;
}, [mousePosition]),
/**
* Check if mouse is enabled
*/
isEnabled: useCallback(() => {
return mouseEnabled;
}, [mouseEnabled]),
/**
* Check if coordinates are within bounds
*/
isWithinBounds: useCallback((mouseX, mouseY, bounds) => {
return MouseUtils.isWithinBounds(mouseX, mouseY, bounds);
}, []),
/**
* Check if mouse interaction is supported
*/
isSupported: useCallback(() => {
return capabilities.mouseInteraction;
}, [capabilities.mouseInteraction]),
};
// Feature detection utilities
const features = {
/**
* Get all available features
*/
getAvailable: useCallback(() => {
return capabilities;
}, [capabilities]),
/**
* Get optimal configuration
*/
getConfig: useCallback(() => {
return config;
}, [config]),
/**
* Test terminal capabilities
*/
test: useCallback(() => {
return FeatureDetection.testCapabilities();
}, []),
/**
* Check if specific feature is available
*/
isAvailable: useCallback(
(feature) => {
return capabilities[feature] || false;
},
[capabilities]
),
};
// Utility functions for common operations
const utils = {
/**
* Create a progress bar with modern characters
*/
createProgressBar: useCallback(
(progress, width = 20, style = "blocks") => {
const filled = Math.round((progress / 100) * width);
const empty = width - filled;
if (style === "blocks" && capabilities.enhancedUnicode) {
const fullChar = unicode.getChar("progress", "full", "#");
const emptyChar = unicode.getChar("progress", "empty", "-");
return fullChar.repeat(filled) + emptyChar.repeat(empty);
}
// ASCII fallback
return "#".repeat(filled) + "-".repeat(empty);
},
[capabilities.enhancedUnicode, unicode]
),
/**
* Create a spinner animation
*/
createSpinner: useCallback(
(frame = 0) => {
if (capabilities.enhancedUnicode) {
const spinnerChars = unicode.progress.spinner;
return spinnerChars[frame % spinnerChars.length];
}
// ASCII fallback
const asciiSpinner = ["|", "/", "-", "\\"];
return asciiSpinner[frame % asciiSpinner.length];
},
[capabilities.enhancedUnicode, unicode]
),
/**
* Create a status indicator
*/
createStatusIndicator: useCallback(
(status) => {
const statusMap = {
success: { char: "checkMark", color: "#00FF00", fallback: "✓" },
error: { char: "crossMark", color: "#FF0000", fallback: "✗" },
warning: { char: "warning", color: "#FFFF00", fallback: "!" },
info: { char: "info", color: "#00FFFF", fallback: "i" },
};
const statusConfig = statusMap[status];
if (!statusConfig) return "?";
const char = unicode.getChar(
"symbols",
statusConfig.char,
statusConfig.fallback
);
if (capabilities.trueColor) {
return colors.hex(statusConfig.color) + char + TrueColorUtils.reset();
}
return char;
},
[capabilities.trueColor, capabilities.enhancedUnicode, colors, unicode]
),
/**
* Create a bordered box with modern characters
*/
createBox: useCallback(
(content, style = "rounded") => {
const boxChars = capabilities.enhancedUnicode
? style === "rounded"
? {
topLeft: unicode.box.roundedTopLeft,
topRight: unicode.box.roundedTopRight,
bottomLeft: unicode.box.roundedBottomLeft,
bottomRight: unicode.box.roundedBottomRight,
horizontal: unicode.box.horizontal,
vertical: unicode.box.vertical,
}
: {
topLeft: unicode.box.topLeft,
topRight: unicode.box.topRight,
bottomLeft: unicode.box.bottomLeft,
bottomRight: unicode.box.bottomRight,
horizontal: unicode.box.horizontal,
vertical: unicode.box.vertical,
}
: {
topLeft: "+",
topRight: "+",
bottomLeft: "+",
bottomRight: "+",
horizontal: "-",
vertical: "|",
};
return {
chars: boxChars,
content,
};
},
[capabilities.enhancedUnicode, unicode]
),
};
return {
// State
capabilities,
config,
mouseEnabled,
mousePosition,
// Utilities
colors,
unicode,
mouse,
features,
utils,
};
};
module.exports = useModernTerminal;

View File

@@ -0,0 +1,44 @@
const { useContext } = require("react");
const { AppContext } = require("../providers/AppProvider.jsx");
/**
* Custom hook for navigation functionality
* Requirements: 5.1, 5.3, 7.1
*/
const useNavigation = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error("useNavigation must be used within an AppProvider");
}
const { appState } = context;
return {
// Current navigation state
currentScreen: appState.currentScreen,
navigationHistory: appState.navigationHistory,
canGoBack: appState.navigationHistory.length > 0,
// Navigation actions
navigateTo: context.navigateTo,
navigateBack: context.navigateBack,
// Navigation utilities
isCurrentScreen: (screenName) => appState.currentScreen === screenName,
getPreviousScreen: () => {
const history = appState.navigationHistory;
return history.length > 0 ? history[history.length - 1] : null;
},
// Clear navigation history (useful for resetting navigation state)
clearHistory: () => {
context.setAppState((prevState) => ({
...prevState,
navigationHistory: [],
}));
},
};
};
module.exports = useNavigation;

View File

@@ -0,0 +1,460 @@
const { useState, useEffect, useRef } = require("react");
const ShopifyService = require("../../services/shopify");
const ProductService = require("../../services/product");
const ProgressService = require("../../services/progress");
// TUI-specific services
const ScheduleService = require("../services/ScheduleService");
const LogService = require("../services/LogService");
const TagAnalysisService = require("../services/TagAnalysisService");
/**
* Custom hook for managing service instances
* Provides access to ShopifyService, ProductService, and ProgressService
* Requirements: 7.1, 12.1
*/
const useServices = () => {
const [services, setServices] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(null);
const servicesRef = useRef(null);
/**
* Initialize services
*/
const initializeServices = async () => {
try {
setError(null);
// Create service instances
const shopifyService = new ShopifyService();
const productService = new ProductService();
const progressService = new ProgressService();
// Create TUI-specific service instances
const scheduleService = new ScheduleService();
const logService = new LogService();
const tagAnalysisService = new TagAnalysisService(
shopifyService,
productService
);
// Store services in ref to prevent recreation on re-renders
servicesRef.current = {
shopifyService,
productService,
progressService,
scheduleService,
logService,
tagAnalysisService,
};
setServices(servicesRef.current);
setIsInitialized(true);
} catch (err) {
setError(err.message);
setIsInitialized(false);
}
};
/**
* Test API connection using ShopifyService
*/
const testConnection = async () => {
if (!services?.shopifyService) {
throw new Error("ShopifyService not initialized");
}
try {
const isConnected = await services.shopifyService.testConnection();
return isConnected;
} catch (error) {
throw new Error(`Connection test failed: ${error.message}`);
}
};
/**
* Get API call limit information
*/
const getApiCallLimit = async () => {
if (!services?.shopifyService) {
throw new Error("ShopifyService not initialized");
}
try {
return await services.shopifyService.getApiCallLimit();
} catch (error) {
console.warn(`Could not retrieve API call limit: ${error.message}`);
return null;
}
};
/**
* Execute GraphQL query through ShopifyService
*/
const executeQuery = async (query, variables = {}) => {
if (!services?.shopifyService) {
throw new Error("ShopifyService not initialized");
}
return await services.shopifyService.executeQuery(query, variables);
};
/**
* Execute GraphQL mutation through ShopifyService
*/
const executeMutation = async (mutation, variables = {}) => {
if (!services?.shopifyService) {
throw new Error("ShopifyService not initialized");
}
return await services.shopifyService.executeMutation(mutation, variables);
};
/**
* Execute operation with retry logic
*/
const executeWithRetry = async (operation, logger = null) => {
if (!services?.shopifyService) {
throw new Error("ShopifyService not initialized");
}
return await services.shopifyService.executeWithRetry(operation, logger);
};
/**
* Fetch products by tag using ProductService
*/
const fetchProductsByTag = async (tag) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return await services.productService.fetchProductsByTag(tag);
};
/**
* Update product prices using ProductService
*/
const updateProductPrices = async (products, priceAdjustmentPercentage) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return await services.productService.updateProductPrices(
products,
priceAdjustmentPercentage
);
};
/**
* Rollback product prices using ProductService
*/
const rollbackProductPrices = async (products) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return await services.productService.rollbackProductPrices(products);
};
/**
* Validate products for operations
*/
const validateProducts = async (products) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return await services.productService.validateProducts(products);
};
/**
* Validate products for rollback operations
*/
const validateProductsForRollback = async (products) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return await services.productService.validateProductsForRollback(products);
};
/**
* Get product summary statistics
*/
const getProductSummary = (products) => {
if (!services?.productService) {
throw new Error("ProductService not initialized");
}
return services.productService.getProductSummary(products);
};
/**
* Log operation start using ProgressService
*/
const logOperationStart = async (config, schedulingContext = null) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logOperationStart(
config,
schedulingContext
);
};
/**
* Log rollback start using ProgressService
*/
const logRollbackStart = async (config, schedulingContext = null) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logRollbackStart(
config,
schedulingContext
);
};
/**
* Log product update using ProgressService
*/
const logProductUpdate = async (entry) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logProductUpdate(entry);
};
/**
* Log rollback update using ProgressService
*/
const logRollbackUpdate = async (entry) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logRollbackUpdate(entry);
};
/**
* Log error using ProgressService
*/
const logError = async (entry, schedulingContext = null) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logError(entry, schedulingContext);
};
/**
* Log completion summary using ProgressService
*/
const logCompletionSummary = async (summary) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logCompletionSummary(summary);
};
/**
* Log rollback summary using ProgressService
*/
const logRollbackSummary = async (summary) => {
if (!services?.progressService) {
throw new Error("ProgressService not initialized");
}
return await services.progressService.logRollbackSummary(summary);
};
// Initialize services on mount
useEffect(() => {
if (!isInitialized && !services) {
initializeServices();
}
}, [isInitialized, services]);
/**
* ScheduleService methods
*/
const loadSchedules = async () => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.loadSchedules();
};
const saveSchedules = async (schedules) => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.saveSchedules(schedules);
};
const addSchedule = async (schedule) => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.addSchedule(schedule);
};
const updateSchedule = async (id, updates) => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.updateSchedule(id, updates);
};
const deleteSchedule = async (id) => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.deleteSchedule(id);
};
const getAllSchedules = async () => {
if (!services?.scheduleService) {
throw new Error("ScheduleService not initialized");
}
return await services.scheduleService.getAllSchedules();
};
/**
* LogService methods
*/
const getLogFiles = async () => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return await services.logService.getLogFiles();
};
const readLogFile = async (filePath) => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return await services.logService.readLogFile(filePath);
};
const parseLogContent = (content) => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return services.logService.parseLogContent(content);
};
const filterLogs = (logs, filters) => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return services.logService.filterLogs(logs, filters);
};
const paginateLogs = (logs, page, pageSize) => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return services.logService.paginateLogs(logs, page, pageSize);
};
const getFilteredLogs = async (options) => {
if (!services?.logService) {
throw new Error("LogService not initialized");
}
return await services.logService.getFilteredLogs(options);
};
/**
* TagAnalysisService methods
*/
const fetchAllTags = async (limit) => {
if (!services?.tagAnalysisService) {
throw new Error("TagAnalysisService not initialized");
}
return await services.tagAnalysisService.fetchAllTags(limit);
};
const getTagDetails = async (tag) => {
if (!services?.tagAnalysisService) {
throw new Error("TagAnalysisService not initialized");
}
return await services.tagAnalysisService.getTagDetails(tag);
};
const calculateTagStatistics = (products) => {
if (!services?.tagAnalysisService) {
throw new Error("TagAnalysisService not initialized");
}
return services.tagAnalysisService.calculateTagStatistics(products);
};
const searchTags = (tags, query) => {
if (!services?.tagAnalysisService) {
throw new Error("TagAnalysisService not initialized");
}
return services.tagAnalysisService.searchTags(tags, query);
};
const getTagRecommendations = (tags) => {
if (!services?.tagAnalysisService) {
throw new Error("TagAnalysisService not initialized");
}
return services.tagAnalysisService.getTagRecommendations(tags);
};
return {
services,
isInitialized,
error,
initializeServices,
// ShopifyService methods
testConnection,
getApiCallLimit,
executeQuery,
executeMutation,
executeWithRetry,
// ProductService methods
fetchProductsByTag,
updateProductPrices,
rollbackProductPrices,
validateProducts,
validateProductsForRollback,
getProductSummary,
// ProgressService methods
logOperationStart,
logRollbackStart,
logProductUpdate,
logRollbackUpdate,
logError,
logCompletionSummary,
logRollbackSummary,
// ScheduleService methods
loadSchedules,
saveSchedules,
addSchedule,
updateSchedule,
deleteSchedule,
getAllSchedules,
// LogService methods
getLogFiles,
readLogFile,
parseLogContent,
filterLogs,
paginateLogs,
getFilteredLogs,
// TagAnalysisService methods
fetchAllTags,
getTagDetails,
calculateTagStatistics,
searchTags,
getTagRecommendations,
};
};
module.exports = useServices;

View File

@@ -0,0 +1,98 @@
const React = require("react");
const { useState, useEffect } = React;
/**
* Custom hook for terminal size management and resize handling
* Requirements: 10.1, 10.2, 10.5
*/
const useTerminalSize = () => {
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
const [isMinimumSize, setIsMinimumSize] = useState(true);
// Minimum size requirements
const MINIMUM_WIDTH = 80;
const MINIMUM_HEIGHT = 20;
useEffect(() => {
const handleResize = () => {
const newSize = {
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
};
setTerminalSize(newSize);
// Check if terminal meets minimum size requirements
const meetsMinimum =
newSize.width >= MINIMUM_WIDTH && newSize.height >= MINIMUM_HEIGHT;
setIsMinimumSize(meetsMinimum);
};
// Listen for terminal resize events
process.stdout.on("resize", handleResize);
// Initial size check
handleResize();
// Cleanup
return () => {
process.stdout.removeListener("resize", handleResize);
};
}, []);
/**
* Get responsive layout configuration based on terminal size
*/
const getLayoutConfig = () => {
const { width, height } = terminalSize;
return {
isSmall: width < 100 || height < 30,
isMedium: width >= 100 && width < 140 && height >= 30,
isLarge: width >= 140 && height >= 30,
showSidebar: width >= 120,
maxContentWidth: Math.min(width - 4, 120), // Leave margin and max width
maxContentHeight: height - 4, // Leave space for status bar and margins
columnsCount: width < 100 ? 1 : width < 140 ? 2 : 3,
};
};
/**
* Get minimum size warning message
*/
const getMinimumSizeMessage = () => {
const { width, height } = terminalSize;
const messages = [];
if (width < MINIMUM_WIDTH) {
messages.push(`Width: ${width} (minimum: ${MINIMUM_WIDTH})`);
}
if (height < MINIMUM_HEIGHT) {
messages.push(`Height: ${height} (minimum: ${MINIMUM_HEIGHT})`);
}
return {
title: "Terminal Too Small",
message: "Please resize your terminal window to continue.",
details: messages,
current: `Current: ${width}x${height}`,
required: `Required: ${MINIMUM_WIDTH}x${MINIMUM_HEIGHT}`,
};
};
return {
terminalSize,
isMinimumSize,
layoutConfig: getLayoutConfig(),
minimumSizeMessage: getMinimumSizeMessage(),
MINIMUM_WIDTH,
MINIMUM_HEIGHT,
};
};
module.exports = useTerminalSize;

View File

@@ -1,5 +1,6 @@
const React = require("react");
const { useState, createContext, useContext } = React;
// const useTerminalSize = require("../hooks/useTerminalSize.js");
/**
* Application Context for global state management
@@ -28,6 +29,12 @@ const initialState = {
modalOpen: false,
selectedMenuIndex: 0,
scrollPosition: 0,
helpVisible: false,
},
terminalState: {
size: { width: 80, height: 24 },
isMinimumSize: true,
layoutConfig: {},
},
};
@@ -37,6 +44,37 @@ const initialState = {
*/
const AppProvider = ({ children }) => {
const [appState, setAppState] = useState(initialState);
// const { terminalSize, isMinimumSize, layoutConfig, minimumSizeMessage } = useTerminalSize();
// Temporary mock terminal state for testing
const mockTerminalState = {
size: { width: 120, height: 30 },
isMinimumSize: true,
layoutConfig: {
isSmall: false,
isMedium: true,
isLarge: false,
maxContentWidth: 116,
maxContentHeight: 26,
columnsCount: 2,
showSidebar: true,
},
minimumSizeMessage: {
title: "Terminal Too Small",
message: "Please resize your terminal window to continue.",
details: [],
current: "Current: 120x30",
required: "Required: 80x20",
},
};
// Update terminal state when terminal size changes
React.useEffect(() => {
setAppState((prevState) => ({
...prevState,
terminalState: mockTerminalState,
}));
}, []);
/**
* Navigate to a new screen
@@ -104,6 +142,45 @@ const AppProvider = ({ children }) => {
}));
};
/**
* Toggle help overlay visibility
*/
const toggleHelp = () => {
setAppState((prevState) => ({
...prevState,
uiState: {
...prevState.uiState,
helpVisible: !prevState.uiState.helpVisible,
},
}));
};
/**
* Show help overlay
*/
const showHelp = () => {
setAppState((prevState) => ({
...prevState,
uiState: {
...prevState.uiState,
helpVisible: true,
},
}));
};
/**
* Hide help overlay
*/
const hideHelp = () => {
setAppState((prevState) => ({
...prevState,
uiState: {
...prevState.uiState,
helpVisible: false,
},
}));
};
const contextValue = {
appState,
setAppState,
@@ -112,12 +189,13 @@ const AppProvider = ({ children }) => {
updateConfiguration,
updateOperationState,
updateUIState,
toggleHelp,
showHelp,
hideHelp,
};
return React.createElement(
AppContext.Provider,
{ value: contextValue },
children
return (
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
};

View File

@@ -0,0 +1,38 @@
const React = require("react");
const { createContext, useContext } = React;
const useServices = require("../hooks/useServices");
/**
* Service Context for providing access to all services
* Requirements: 7.1, 12.1
*/
const ServiceContext = createContext();
/**
* ServiceProvider Component
* Provides service instances to all child components
*/
const ServiceProvider = ({ children }) => {
const serviceHook = useServices();
return (
<ServiceContext.Provider value={serviceHook}>
{children}
</ServiceContext.Provider>
);
};
/**
* Custom hook to use service context
*/
const useServiceContext = () => {
const context = useContext(ServiceContext);
if (!context) {
throw new Error("useServiceContext must be used within a ServiceProvider");
}
return context;
};
module.exports = ServiceProvider;
module.exports.useServiceContext = useServiceContext;
module.exports.ServiceContext = ServiceContext;

View File

@@ -0,0 +1,540 @@
const fs = require("fs").promises;
const path = require("path");
/**
* LogService - Reads and parses Progress.md files for TUI log viewing
* Requirements: 5.2, 2.1, 2.3, 2.4, 2.5
*/
class LogService {
constructor(progressFilePath = "Progress.md") {
this.progressFilePath = progressFilePath;
this.cache = new Map();
this.cacheExpiry = 2 * 60 * 1000; // 2 minutes
}
/**
* Get available log files
* @returns {Promise<Array>} Array of log file information
*/
async getLogFiles() {
try {
const files = [];
// Check main Progress.md file
try {
const stats = await fs.stat(this.progressFilePath);
files.push({
name: "Progress.md",
path: this.progressFilePath,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
isMain: true,
});
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
// Look for archived log files (Progress_YYYY-MM-DD.md pattern)
try {
const currentDir = await fs.readdir(".");
const logFiles = currentDir.filter((file) =>
file.match(/^Progress_\d{4}-\d{2}-\d{2}\.md$/)
);
for (const file of logFiles) {
const stats = await fs.stat(file);
files.push({
name: file,
path: file,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
isMain: false,
});
}
} catch (error) {
// Directory read failed, continue with main file only
}
// Sort by modification date (newest first)
return files.sort((a, b) => b.modified.getTime() - a.modified.getTime());
} catch (error) {
throw new Error(`Failed to get log files: ${error.message}`);
}
}
/**
* Read log file content
* @param {string} filePath - Path to log file
* @returns {Promise<string>} File content
*/
async readLogFile(filePath = null) {
const targetPath = filePath || this.progressFilePath;
try {
const content = await fs.readFile(targetPath, "utf8");
return content;
} catch (error) {
if (error.code === "ENOENT") {
return ""; // Return empty string for non-existent files
}
throw new Error(
`Failed to read log file ${targetPath}: ${error.message}`
);
}
}
/**
* Parse log content into structured entries
* @param {string} content - Raw log content
* @returns {Array} Array of parsed log entries
*/
parseLogContent(content) {
if (!content || content.trim() === "") {
return [];
}
const entries = [];
const lines = content.split("\n");
let currentOperation = null;
let currentSection = null;
let lineIndex = 0;
for (const line of lines) {
lineIndex++;
const trimmedLine = line.trim();
// Skip empty lines and markdown headers
if (
!trimmedLine ||
trimmedLine.startsWith("#") ||
trimmedLine === "---"
) {
continue;
}
// Parse operation headers (## Operation Type - Timestamp)
const operationMatch = trimmedLine.match(/^## (.+) - (.+)$/);
if (operationMatch) {
const [, operationType, timestamp] = operationMatch;
currentOperation = {
id: `op_${entries.length}`,
type: this.parseOperationType(operationType),
timestamp: this.parseTimestamp(timestamp),
rawTimestamp: timestamp,
title: operationType,
level: "INFO",
message: `Operation: ${operationType}`,
details: "",
lineNumber: lineIndex,
configuration: {},
products: [],
summary: {},
errors: [],
};
entries.push(currentOperation);
currentSection = "header";
continue;
}
// Parse section headers
if (trimmedLine.startsWith("**") && trimmedLine.endsWith("**")) {
const sectionTitle = trimmedLine.slice(2, -2);
if (sectionTitle.includes("Configuration")) {
currentSection = "configuration";
} else if (sectionTitle.includes("Progress")) {
currentSection = "progress";
} else if (sectionTitle.includes("Summary")) {
currentSection = "summary";
} else if (sectionTitle.includes("Error")) {
currentSection = "errors";
}
continue;
}
// Parse content based on current section
if (currentOperation && currentSection) {
this.parseLineBySection(
trimmedLine,
currentOperation,
currentSection,
entries,
lineIndex
);
}
}
return entries;
}
/**
* Parse line content based on section
* @param {string} line - Line content
* @param {Object} operation - Current operation
* @param {string} section - Current section
* @param {Array} entries - All entries array
* @param {number} lineNumber - Line number in file
*/
parseLineBySection(line, operation, section, entries, lineNumber) {
switch (section) {
case "configuration":
this.parseConfigurationLine(line, operation);
break;
case "progress":
this.parseProgressLine(line, operation, entries, lineNumber);
break;
case "summary":
this.parseSummaryLine(line, operation);
break;
case "errors":
this.parseErrorLine(line, operation, entries, lineNumber);
break;
}
}
/**
* Parse configuration lines
* @param {string} line - Configuration line
* @param {Object} operation - Operation object
*/
parseConfigurationLine(line, operation) {
const configMatch = line.match(/^- (.+?): (.+)$/);
if (configMatch) {
const [, key, value] = configMatch;
operation.configuration[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse progress lines (product updates)
* @param {string} line - Progress line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
* @param {number} lineNumber - Line number
*/
parseProgressLine(line, operation, entries, lineNumber) {
// Parse product update lines with status indicators
const updateMatch = line.match(
/^- ([✅❌🔄⚠️]) \*\*(.+?)\*\* \((.+?)\)(.*)$/
);
if (updateMatch) {
const [, status, productTitle, productId, details] = updateMatch;
const level = this.getLogLevelFromStatus(status);
const entry = {
id: `entry_${entries.length}`,
type: "product_update",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `${productTitle}`,
level,
message: `${status} ${productTitle}`,
details: `Product ID: ${productId}\nOperation: ${operation.title}${
details ? "\n" + details.trim() : ""
}`,
lineNumber,
productTitle,
productId,
status,
parentOperation: operation.id,
};
entries.push(entry);
operation.products.push(entry);
}
}
/**
* Parse summary lines
* @param {string} line - Summary line
* @param {Object} operation - Operation object
*/
parseSummaryLine(line, operation) {
const summaryMatch = line.match(/^- (.+?): (.+)$/);
if (summaryMatch) {
const [, key, value] = summaryMatch;
operation.summary[key] = value;
operation.details += `${key}: ${value}\n`;
}
}
/**
* Parse error lines
* @param {string} line - Error line
* @param {Object} operation - Operation object
* @param {Array} entries - All entries array
* @param {number} lineNumber - Line number
*/
parseErrorLine(line, operation, entries, lineNumber) {
// Parse numbered error entries
const errorMatch = line.match(/^(\d+)\. \*\*(.+?)\*\* \((.+?)\)(.*)$/);
if (errorMatch) {
const [, errorNum, productTitle, productId, details] = errorMatch;
const entry = {
id: `error_${entries.length}`,
type: "error",
timestamp: operation.timestamp,
rawTimestamp: operation.rawTimestamp,
title: `Error: ${productTitle}`,
level: "ERROR",
message: `Error processing ${productTitle}`,
details: `Product ID: ${productId}\nError #${errorNum}\nOperation: ${
operation.title
}${details ? "\n" + details.trim() : ""}`,
lineNumber,
productTitle,
productId,
errorNumber: parseInt(errorNum),
parentOperation: operation.id,
};
entries.push(entry);
operation.errors.push(entry);
}
}
/**
* Filter logs based on criteria
* @param {Array} logs - Array of log entries
* @param {Object} filters - Filter criteria
* @returns {Array} Filtered log entries
*/
filterLogs(logs, filters = {}) {
let filtered = [...logs];
// Filter by date range
if (filters.dateRange && filters.dateRange !== "all") {
const now = new Date();
let startDate;
switch (filters.dateRange) {
case "today":
startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
break;
case "yesterday":
startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - 1
);
const endDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
filtered = filtered.filter(
(log) => log.timestamp >= startDate && log.timestamp < endDate
);
break;
case "week":
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case "month":
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (startDate && filters.dateRange !== "yesterday") {
filtered = filtered.filter((log) => log.timestamp >= startDate);
}
}
// Filter by operation type
if (filters.operationType && filters.operationType !== "all") {
filtered = filtered.filter((log) => log.type === filters.operationType);
}
// Filter by status/level
if (filters.status && filters.status !== "all") {
filtered = filtered.filter(
(log) => log.level === filters.status.toUpperCase()
);
}
// Filter by search term
if (filters.searchTerm && filters.searchTerm.trim() !== "") {
const searchTerm = filters.searchTerm.toLowerCase();
filtered = filtered.filter(
(log) =>
log.title.toLowerCase().includes(searchTerm) ||
log.message.toLowerCase().includes(searchTerm) ||
log.details.toLowerCase().includes(searchTerm) ||
(log.productTitle &&
log.productTitle.toLowerCase().includes(searchTerm))
);
}
return filtered;
}
/**
* Paginate logs
* @param {Array} logs - Array of log entries
* @param {number} page - Page number (0-based)
* @param {number} pageSize - Number of entries per page
* @returns {Object} Paginated results
*/
paginateLogs(logs, page = 0, pageSize = 20) {
const totalEntries = logs.length;
const totalPages = Math.ceil(totalEntries / pageSize);
const startIndex = page * pageSize;
const endIndex = Math.min(startIndex + pageSize, totalEntries);
const pageEntries = logs.slice(startIndex, endIndex);
return {
entries: pageEntries,
pagination: {
currentPage: page,
pageSize,
totalEntries,
totalPages,
hasNextPage: page < totalPages - 1,
hasPreviousPage: page > 0,
startIndex: startIndex + 1,
endIndex,
},
};
}
/**
* Get logs with filtering and pagination
* @param {Object} options - Options for filtering and pagination
* @returns {Promise<Object>} Filtered and paginated results
*/
async getFilteredLogs(options = {}) {
const {
filePath = null,
page = 0,
pageSize = 20,
dateRange = "all",
operationType = "all",
status = "all",
searchTerm = "",
} = options;
// Check cache first
const cacheKey = `logs_${filePath || "main"}_${JSON.stringify(options)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheExpiry) {
return cached.data;
}
try {
// Read and parse log content
const content = await this.readLogFile(filePath);
const allLogs = this.parseLogContent(content);
// Apply filters
const filteredLogs = this.filterLogs(allLogs, {
dateRange,
operationType,
status,
searchTerm,
});
// Apply pagination
const result = this.paginateLogs(filteredLogs, page, pageSize);
// Add metadata
result.metadata = {
totalUnfilteredEntries: allLogs.length,
filePath: filePath || this.progressFilePath,
filters: { dateRange, operationType, status, searchTerm },
};
// Cache the result
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now(),
});
return result;
} catch (error) {
throw new Error(`Failed to get filtered logs: ${error.message}`);
}
}
/**
* Parse operation type from title
* @param {string} title - Operation title
* @returns {string} Parsed operation type
*/
parseOperationType(title) {
const titleLower = title.toLowerCase();
if (titleLower.includes("rollback")) return "rollback";
if (titleLower.includes("update")) return "update";
if (titleLower.includes("scheduled")) return "scheduled";
return "unknown";
}
/**
* Parse timestamp string into Date object
* @param {string} timestampStr - Timestamp string
* @returns {Date} Parsed date
*/
parseTimestamp(timestampStr) {
try {
// Handle various timestamp formats
let cleanStr = timestampStr.trim();
// Handle "YYYY-MM-DD HH:MM:SS UTC" format
if (cleanStr.endsWith(" UTC")) {
cleanStr = cleanStr.replace(" UTC", "Z").replace(" ", "T");
}
return new Date(cleanStr);
} catch (error) {
return new Date(); // Fallback to current time
}
}
/**
* Get log level from status indicator
* @param {string} status - Status indicator (✅❌🔄⚠️)
* @returns {string} Log level
*/
getLogLevelFromStatus(status) {
switch (status) {
case "✅":
return "SUCCESS";
case "❌":
return "ERROR";
case "⚠️":
return "WARNING";
case "🔄":
return "INFO";
default:
return "INFO";
}
}
/**
* Clear cache
*/
clearCache() {
this.cache.clear();
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getCacheStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys()),
};
}
}
module.exports = LogService;

View File

@@ -0,0 +1,318 @@
const fs = require("fs").promises;
const path = require("path");
/**
* ScheduleService - Manages scheduled operations with JSON persistence for TUI
* Requirements: 5.1, 1.6
*/
class ScheduleService {
constructor() {
this.schedulesFile = "schedules.json";
this.schedules = [];
this.isLoaded = false;
}
/**
* Load schedules from JSON file
* @returns {Promise<Array>} Array of schedules
*/
async loadSchedules() {
try {
const data = await fs.readFile(this.schedulesFile, "utf8");
this.schedules = JSON.parse(data);
this.isLoaded = true;
return this.schedules;
} catch (error) {
if (error.code === "ENOENT") {
// File doesn't exist, start with empty array
this.schedules = [];
this.isLoaded = true;
return this.schedules;
}
throw new Error(`Failed to load schedules: ${error.message}`);
}
}
/**
* Save schedules to JSON file
* @param {Array} schedules - Array of schedules to save
* @returns {Promise<void>}
*/
async saveSchedules(schedules = null) {
try {
const dataToSave = schedules || this.schedules;
await fs.writeFile(
this.schedulesFile,
JSON.stringify(dataToSave, null, 2)
);
if (!schedules) {
this.schedules = dataToSave;
}
} catch (error) {
throw new Error(`Failed to save schedules: ${error.message}`);
}
}
/**
* Add a new schedule
* @param {Object} schedule - Schedule object to add
* @returns {Promise<Object>} Added schedule with generated ID
*/
async addSchedule(schedule) {
if (!this.isLoaded) {
await this.loadSchedules();
}
const validatedSchedule = this.validateSchedule(schedule);
// Generate unique ID
const id = this.generateScheduleId();
const newSchedule = {
...validatedSchedule,
id,
createdAt: new Date().toISOString(),
status: "pending",
nextExecution: this.calculateNextExecution(validatedSchedule),
};
this.schedules.push(newSchedule);
await this.saveSchedules();
return newSchedule;
}
/**
* Update an existing schedule
* @param {string} id - Schedule ID
* @param {Object} updates - Updates to apply
* @returns {Promise<Object>} Updated schedule
*/
async updateSchedule(id, updates) {
if (!this.isLoaded) {
await this.loadSchedules();
}
const index = this.schedules.findIndex((schedule) => schedule.id === id);
if (index === -1) {
throw new Error(`Schedule with ID ${id} not found`);
}
const updatedSchedule = {
...this.schedules[index],
...updates,
updatedAt: new Date().toISOString(),
};
// Validate the updated schedule
this.validateSchedule(updatedSchedule);
// Recalculate next execution if timing changed
if (updates.scheduledTime || updates.recurrence) {
updatedSchedule.nextExecution =
this.calculateNextExecution(updatedSchedule);
}
this.schedules[index] = updatedSchedule;
await this.saveSchedules();
return updatedSchedule;
}
/**
* Delete a schedule
* @param {string} id - Schedule ID
* @returns {Promise<boolean>} True if deleted successfully
*/
async deleteSchedule(id) {
if (!this.isLoaded) {
await this.loadSchedules();
}
const index = this.schedules.findIndex((schedule) => schedule.id === id);
if (index === -1) {
return false;
}
this.schedules.splice(index, 1);
await this.saveSchedules();
return true;
}
/**
* Get all schedules
* @returns {Promise<Array>} Array of all schedules
*/
async getAllSchedules() {
if (!this.isLoaded) {
await this.loadSchedules();
}
return [...this.schedules];
}
/**
* Get schedule by ID
* @param {string} id - Schedule ID
* @returns {Promise<Object|null>} Schedule object or null if not found
*/
async getScheduleById(id) {
if (!this.isLoaded) {
await this.loadSchedules();
}
return this.schedules.find((schedule) => schedule.id === id) || null;
}
/**
* Validate schedule data
* @param {Object} schedule - Schedule to validate
* @returns {Object} Validated schedule
* @throws {Error} If validation fails
*/
validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") {
throw new Error("Schedule must be an object");
}
const required = ["operationType", "scheduledTime", "recurrence"];
for (const field of required) {
if (!schedule[field]) {
throw new Error(`Schedule field '${field}' is required`);
}
}
// Validate operation type
if (!["update", "rollback"].includes(schedule.operationType)) {
throw new Error('Operation type must be "update" or "rollback"');
}
// Validate scheduled time
const scheduledTime = new Date(schedule.scheduledTime);
if (isNaN(scheduledTime.getTime())) {
throw new Error("Invalid scheduled time format");
}
if (scheduledTime <= new Date()) {
throw new Error("Scheduled time must be in the future");
}
// Validate recurrence
if (!["once", "daily", "weekly", "monthly"].includes(schedule.recurrence)) {
throw new Error(
"Recurrence must be one of: once, daily, weekly, monthly"
);
}
// Validate enabled flag
if (
schedule.enabled !== undefined &&
typeof schedule.enabled !== "boolean"
) {
throw new Error("Enabled flag must be a boolean");
}
return {
operationType: schedule.operationType,
scheduledTime: scheduledTime.toISOString(),
recurrence: schedule.recurrence,
enabled: schedule.enabled !== false, // Default to true
config: schedule.config || {},
description: schedule.description || "",
};
}
/**
* Generate unique schedule ID
* @returns {string} Unique ID
*/
generateScheduleId() {
return `schedule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Calculate next execution time based on recurrence
* @param {Object} schedule - Schedule object
* @returns {string} Next execution time ISO string
*/
calculateNextExecution(schedule) {
const baseTime = new Date(schedule.scheduledTime);
switch (schedule.recurrence) {
case "once":
return schedule.scheduledTime;
case "daily":
return new Date(baseTime.getTime() + 24 * 60 * 60 * 1000).toISOString();
case "weekly":
return new Date(
baseTime.getTime() + 7 * 24 * 60 * 60 * 1000
).toISOString();
case "monthly":
const nextMonth = new Date(baseTime);
nextMonth.setMonth(nextMonth.getMonth() + 1);
return nextMonth.toISOString();
default:
return schedule.scheduledTime;
}
}
/**
* Get pending schedules (enabled and not yet executed)
* @returns {Promise<Array>} Array of pending schedules
*/
async getPendingSchedules() {
const schedules = await this.getAllSchedules();
const now = new Date();
return schedules.filter(
(schedule) =>
schedule.enabled &&
schedule.status === "pending" &&
new Date(schedule.scheduledTime) > now
);
}
/**
* Get overdue schedules (should have been executed)
* @returns {Promise<Array>} Array of overdue schedules
*/
async getOverdueSchedules() {
const schedules = await this.getAllSchedules();
const now = new Date();
return schedules.filter(
(schedule) =>
schedule.enabled &&
schedule.status === "pending" &&
new Date(schedule.scheduledTime) <= now
);
}
/**
* Mark schedule as completed
* @param {string} id - Schedule ID
* @param {Object} result - Execution result
* @returns {Promise<Object>} Updated schedule
*/
async markScheduleCompleted(id, result = {}) {
return await this.updateSchedule(id, {
status: "completed",
lastExecuted: new Date().toISOString(),
lastResult: result,
});
}
/**
* Mark schedule as failed
* @param {string} id - Schedule ID
* @param {string} error - Error message
* @returns {Promise<Object>} Updated schedule
*/
async markScheduleFailed(id, error) {
return await this.updateSchedule(id, {
status: "failed",
lastExecuted: new Date().toISOString(),
lastError: error,
});
}
}
module.exports = ScheduleService;

View File

@@ -0,0 +1,524 @@
/**
* TagAnalysisService - Fetches and analyzes Shopify product tags for TUI
* Requirements: 5.3, 3.1, 3.2, 3.3, 3.4, 3.6
*/
class TagAnalysisService {
constructor(shopifyService, productService) {
this.shopifyService = shopifyService;
this.productService = productService;
this.cache = new Map();
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
}
/**
* Fetch all tags from the store
* @param {number} limit - Maximum number of products to analyze
* @returns {Promise<Array>} Array of tag objects with counts
*/
async fetchAllTags(limit = 250) {
const cacheKey = `all_tags_${limit}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheExpiry) {
return cached.data;
}
try {
// Use existing ProductService method to fetch products with tags
const products = await this.productService.debugFetchAllProductTags(
limit
);
if (!products || products.length === 0) {
return [];
}
// Analyze tags from products
const tagMap = new Map();
let totalProducts = 0;
let totalVariants = 0;
products.forEach((product) => {
if (!product.tags || !Array.isArray(product.tags)) return;
totalProducts++;
const productVariants = product.variants ? product.variants.length : 0;
totalVariants += productVariants;
product.tags.forEach((tag) => {
if (!tagMap.has(tag)) {
tagMap.set(tag, {
tag,
productCount: 0,
variantCount: 0,
totalValue: 0,
prices: [],
products: [],
});
}
const tagData = tagMap.get(tag);
tagData.productCount++;
tagData.variantCount += productVariants;
// Store product reference
tagData.products.push({
id: product.id,
title: product.title,
variants: product.variants || [],
});
// Collect price data
if (product.variants) {
product.variants.forEach((variant) => {
if (variant.price) {
const price = parseFloat(variant.price);
if (!isNaN(price)) {
tagData.prices.push(price);
tagData.totalValue += price;
}
}
});
}
});
});
// Convert to array and calculate statistics
const tags = Array.from(tagMap.values()).map((tagData) => ({
...tagData,
averagePrice:
tagData.prices.length > 0
? tagData.totalValue / tagData.prices.length
: 0,
priceRange:
tagData.prices.length > 0
? {
min: Math.min(...tagData.prices),
max: Math.max(...tagData.prices),
}
: { min: 0, max: 0 },
percentage: (tagData.productCount / totalProducts) * 100,
}));
// Sort by product count (descending)
tags.sort((a, b) => b.productCount - a.productCount);
const result = {
tags,
metadata: {
totalProducts,
totalVariants,
totalTags: tags.length,
analyzedAt: new Date().toISOString(),
limit,
},
};
// Cache the result
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now(),
});
return result;
} catch (error) {
throw new Error(`Failed to fetch tags: ${error.message}`);
}
}
/**
* Get detailed information for a specific tag
* @param {string} tag - Tag name
* @returns {Promise<Object>} Detailed tag information
*/
async getTagDetails(tag) {
const cacheKey = `tag_details_${tag}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheExpiry) {
return cached.data;
}
try {
// Fetch products with this specific tag
const products = await this.productService.fetchProductsByTag(tag);
if (!products || products.length === 0) {
return {
tag,
productCount: 0,
variantCount: 0,
totalValue: 0,
averagePrice: 0,
priceRange: { min: 0, max: 0 },
products: [],
statistics: this.calculateTagStatistics([]),
};
}
// Calculate detailed statistics
const statistics = this.calculateTagStatistics(products);
// Prepare product details
const productDetails = products.map((product) => ({
id: product.id,
title: product.title,
handle: product.handle,
tags: product.tags,
variants: product.variants.map((variant) => ({
id: variant.id,
title: variant.title,
price: parseFloat(variant.price) || 0,
compareAtPrice: variant.compareAtPrice
? parseFloat(variant.compareAtPrice)
: null,
inventoryQuantity: variant.inventoryQuantity || 0,
})),
}));
const result = {
tag,
...statistics,
products: productDetails,
analyzedAt: new Date().toISOString(),
};
// Cache the result
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now(),
});
return result;
} catch (error) {
throw new Error(
`Failed to get tag details for "${tag}": ${error.message}`
);
}
}
/**
* Calculate statistics for products with a tag
* @param {Array} products - Array of products
* @returns {Object} Calculated statistics
*/
calculateTagStatistics(products) {
if (!products || products.length === 0) {
return {
productCount: 0,
variantCount: 0,
totalValue: 0,
averagePrice: 0,
priceRange: { min: 0, max: 0 },
priceDistribution: {},
inventoryTotal: 0,
};
}
let totalValue = 0;
let variantCount = 0;
let inventoryTotal = 0;
const prices = [];
products.forEach((product) => {
if (product.variants && Array.isArray(product.variants)) {
product.variants.forEach((variant) => {
variantCount++;
const price = parseFloat(variant.price) || 0;
if (price > 0) {
prices.push(price);
totalValue += price;
}
inventoryTotal += variant.inventoryQuantity || 0;
});
}
});
const averagePrice = prices.length > 0 ? totalValue / prices.length : 0;
const priceRange =
prices.length > 0
? { min: Math.min(...prices), max: Math.max(...prices) }
: { min: 0, max: 0 };
// Calculate price distribution
const priceDistribution = this.calculatePriceDistribution(prices);
return {
productCount: products.length,
variantCount,
totalValue,
averagePrice,
priceRange,
priceDistribution,
inventoryTotal,
};
}
/**
* Calculate price distribution for visualization
* @param {Array} prices - Array of prices
* @returns {Object} Price distribution buckets
*/
calculatePriceDistribution(prices) {
if (prices.length === 0) {
return {};
}
const min = Math.min(...prices);
const max = Math.max(...prices);
const range = max - min;
// Create 5 buckets
const bucketSize = range / 5;
const buckets = {
"Under $25": 0,
"$25-$50": 0,
"$50-$100": 0,
"$100-$200": 0,
"Over $200": 0,
};
prices.forEach((price) => {
if (price < 25) {
buckets["Under $25"]++;
} else if (price < 50) {
buckets["$25-$50"]++;
} else if (price < 100) {
buckets["$50-$100"]++;
} else if (price < 200) {
buckets["$100-$200"]++;
} else {
buckets["Over $200"]++;
}
});
return buckets;
}
/**
* Search tags by query string
* @param {Array} tags - Array of tag objects
* @param {string} query - Search query
* @returns {Array} Filtered tags
*/
searchTags(tags, query) {
if (!query || query.trim() === "") {
return tags;
}
const searchTerm = query.toLowerCase().trim();
return tags.filter((tagData) => {
// Search in tag name
if (tagData.tag.toLowerCase().includes(searchTerm)) {
return true;
}
// Search in product titles
if (
tagData.products &&
tagData.products.some((product) =>
product.title.toLowerCase().includes(searchTerm)
)
) {
return true;
}
return false;
});
}
/**
* Get tag recommendations based on analysis
* @param {Array} tags - Array of tag objects
* @returns {Array} Array of recommendations
*/
getTagRecommendations(tags) {
if (!tags || tags.length === 0) {
return [];
}
const recommendations = [];
// High-impact tags (many products, good for bulk operations)
const highImpactTags = tags
.filter((tag) => tag.productCount >= 10 && tag.percentage >= 5)
.slice(0, 3);
if (highImpactTags.length > 0) {
recommendations.push({
type: "high_impact",
title: "High-Impact Tags",
description: "Tags with many products, ideal for bulk price updates",
tags: highImpactTags,
priority: "high",
});
}
// High-value tags (expensive products)
const highValueTags = tags
.filter((tag) => tag.averagePrice > 100 && tag.productCount >= 3)
.sort((a, b) => b.averagePrice - a.averagePrice)
.slice(0, 3);
if (highValueTags.length > 0) {
recommendations.push({
type: "high_value",
title: "High-Value Tags",
description:
"Tags with premium products where price changes have significant impact",
tags: highValueTags,
priority: "medium",
});
}
// Caution tags (might need special handling)
const cautionTags = tags
.filter((tag) => {
const tagLower = tag.tag.toLowerCase();
return (
tagLower.includes("sale") ||
tagLower.includes("discount") ||
tagLower.includes("clearance") ||
tagLower.includes("new")
);
})
.slice(0, 3);
if (cautionTags.length > 0) {
recommendations.push({
type: "caution",
title: "Use Caution",
description: "Tags that may have special pricing strategies",
tags: cautionTags,
priority: "low",
});
}
return recommendations;
}
/**
* Get tag comparison data
* @param {Array} tagNames - Array of tag names to compare
* @returns {Promise<Object>} Comparison data
*/
async compareTagsAsync(tagNames) {
if (!tagNames || tagNames.length === 0) {
return { tags: [], comparison: {} };
}
try {
const tagDetails = await Promise.all(
tagNames.map((tag) => this.getTagDetails(tag))
);
const comparison = {
totalProducts: tagDetails.reduce(
(sum, tag) => sum + tag.productCount,
0
),
totalVariants: tagDetails.reduce(
(sum, tag) => sum + tag.variantCount,
0
),
averagePrice: {
min: Math.min(...tagDetails.map((tag) => tag.averagePrice)),
max: Math.max(...tagDetails.map((tag) => tag.averagePrice)),
average:
tagDetails.reduce((sum, tag) => sum + tag.averagePrice, 0) /
tagDetails.length,
},
priceRange: {
min: Math.min(...tagDetails.map((tag) => tag.priceRange.min)),
max: Math.max(...tagDetails.map((tag) => tag.priceRange.max)),
},
};
return {
tags: tagDetails,
comparison,
analyzedAt: new Date().toISOString(),
};
} catch (error) {
throw new Error(`Failed to compare tags: ${error.message}`);
}
}
/**
* Clear cache for specific tag or all cache
* @param {string} tag - Optional specific tag to clear
*/
clearCache(tag = null) {
if (tag) {
// Clear specific tag caches
const keysToDelete = Array.from(this.cache.keys()).filter((key) =>
key.includes(tag)
);
keysToDelete.forEach((key) => this.cache.delete(key));
} else {
// Clear all cache
this.cache.clear();
}
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getCacheStats() {
const entries = Array.from(this.cache.values());
return {
size: this.cache.size,
keys: Array.from(this.cache.keys()),
oldestEntry:
entries.length > 0
? Math.min(...entries.map((entry) => entry.timestamp))
: null,
newestEntry:
entries.length > 0
? Math.max(...entries.map((entry) => entry.timestamp))
: null,
};
}
/**
* Validate tag name
* @param {string} tag - Tag name to validate
* @returns {boolean} True if valid
*/
validateTagName(tag) {
return (
typeof tag === "string" &&
tag.trim().length > 0 &&
tag.trim().length <= 255
);
}
/**
* Get popular tags (most used)
* @param {Array} tags - Array of tag objects
* @param {number} limit - Number of tags to return
* @returns {Array} Most popular tags
*/
getPopularTags(tags, limit = 10) {
return tags.sort((a, b) => b.productCount - a.productCount).slice(0, limit);
}
/**
* Get tags by price range
* @param {Array} tags - Array of tag objects
* @param {number} minPrice - Minimum average price
* @param {number} maxPrice - Maximum average price
* @returns {Array} Tags within price range
*/
getTagsByPriceRange(tags, minPrice = 0, maxPrice = Infinity) {
return tags.filter(
(tag) => tag.averagePrice >= minPrice && tag.averagePrice <= maxPrice
);
}
}
module.exports = TagAnalysisService;

View File

@@ -0,0 +1,320 @@
/**
* Accessibility utilities for TUI components
* Provides screen reader support, high contrast mode, and focus management
* Requirements: 8.1, 8.2, 8.3
*/
/**
* Accessibility configuration and detection
*/
const AccessibilityConfig = {
// Screen reader detection (basic heuristics)
isScreenReaderActive: () => {
// Check for common screen reader environment variables
const screenReaderVars = [
"NVDA_ACTIVE",
"JAWS_ACTIVE",
"SCREEN_READER",
"ACCESSIBILITY_MODE",
];
return screenReaderVars.some((varName) => process.env[varName] === "true");
},
// High contrast mode detection
isHighContrastMode: () => {
return (
process.env.HIGH_CONTRAST_MODE === "true" ||
process.env.FORCE_HIGH_CONTRAST === "true"
);
},
// Enhanced focus indicators
shouldShowEnhancedFocus: () => {
return (
AccessibilityConfig.isScreenReaderActive() ||
process.env.ENHANCED_FOCUS === "true"
);
},
// Reduced motion preference
prefersReducedMotion: () => {
return process.env.PREFERS_REDUCED_MOTION === "true";
},
};
/**
* Screen reader text generation utilities
*/
const ScreenReaderUtils = {
/**
* Generate descriptive text for menu items
*/
describeMenuItem: (item, index, total, isSelected) => {
const position = `Item ${index + 1} of ${total}`;
const status = isSelected ? "selected" : "not selected";
const description = item.description ? `, ${item.description}` : "";
return `${item.label}${description}, ${position}, ${status}`;
},
/**
* Generate progress description
*/
describeProgress: (current, total, label) => {
const percentage = Math.round((current / total) * 100);
return `${label}: ${current} of ${total} complete, ${percentage} percent`;
},
/**
* Generate status description
*/
describeStatus: (status, details) => {
const statusText = {
connected: "Connected to Shopify",
disconnected: "Not connected to Shopify",
error: "Error occurred",
loading: "Loading",
idle: "Ready",
};
const baseText = statusText[status] || status;
return details ? `${baseText}, ${details}` : baseText;
},
/**
* Generate form field description
*/
describeFormField: (label, value, isValid, errorMessage) => {
const valueText = value ? `current value: ${value}` : "no value entered";
const validityText = isValid ? "valid" : `invalid, ${errorMessage}`;
return `${label}, ${valueText}, ${validityText}`;
},
};
/**
* High contrast color schemes
*/
const HighContrastColors = {
// Standard high contrast scheme
standard: {
background: "black",
foreground: "white",
accent: "yellow",
success: "green",
error: "red",
warning: "yellow",
info: "cyan",
disabled: "gray",
focus: "yellow",
selection: "white",
},
// Alternative high contrast scheme
alternative: {
background: "white",
foreground: "black",
accent: "blue",
success: "green",
error: "red",
warning: "magenta",
info: "blue",
disabled: "gray",
focus: "blue",
selection: "black",
},
};
/**
* Get appropriate colors based on accessibility settings
*/
const getAccessibleColors = () => {
if (!AccessibilityConfig.isHighContrastMode()) {
// Return standard colors for normal mode
return {
background: undefined, // Use terminal default
foreground: undefined, // Use terminal default
accent: "blue",
success: "green",
error: "red",
warning: "yellow",
info: "cyan",
disabled: "gray",
focus: "blue",
selection: "blue",
};
}
// Use high contrast colors
const scheme =
process.env.HIGH_CONTRAST_SCHEME === "alternative"
? HighContrastColors.alternative
: HighContrastColors.standard;
return scheme;
};
/**
* Focus management utilities
*/
const FocusManager = {
/**
* Generate focus indicator props for components
*/
getFocusProps: (isFocused, componentType = "default") => {
const colors = getAccessibleColors();
const enhancedFocus = AccessibilityConfig.shouldShowEnhancedFocus();
if (!isFocused) {
return {
borderStyle: "single",
borderColor: "gray",
};
}
// Enhanced focus indicators for accessibility
if (enhancedFocus) {
return {
borderStyle: "double",
borderColor: colors.focus,
backgroundColor:
componentType === "input" ? colors.background : undefined,
};
}
// Standard focus indicators
return {
borderStyle: "single",
borderColor: colors.focus,
};
},
/**
* Generate selection indicator props
*/
getSelectionProps: (isSelected) => {
const colors = getAccessibleColors();
if (!isSelected) {
return {
color: colors.foreground,
};
}
return {
color: colors.selection,
backgroundColor: AccessibilityConfig.isHighContrastMode()
? colors.accent
: undefined,
bold: true,
};
},
};
/**
* Keyboard navigation helpers
*/
const KeyboardNavigation = {
/**
* Standard navigation key mappings
*/
keys: {
up: ["up", "k"],
down: ["down", "j"],
left: ["left", "h"],
right: ["right", "l"],
select: ["return", "enter", "space"],
back: ["escape", "backspace"],
help: ["?", "h"],
quit: ["q", "ctrl+c"],
},
/**
* Check if key matches navigation action
*/
isNavigationKey: (key, action) => {
const actionKeys = KeyboardNavigation.keys[action] || [];
return actionKeys.some((keyName) => {
if (keyName.includes("+")) {
// Handle modifier keys like 'ctrl+c'
const [modifier, keyChar] = keyName.split("+");
return key[modifier] && key.name === keyChar;
}
return key.name === keyName;
});
},
/**
* Generate keyboard shortcut description
*/
describeShortcuts: (availableActions) => {
const descriptions = {
up: "Up arrow or K to move up",
down: "Down arrow or J to move down",
left: "Left arrow or H to move left",
right: "Right arrow or L to move right",
select: "Enter or Space to select",
back: "Escape to go back",
help: "Question mark for help",
quit: "Q to quit",
};
return availableActions
.map((action) => descriptions[action])
.filter(Boolean)
.join(", ");
},
};
/**
* Accessibility announcements for screen readers
*/
const AccessibilityAnnouncer = {
/**
* Queue of announcements to be made
*/
announcements: [],
/**
* Add announcement to queue
*/
announce: (message, priority = "polite") => {
if (!AccessibilityConfig.isScreenReaderActive()) {
return;
}
AccessibilityAnnouncer.announcements.push({
message,
priority,
timestamp: Date.now(),
});
// In a real implementation, this would interface with screen reader APIs
// For now, we'll use console output with special formatting
if (process.env.NODE_ENV === "development") {
console.log(`[SCREEN_READER_${priority.toUpperCase()}]: ${message}`);
}
},
/**
* Clear old announcements
*/
clearOldAnnouncements: (maxAge = 5000) => {
const now = Date.now();
AccessibilityAnnouncer.announcements =
AccessibilityAnnouncer.announcements.filter(
(announcement) => now - announcement.timestamp < maxAge
);
},
};
module.exports = {
AccessibilityConfig,
ScreenReaderUtils,
HighContrastColors,
getAccessibleColors,
FocusManager,
KeyboardNavigation,
AccessibilityAnnouncer,
};

View File

@@ -0,0 +1,199 @@
/**
* Global Keyboard Handlers
* Provides reusable keyboard handling utilities for the TUI
* Requirements: 9.1, 9.3, 9.4, 9.2, 9.5
*/
/**
* Global keyboard shortcuts that work across all screens
* @param {string} input - The input character
* @param {object} key - The key object from Ink
* @param {object} context - Application context with state and actions
* @returns {boolean} - True if the key was handled globally, false otherwise
*/
const handleGlobalShortcuts = (input, key, context) => {
const { appState, toggleHelp, navigateBack } = context;
// Help toggle (h key)
if (input === "h" || input === "H") {
// Don't toggle help if we're in an input field or modal
if (
!appState.uiState.modalOpen &&
appState.uiState.focusedComponent !== "input"
) {
toggleHelp();
return true;
}
}
// Back navigation (Escape key)
if (key.escape) {
// If help is visible, close it first
if (appState.uiState.helpVisible) {
context.hideHelp();
return true;
}
// Otherwise, navigate back if possible
if (appState.navigationHistory.length > 0) {
navigateBack();
return true;
}
}
// Quick exit (Ctrl+C or q in main menu)
if (key.ctrl && input === "c") {
process.exit(0);
}
if (
(input === "q" || input === "Q") &&
appState.currentScreen === "main-menu"
) {
process.exit(0);
}
return false;
};
/**
* Create a keyboard handler that combines global shortcuts with screen-specific handling
* @param {function} screenHandler - Screen-specific keyboard handler
* @param {object} context - Application context
* @returns {function} - Combined keyboard handler
*/
const createKeyboardHandler = (screenHandler, context) => {
return (input, key) => {
// First, try to handle global shortcuts
const wasHandledGlobally = handleGlobalShortcuts(input, key, context);
// If not handled globally, pass to screen-specific handler
if (!wasHandledGlobally && screenHandler) {
screenHandler(input, key);
}
};
};
/**
* Common navigation key handlers
*/
const navigationKeys = {
/**
* Handle menu navigation (up/down arrows)
* @param {object} key - The key object from Ink
* @param {number} currentIndex - Current selected index
* @param {number} maxIndex - Maximum index (length - 1)
* @param {function} onIndexChange - Callback when index changes
*/
handleMenuNavigation: (key, currentIndex, maxIndex, onIndexChange) => {
if (key.upArrow) {
const newIndex = Math.max(0, currentIndex - 1);
onIndexChange(newIndex);
return true;
} else if (key.downArrow) {
const newIndex = Math.min(maxIndex, currentIndex + 1);
onIndexChange(newIndex);
return true;
}
return false;
},
/**
* Handle form navigation (Tab key)
* @param {object} key - The key object from Ink
* @param {number} currentIndex - Current field index
* @param {number} maxIndex - Maximum field index
* @param {function} onIndexChange - Callback when index changes
*/
handleFormNavigation: (key, currentIndex, maxIndex, onIndexChange) => {
if (key.tab) {
const newIndex = (currentIndex + 1) % (maxIndex + 1);
onIndexChange(newIndex);
return true;
}
return false;
},
/**
* Handle pagination (Page Up/Page Down)
* @param {object} key - The key object from Ink
* @param {number} currentPage - Current page number
* @param {number} totalPages - Total number of pages
* @param {function} onPageChange - Callback when page changes
*/
handlePagination: (key, currentPage, totalPages, onPageChange) => {
if (key.pageUp) {
const newPage = Math.max(0, currentPage - 1);
onPageChange(newPage);
return true;
} else if (key.pageDown) {
const newPage = Math.min(totalPages - 1, currentPage + 1);
onPageChange(newPage);
return true;
}
return false;
},
};
/**
* Help system utilities
*/
const helpSystem = {
/**
* Get help shortcuts for a specific screen
* @param {string} screenName - Name of the current screen
* @returns {array} - Array of shortcut objects
*/
getScreenShortcuts: (screenName) => {
const shortcuts = {
"main-menu": [
{ key: "↑/↓", description: "Navigate menu" },
{ key: "Enter", description: "Select item" },
{ key: "q", description: "Quit" },
],
configuration: [
{ key: "Tab", description: "Next field" },
{ key: "Enter", description: "Confirm/Test" },
{ key: "Ctrl+S", description: "Save" },
],
operation: [
{ key: "↑/↓", description: "Select operation" },
{ key: "Enter", description: "Start" },
{ key: "Ctrl+C", description: "Cancel" },
],
scheduling: [
{ key: "Tab", description: "Navigate fields" },
{ key: "↑/↓", description: "Adjust values" },
{ key: "Enter", description: "Schedule" },
],
logs: [
{ key: "↑/↓", description: "Scroll" },
{ key: "PgUp/PgDn", description: "Page" },
{ key: "/", description: "Search" },
],
"tag-analysis": [
{ key: "↑/↓", description: "Navigate tags" },
{ key: "Enter", description: "View details" },
{ key: "r", description: "Refresh" },
],
};
return shortcuts[screenName] || [];
},
/**
* Get global shortcuts that work on all screens
* @returns {array} - Array of global shortcut objects
*/
getGlobalShortcuts: () => [
{ key: "h", description: "Toggle help" },
{ key: "Esc", description: "Back/Close" },
{ key: "Ctrl+C", description: "Exit" },
],
};
module.exports = {
handleGlobalShortcuts,
createKeyboardHandler,
navigationKeys,
helpSystem,
};

View File

@@ -0,0 +1,549 @@
/**
* Memory Leak Detection Utility
* Provides tools for detecting and preventing memory leaks in TUI components
* Requirements: 4.2, 4.5
*/
/**
* Memory leak detector class
*/
class MemoryLeakDetector {
constructor(options = {}) {
this.options = {
checkInterval: options.checkInterval || 30000, // 30 seconds
sampleSize: options.sampleSize || 10,
growthThreshold: options.growthThreshold || 5 * 1024 * 1024, // 5MB
enabled: options.enabled !== false,
verbose: options.verbose || false,
...options,
};
this.samples = [];
this.isMonitoring = false;
this.intervalId = null;
this.listeners = new Set();
this.componentRegistry = new Map();
}
/**
* Start monitoring for memory leaks
*/
start() {
if (!this.options.enabled || this.isMonitoring) return;
this.isMonitoring = true;
this.samples = [];
this.intervalId = setInterval(() => {
this.takeSample();
this.analyzeLeaks();
}, this.options.checkInterval);
if (this.options.verbose) {
console.log("[MemoryLeakDetector] Started monitoring");
}
}
/**
* Stop monitoring
*/
stop() {
if (!this.isMonitoring) return;
this.isMonitoring = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.options.verbose) {
console.log("[MemoryLeakDetector] Stopped monitoring");
}
}
/**
* Take a memory usage sample
*/
takeSample() {
if (typeof process === "undefined" || !process.memoryUsage) {
return;
}
const usage = process.memoryUsage();
const sample = {
timestamp: Date.now(),
heapUsed: usage.heapUsed,
heapTotal: usage.heapTotal,
external: usage.external,
rss: usage.rss,
componentCount: this.componentRegistry.size,
};
this.samples.push(sample);
// Keep only the last N samples
if (this.samples.length > this.options.sampleSize) {
this.samples.shift();
}
this.notifyListeners("sample", sample);
}
/**
* Analyze samples for potential memory leaks
*/
analyzeLeaks() {
if (this.samples.length < 3) return;
const analysis = this.performAnalysis();
if (analysis.hasLeak) {
this.notifyListeners("leak-detected", analysis);
if (this.options.verbose) {
console.warn(
"[MemoryLeakDetector] Potential memory leak detected:",
analysis
);
}
}
return analysis;
}
/**
* Perform detailed memory analysis
*/
performAnalysis() {
const recent = this.samples.slice(-3);
const oldest = recent[0];
const newest = recent[recent.length - 1];
const heapGrowth = newest.heapUsed - oldest.heapUsed;
const timeSpan = newest.timestamp - oldest.timestamp;
const growthRate = heapGrowth / (timeSpan / 1000); // bytes per second
// Calculate trend
const trend = this.calculateTrend();
// Detect leak patterns
const hasLeak = this.detectLeakPatterns(heapGrowth, growthRate, trend);
return {
hasLeak,
heapGrowth,
growthRate,
trend,
timeSpan,
samples: recent.length,
analysis: {
steadyGrowth: trend.slope > 0 && trend.correlation > 0.7,
rapidGrowth: growthRate > this.options.growthThreshold / 1000,
componentLeak: this.detectComponentLeak(),
recommendations: this.generateRecommendations(
heapGrowth,
growthRate,
trend
),
},
};
}
/**
* Calculate memory usage trend
*/
calculateTrend() {
if (this.samples.length < 2) {
return { slope: 0, correlation: 0 };
}
const n = this.samples.length;
const x = this.samples.map((_, i) => i);
const y = this.samples.map((s) => s.heapUsed);
// Calculate linear regression
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((sum, xi, i) => sum + xi * y[i], 0);
const sumXX = x.reduce((sum, xi) => sum + xi * xi, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
// Calculate correlation coefficient
const meanX = sumX / n;
const meanY = sumY / n;
const numerator = x.reduce(
(sum, xi, i) => sum + (xi - meanX) * (y[i] - meanY),
0
);
const denomX = Math.sqrt(
x.reduce((sum, xi) => sum + Math.pow(xi - meanX, 2), 0)
);
const denomY = Math.sqrt(
y.reduce((sum, yi) => sum + Math.pow(yi - meanY, 2), 0)
);
const correlation = numerator / (denomX * denomY);
return { slope, correlation };
}
/**
* Detect leak patterns
*/
detectLeakPatterns(heapGrowth, growthRate, trend) {
// Pattern 1: Steady growth over time
if (trend.slope > 0 && trend.correlation > 0.7) {
return true;
}
// Pattern 2: Rapid growth
if (growthRate > this.options.growthThreshold / 1000) {
return true;
}
// Pattern 3: Significant heap growth
if (heapGrowth > this.options.growthThreshold) {
return true;
}
return false;
}
/**
* Detect component-related leaks
*/
detectComponentLeak() {
const componentCounts = this.samples.map((s) => s.componentCount);
const componentGrowth =
componentCounts[componentCounts.length - 1] - componentCounts[0];
return {
hasComponentLeak: componentGrowth > 0,
componentGrowth,
suspiciousComponents: this.getSuspiciousComponents(),
};
}
/**
* Get components that might be leaking
*/
getSuspiciousComponents() {
const suspicious = [];
for (const [name, info] of this.componentRegistry) {
if (info.instances > info.expectedInstances * 2) {
suspicious.push({
name,
instances: info.instances,
expected: info.expectedInstances,
ratio: info.instances / info.expectedInstances,
});
}
}
return suspicious.sort((a, b) => b.ratio - a.ratio);
}
/**
* Generate recommendations for fixing leaks
*/
generateRecommendations(heapGrowth, growthRate, trend) {
const recommendations = [];
if (trend.slope > 0 && trend.correlation > 0.7) {
recommendations.push({
type: "steady-growth",
message:
"Steady memory growth detected. Check for uncleaned event listeners, timers, or accumulating data structures.",
priority: "high",
});
}
if (growthRate > this.options.growthThreshold / 1000) {
recommendations.push({
type: "rapid-growth",
message:
"Rapid memory growth detected. Look for memory-intensive operations or large object creation.",
priority: "critical",
});
}
const componentLeak = this.detectComponentLeak();
if (componentLeak.hasComponentLeak) {
recommendations.push({
type: "component-leak",
message:
"Component instances are not being properly cleaned up. Check component unmounting and cleanup functions.",
priority: "high",
details: componentLeak.suspiciousComponents,
});
}
if (recommendations.length === 0) {
recommendations.push({
type: "general",
message: "Memory usage appears stable. Continue monitoring.",
priority: "low",
});
}
return recommendations;
}
/**
* Register a component for monitoring
*/
registerComponent(name, expectedInstances = 1) {
if (!this.componentRegistry.has(name)) {
this.componentRegistry.set(name, {
instances: 0,
expectedInstances,
createdAt: Date.now(),
});
}
const info = this.componentRegistry.get(name);
info.instances++;
}
/**
* Unregister a component
*/
unregisterComponent(name) {
if (this.componentRegistry.has(name)) {
const info = this.componentRegistry.get(name);
info.instances = Math.max(0, info.instances - 1);
if (info.instances === 0) {
this.componentRegistry.delete(name);
}
}
}
/**
* Add a listener for leak detection events
*/
addListener(callback) {
this.listeners.add(callback);
}
/**
* Remove a listener
*/
removeListener(callback) {
this.listeners.delete(callback);
}
/**
* Notify all listeners of an event
*/
notifyListeners(event, data) {
this.listeners.forEach((callback) => {
try {
callback(event, data);
} catch (error) {
console.error("[MemoryLeakDetector] Error in listener:", error);
}
});
}
/**
* Get current memory statistics
*/
getStatistics() {
if (this.samples.length === 0) return null;
const latest = this.samples[this.samples.length - 1];
const oldest = this.samples[0];
return {
current: latest,
growth: {
heap: latest.heapUsed - oldest.heapUsed,
total: latest.heapTotal - oldest.heapTotal,
timeSpan: latest.timestamp - oldest.timestamp,
},
trend: this.calculateTrend(),
components: this.componentRegistry.size,
samples: this.samples.length,
};
}
/**
* Force garbage collection if available
*/
forceGarbageCollection() {
if (typeof global !== "undefined" && global.gc) {
try {
global.gc();
return true;
} catch (error) {
console.warn(
"[MemoryLeakDetector] Could not force garbage collection:",
error.message
);
}
}
return false;
}
/**
* Generate a detailed report
*/
generateReport() {
const stats = this.getStatistics();
const analysis = this.analyzeLeaks();
return {
timestamp: Date.now(),
monitoring: this.isMonitoring,
statistics: stats,
analysis,
components: Array.from(this.componentRegistry.entries()).map(
([name, info]) => ({
name,
...info,
})
),
recommendations: analysis ? analysis.analysis.recommendations : [],
};
}
}
/**
* Global memory leak detector instance
*/
let globalDetector = null;
/**
* Get or create the global detector instance
*/
const getGlobalDetector = (options = {}) => {
if (!globalDetector) {
globalDetector = new MemoryLeakDetector(options);
}
return globalDetector;
};
/**
* React hook for memory leak detection
*/
const useMemoryLeakDetection = (componentName, options = {}) => {
const React = require("react");
const detector = React.useMemo(() => getGlobalDetector(options), [options]);
React.useEffect(() => {
detector.registerComponent(componentName);
return () => {
detector.unregisterComponent(componentName);
};
}, [detector, componentName]);
return {
detector,
forceGC: () => detector.forceGarbageCollection(),
getReport: () => detector.generateReport(),
getStats: () => detector.getStatistics(),
};
};
/**
* Memory leak detection utilities
*/
const MemoryLeakUtils = {
/**
* Check if an object might be causing a memory leak
*/
checkObjectForLeaks(obj, path = "") {
const issues = [];
if (obj === null || obj === undefined) return issues;
// Check for circular references
const seen = new WeakSet();
const checkCircular = (current, currentPath) => {
if (seen.has(current)) {
issues.push({
type: "circular-reference",
path: currentPath,
message: "Circular reference detected",
});
return;
}
if (typeof current === "object" && current !== null) {
seen.add(current);
for (const key in current) {
if (current.hasOwnProperty(key)) {
checkCircular(current[key], `${currentPath}.${key}`);
}
}
}
};
checkCircular(obj, path);
// Check for large arrays
if (Array.isArray(obj) && obj.length > 10000) {
issues.push({
type: "large-array",
path,
length: obj.length,
message: `Large array with ${obj.length} items`,
});
}
// Check for many properties
if (typeof obj === "object" && obj !== null) {
const keys = Object.keys(obj);
if (keys.length > 1000) {
issues.push({
type: "many-properties",
path,
count: keys.length,
message: `Object with ${keys.length} properties`,
});
}
}
return issues;
},
/**
* Monitor DOM node for potential leaks
*/
checkDOMNodeForLeaks(node) {
const issues = [];
if (!node || typeof node !== "object") return issues;
// Check for excessive event listeners
if (node._events && Object.keys(node._events).length > 50) {
issues.push({
type: "excessive-listeners",
count: Object.keys(node._events).length,
message: "Excessive event listeners detected",
});
}
// Check for detached nodes
if (node.parentNode === null && node !== document) {
issues.push({
type: "detached-node",
message: "Detached DOM node detected",
});
}
return issues;
},
};
module.exports = {
MemoryLeakDetector,
getGlobalDetector,
useMemoryLeakDetection,
MemoryLeakUtils,
};

View File

@@ -0,0 +1,716 @@
/**
* Modern Terminal Features Utilities
* Provides true color support, enhanced Unicode characters, and mouse interaction
* Requirements: 12.1, 12.2, 12.3
*/
/**
* Terminal capability detection
*/
const TerminalCapabilities = {
/**
* Detect if terminal supports true color (24-bit)
*/
supportsTrueColor: () => {
// Check for common true color environment variables
const trueColorVars = ["COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"];
// Check COLORTERM for truecolor or 24bit
if (
process.env.COLORTERM === "truecolor" ||
process.env.COLORTERM === "24bit"
) {
return true;
}
// Check for modern terminal programs
const modernTerminals = [
"iTerm.app",
"vscode",
"Windows Terminal",
"wt",
"hyper",
"alacritty",
"kitty",
];
if (
modernTerminals.some(
(term) =>
process.env.TERM_PROGRAM?.includes(term) ||
process.env.TERMINAL_EMULATOR?.includes(term)
)
) {
return true;
}
// Check TERM variable for modern terminals
const modernTermTypes = [
"xterm-256color",
"screen-256color",
"tmux-256color",
"alacritty",
"kitty",
];
if (modernTermTypes.some((term) => process.env.TERM?.includes(term))) {
return true;
}
// Windows Terminal detection
if (process.platform === "win32" && process.env.WT_SESSION) {
return true;
}
return false;
},
/**
* Detect if terminal supports enhanced Unicode
*/
supportsEnhancedUnicode: () => {
// Check for UTF-8 support
const utf8Vars = ["LC_ALL", "LC_CTYPE", "LANG"];
const hasUtf8 = utf8Vars.some(
(varName) =>
process.env[varName]?.toLowerCase().includes("utf-8") ||
process.env[varName]?.toLowerCase().includes("utf8")
);
if (hasUtf8) {
return true;
}
// Modern terminals generally support enhanced Unicode
return TerminalCapabilities.supportsTrueColor();
},
/**
* Detect if terminal supports mouse interaction
*/
supportsMouseInteraction: () => {
// Check for mouse support environment variables
if (process.env.TERM_FEATURES?.includes("mouse")) {
return true;
}
// Modern terminals with mouse support
const mouseCapableTerminals = [
"iTerm.app",
"Windows Terminal",
"wt",
"alacritty",
"kitty",
"hyper",
];
if (
mouseCapableTerminals.some(
(term) =>
process.env.TERM_PROGRAM?.includes(term) ||
process.env.TERMINAL_EMULATOR?.includes(term)
)
) {
return true;
}
// Windows Terminal has good mouse support
if (process.platform === "win32" && process.env.WT_SESSION) {
return true;
}
return false;
},
/**
* Get terminal size and capabilities
*/
getTerminalInfo: () => {
return {
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
colorDepth: TerminalCapabilities.supportsTrueColor() ? 24 : 8,
supportsUnicode: TerminalCapabilities.supportsEnhancedUnicode(),
supportsMouse: TerminalCapabilities.supportsMouseInteraction(),
platform: process.platform,
termProgram: process.env.TERM_PROGRAM || "unknown",
termType: process.env.TERM || "unknown",
};
},
};
/**
* True color utilities
*/
const TrueColorUtils = {
/**
* Convert RGB values to true color escape sequence
*/
rgb: (r, g, b) => {
if (!TerminalCapabilities.supportsTrueColor()) {
// Fallback to nearest 8-bit color
return TrueColorUtils.fallbackColor(r, g, b);
}
return `\x1b[38;2;${r};${g};${b}m`;
},
/**
* Convert RGB values to true color background escape sequence
*/
rgbBg: (r, g, b) => {
if (!TerminalCapabilities.supportsTrueColor()) {
// Fallback to nearest 8-bit background color
return TrueColorUtils.fallbackBgColor(r, g, b);
}
return `\x1b[48;2;${r};${g};${b}m`;
},
/**
* Convert hex color to RGB true color
*/
hex: (hexColor) => {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return TrueColorUtils.rgb(r, g, b);
},
/**
* Convert hex color to RGB true color background
*/
hexBg: (hexColor) => {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return TrueColorUtils.rgbBg(r, g, b);
},
/**
* Fallback to 8-bit color for terminals without true color support
*/
fallbackColor: (r, g, b) => {
// Convert RGB to nearest 8-bit color (simplified)
const colorIndex =
Math.round((r / 255) * 5) * 36 +
Math.round((g / 255) * 5) * 6 +
Math.round((b / 255) * 5) +
16;
return `\x1b[38;5;${Math.min(255, Math.max(16, colorIndex))}m`;
},
/**
* Fallback to 8-bit background color
*/
fallbackBgColor: (r, g, b) => {
const colorIndex =
Math.round((r / 255) * 5) * 36 +
Math.round((g / 255) * 5) * 6 +
Math.round((b / 255) * 5) +
16;
return `\x1b[48;5;${Math.min(255, Math.max(16, colorIndex))}m`;
},
/**
* Reset color formatting
*/
reset: () => "\x1b[0m",
/**
* Get Ink-compatible color object for true colors
*/
getInkColor: (hexColor) => {
if (TerminalCapabilities.supportsTrueColor()) {
return hexColor;
}
// Return standard color names for fallback
const colorMap = {
"#FF0000": "red",
"#00FF00": "green",
"#0000FF": "blue",
"#FFFF00": "yellow",
"#FF00FF": "magenta",
"#00FFFF": "cyan",
"#FFFFFF": "white",
"#000000": "black",
"#808080": "gray",
};
return colorMap[hexColor.toUpperCase()] || "white";
},
};
/**
* Enhanced Unicode character sets
*/
const UnicodeChars = {
/**
* Box drawing characters (enhanced set)
*/
box: {
// Basic box drawing
horizontal: "─",
vertical: "│",
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
cross: "┼",
teeUp: "┴",
teeDown: "┬",
teeLeft: "┤",
teeRight: "├",
// Double line box drawing
doubleHorizontal: "═",
doubleVertical: "║",
doubleTopLeft: "╔",
doubleTopRight: "╗",
doubleBottomLeft: "╚",
doubleBottomRight: "╝",
doubleCross: "╬",
// Rounded corners
roundedTopLeft: "╭",
roundedTopRight: "╮",
roundedBottomLeft: "╰",
roundedBottomRight: "╯",
// Heavy lines
heavyHorizontal: "━",
heavyVertical: "┃",
heavyTopLeft: "┏",
heavyTopRight: "┓",
heavyBottomLeft: "┗",
heavyBottomRight: "┛",
},
/**
* Progress and status indicators
*/
progress: {
// Block characters for progress bars
full: "█",
sevenEighths: "▉",
threeFourths: "▊",
fiveEighths: "▋",
half: "▌",
threeEighths: "▍",
quarter: "▎",
eighth: "▏",
empty: "░",
light: "░",
medium: "▒",
dark: "▓",
// Spinner characters
spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
dots: ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
// Arrow indicators
arrowRight: "▶",
arrowLeft: "◀",
arrowUp: "▲",
arrowDown: "▼",
triangleRight: "▷",
triangleLeft: "◁",
},
/**
* Status and UI symbols
*/
symbols: {
// Status indicators
checkMark: "✓",
crossMark: "✗",
warning: "⚠",
info: "",
error: "✖",
success: "✔",
// UI elements
bullet: "•",
circle: "○",
filledCircle: "●",
square: "□",
filledSquare: "■",
diamond: "◆",
// Arrows and pointers
rightArrow: "→",
leftArrow: "←",
upArrow: "↑",
downArrow: "↓",
pointer: "►",
// Special characters
ellipsis: "…",
middleDot: "·",
section: "§",
paragraph: "¶",
},
/**
* Emoji-like characters (for terminals that support them)
*/
emoji: {
// Common UI emojis
gear: "⚙",
folder: "📁",
file: "📄",
search: "🔍",
clock: "🕐",
calendar: "📅",
chart: "📊",
tag: "🏷",
// Status emojis
rocket: "🚀",
fire: "🔥",
star: "⭐",
heart: "❤",
thumbsUp: "👍",
thumbsDown: "👎",
},
/**
* Get appropriate character based on terminal capabilities
*/
getChar: (category, name, fallback = "?") => {
if (!TerminalCapabilities.supportsEnhancedUnicode()) {
// Provide ASCII fallbacks
const asciiFallbacks = {
box: {
horizontal: "-",
vertical: "|",
topLeft: "+",
topRight: "+",
bottomLeft: "+",
bottomRight: "+",
cross: "+",
},
progress: {
full: "#",
empty: "-",
spinner: ["|", "/", "-", "\\"],
arrowRight: ">",
arrowLeft: "<",
},
symbols: {
checkMark: "v",
crossMark: "x",
warning: "!",
info: "i",
rightArrow: ">",
leftArrow: "<",
pointer: ">",
},
};
return asciiFallbacks[category]?.[name] || fallback;
}
return UnicodeChars[category]?.[name] || fallback;
},
};
/**
* Mouse interaction utilities
*/
const MouseUtils = {
/**
* Enable mouse tracking in terminal
*/
enableMouse: () => {
if (!TerminalCapabilities.supportsMouseInteraction()) {
return false;
}
// Enable mouse tracking escape sequences
process.stdout.write("\x1b[?1000h"); // Basic mouse tracking
process.stdout.write("\x1b[?1002h"); // Button event tracking
process.stdout.write("\x1b[?1015h"); // Extended coordinates
process.stdout.write("\x1b[?1006h"); // SGR mouse mode
return true;
},
/**
* Disable mouse tracking in terminal
*/
disableMouse: () => {
if (!TerminalCapabilities.supportsMouseInteraction()) {
return false;
}
// Disable mouse tracking escape sequences
process.stdout.write("\x1b[?1006l"); // SGR mouse mode
process.stdout.write("\x1b[?1015l"); // Extended coordinates
process.stdout.write("\x1b[?1002l"); // Button event tracking
process.stdout.write("\x1b[?1000l"); // Basic mouse tracking
return true;
},
/**
* Parse mouse event from terminal input
*/
parseMouseEvent: (data) => {
// Parse SGR mouse format: \x1b[<button;x;y;M or m
const sgrMatch = data.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
if (sgrMatch) {
const [, button, x, y, action] = sgrMatch;
return {
button: parseInt(button),
x: parseInt(x),
y: parseInt(y),
action: action === "M" ? "press" : "release",
type: "mouse",
};
}
// Parse basic mouse format: \x1b[M + 3 bytes
if (data.startsWith("\x1b[M") && data.length >= 6) {
const button = data.charCodeAt(3) - 32;
const x = data.charCodeAt(4) - 32;
const y = data.charCodeAt(5) - 32;
return {
button,
x,
y,
action: "press",
type: "mouse",
};
}
return null;
},
/**
* Check if coordinates are within a component bounds
*/
isWithinBounds: (mouseX, mouseY, componentBounds) => {
const { x, y, width, height } = componentBounds;
return (
mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height
);
},
};
/**
* Modern terminal feature detection and graceful degradation
*/
const FeatureDetection = {
/**
* Get available modern features
*/
getAvailableFeatures: () => {
return {
trueColor: TerminalCapabilities.supportsTrueColor(),
enhancedUnicode: TerminalCapabilities.supportsEnhancedUnicode(),
mouseInteraction: TerminalCapabilities.supportsMouseInteraction(),
terminalInfo: TerminalCapabilities.getTerminalInfo(),
};
},
/**
* Get feature-appropriate configuration
*/
getOptimalConfig: () => {
const features = FeatureDetection.getAvailableFeatures();
return {
// Color configuration
colors: {
useTrue: features.trueColor,
palette: features.trueColor ? "extended" : "basic",
},
// Character configuration
characters: {
useUnicode: features.enhancedUnicode,
boxStyle: features.enhancedUnicode ? "rounded" : "basic",
progressStyle: features.enhancedUnicode ? "blocks" : "ascii",
},
// Interaction configuration
interaction: {
enableMouse: features.mouseInteraction,
mouseTracking: features.mouseInteraction ? "full" : "none",
},
// Performance configuration
performance: {
animationLevel: features.enhancedUnicode ? "full" : "reduced",
updateFrequency: features.trueColor ? "high" : "standard",
},
};
},
/**
* Test terminal capabilities
*/
testCapabilities: () => {
const results = {
trueColor: false,
unicode: false,
mouse: false,
errors: [],
};
try {
// Test true color
if (TerminalCapabilities.supportsTrueColor()) {
process.stdout.write(
TrueColorUtils.rgb(255, 0, 0) + "●" + TrueColorUtils.reset()
);
results.trueColor = true;
}
} catch (error) {
results.errors.push(`True color test failed: ${error.message}`);
}
try {
// Test Unicode
if (TerminalCapabilities.supportsEnhancedUnicode()) {
process.stdout.write(UnicodeChars.symbols.checkMark);
results.unicode = true;
}
} catch (error) {
results.errors.push(`Unicode test failed: ${error.message}`);
}
try {
// Test mouse (just capability, not actual interaction)
results.mouse = TerminalCapabilities.supportsMouseInteraction();
} catch (error) {
results.errors.push(`Mouse test failed: ${error.message}`);
}
return results;
},
};
/**
* Windows-specific terminal detection and capabilities
*/
const WindowsTerminalUtils = {
/**
* Detect if running in Windows Terminal
*/
detectWindowsTerminal: () => {
if (process.platform !== "win32") {
return false;
}
// Check for Windows Terminal session
if (process.env.WT_SESSION) {
return true;
}
// Check for Windows Terminal program
if (process.env.TERM_PROGRAM?.includes("Windows Terminal")) {
return true;
}
return false;
},
/**
* Get Windows terminal capabilities
*/
getWindowsTerminalCapabilities: () => {
const isWindows = process.platform === "win32";
const isWindowsTerminal = WindowsTerminalUtils.detectWindowsTerminal();
// Command Prompt detection - must not be Windows Terminal and have COMSPEC
const isCommandPrompt =
isWindows &&
process.env.COMSPEC?.includes("cmd.exe") &&
!isWindowsTerminal &&
!process.env.PSModulePath &&
!process.env.TERM_PROGRAM?.includes("PowerShell");
// PowerShell detection - must not be Windows Terminal and have PowerShell indicators
const isPowerShell =
isWindows &&
(process.env.PSModulePath ||
process.env.TERM_PROGRAM?.includes("PowerShell")) &&
!isWindowsTerminal;
let terminalType = "unknown";
if (isWindowsTerminal) terminalType = "windows-terminal";
else if (isCommandPrompt) terminalType = "cmd";
else if (isPowerShell) terminalType = "powershell";
return {
isWindows,
isWindowsTerminal,
isCommandPrompt,
isPowerShell,
terminalType,
supportsUnicode: Boolean(isWindowsTerminal || isPowerShell),
supportsTrueColor:
isWindowsTerminal &&
(process.env.COLORTERM === "truecolor" ||
process.env.COLORTERM === "24bit"),
supportsColor: true, // All Windows terminals support basic colors
supports256Color: Boolean(isWindowsTerminal || isPowerShell),
supportsMouseInteraction: isWindowsTerminal,
version: process.env.WT_PROFILE_ID || "unknown",
};
},
/**
* Get Windows color support details
*/
getWindowsColorSupport: () => {
const capabilities = WindowsTerminalUtils.getWindowsTerminalCapabilities();
return {
supportsTrueColor: capabilities.supportsTrueColor,
supports256Color: capabilities.supports256Color,
supportsBasicColor: capabilities.supportsColor,
colorDepth: capabilities.supportsTrueColor
? 24
: capabilities.supports256Color
? 8
: 4,
};
},
/**
* Get Windows Unicode support details
*/
getWindowsUnicodeSupport: () => {
const capabilities = WindowsTerminalUtils.getWindowsTerminalCapabilities();
return {
supportsUnicode: capabilities.supportsUnicode,
supportsEmoji: capabilities.isWindowsTerminal,
supportsBoxDrawing: capabilities.supportsUnicode,
encoding: capabilities.supportsUnicode ? "utf-8" : "ascii",
};
},
};
// Export Windows-specific functions for backward compatibility
const detectWindowsTerminal = WindowsTerminalUtils.detectWindowsTerminal;
const getWindowsTerminalCapabilities =
WindowsTerminalUtils.getWindowsTerminalCapabilities;
const getWindowsColorSupport = WindowsTerminalUtils.getWindowsColorSupport;
const getWindowsUnicodeSupport = WindowsTerminalUtils.getWindowsUnicodeSupport;
module.exports = {
TerminalCapabilities,
TrueColorUtils,
UnicodeChars,
MouseUtils,
FeatureDetection,
WindowsTerminalUtils,
// Export individual functions for easier testing
detectWindowsTerminal,
getWindowsTerminalCapabilities,
getWindowsColorSupport,
getWindowsUnicodeSupport,
};

View File

@@ -0,0 +1,477 @@
/**
* Performance utilities for TUI components
* Provides benchmarking, profiling, and optimization tools
* Requirements: 4.1, 4.3, 4.4
*/
/**
* Performance profiler for measuring component render times
*/
class PerformanceProfiler {
constructor() {
this.measurements = new Map();
this.isEnabled = process.env.NODE_ENV === "development";
}
/**
* Start measuring a performance metric
*/
start(name) {
if (!this.isEnabled) return;
this.measurements.set(name, {
startTime: process.hrtime.bigint(),
endTime: null,
duration: null,
});
}
/**
* End measuring a performance metric
*/
end(name) {
if (!this.isEnabled) return;
const measurement = this.measurements.get(name);
if (!measurement) {
console.warn(`Performance measurement '${name}' was not started`);
return;
}
measurement.endTime = process.hrtime.bigint();
measurement.duration =
Number(measurement.endTime - measurement.startTime) / 1000000; // Convert to milliseconds
return measurement.duration;
}
/**
* Get measurement results
*/
getMeasurement(name) {
return this.measurements.get(name);
}
/**
* Get all measurements
*/
getAllMeasurements() {
const results = {};
for (const [name, measurement] of this.measurements) {
if (measurement.duration !== null) {
results[name] = measurement.duration;
}
}
return results;
}
/**
* Clear all measurements
*/
clear() {
this.measurements.clear();
}
/**
* Log performance summary
*/
logSummary() {
if (!this.isEnabled) return;
const measurements = this.getAllMeasurements();
const entries = Object.entries(measurements);
if (entries.length === 0) {
console.log("No performance measurements recorded");
return;
}
console.log("\n=== Performance Summary ===");
entries
.sort(([, a], [, b]) => b - a) // Sort by duration descending
.forEach(([name, duration]) => {
const color =
duration > 100 ? "\x1b[31m" : duration > 50 ? "\x1b[33m" : "\x1b[32m";
console.log(`${color}${name}: ${duration.toFixed(2)}ms\x1b[0m`);
});
console.log("===========================\n");
}
}
/**
* React hook for measuring component render performance
*/
const usePerformanceProfiler = (componentName) => {
const React = require("react");
const profiler = React.useMemo(() => new PerformanceProfiler(), []);
React.useEffect(() => {
profiler.start(`${componentName}-render`);
return () => {
profiler.end(`${componentName}-render`);
};
});
return profiler;
};
/**
* Debounce utility for preventing excessive function calls
*/
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
/**
* Throttle utility for limiting function call frequency
*/
const throttle = (func, limit) => {
let inThrottle;
return (...args) => {
if (!inThrottle) {
func.apply(null, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
};
/**
* Memory usage monitor
*/
class MemoryMonitor {
constructor() {
this.snapshots = [];
this.isMonitoring = false;
this.interval = null;
}
/**
* Start monitoring memory usage
*/
startMonitoring(intervalMs = 5000) {
if (this.isMonitoring) return;
this.isMonitoring = true;
this.snapshots = [];
this.interval = setInterval(() => {
const usage = process.memoryUsage();
this.snapshots.push({
timestamp: Date.now(),
...usage,
});
// Keep only last 100 snapshots to prevent memory leak
if (this.snapshots.length > 100) {
this.snapshots.shift();
}
}, intervalMs);
}
/**
* Stop monitoring memory usage
*/
stopMonitoring() {
if (!this.isMonitoring) return;
this.isMonitoring = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* Get current memory usage
*/
getCurrentUsage() {
return process.memoryUsage();
}
/**
* Get memory usage statistics
*/
getStatistics() {
if (this.snapshots.length === 0) {
return null;
}
const latest = this.snapshots[this.snapshots.length - 1];
const oldest = this.snapshots[0];
const heapUsedDiff = latest.heapUsed - oldest.heapUsed;
const heapTotalDiff = latest.heapTotal - oldest.heapTotal;
return {
current: latest,
growth: {
heapUsed: heapUsedDiff,
heapTotal: heapTotalDiff,
duration: latest.timestamp - oldest.timestamp,
},
snapshots: this.snapshots.length,
};
}
/**
* Check for potential memory leaks
*/
checkForLeaks() {
const stats = this.getStatistics();
if (!stats) return null;
const { growth } = stats;
const growthRateMB =
growth.heapUsed / (1024 * 1024) / (growth.duration / 1000 / 60); // MB per minute
return {
isLikely: growthRateMB > 1, // More than 1MB per minute growth
growthRate: growthRateMB,
recommendation:
growthRateMB > 1
? "Potential memory leak detected. Check for uncleaned event listeners, timers, or large object references."
: "Memory usage appears stable.",
};
}
/**
* Log memory usage summary
*/
logSummary() {
const stats = this.getStatistics();
if (!stats) {
console.log("No memory usage data available");
return;
}
const current = stats.current;
const leak = this.checkForLeaks();
console.log("\n=== Memory Usage Summary ===");
console.log(`Heap Used: ${(current.heapUsed / 1024 / 1024).toFixed(2)} MB`);
console.log(
`Heap Total: ${(current.heapTotal / 1024 / 1024).toFixed(2)} MB`
);
console.log(`External: ${(current.external / 1024 / 1024).toFixed(2)} MB`);
console.log(`RSS: ${(current.rss / 1024 / 1024).toFixed(2)} MB`);
if (leak) {
const color = leak.isLikely ? "\x1b[31m" : "\x1b[32m";
console.log(
`${color}Growth Rate: ${leak.growthRate.toFixed(2)} MB/min\x1b[0m`
);
console.log(`${color}${leak.recommendation}\x1b[0m`);
}
console.log("============================\n");
}
}
/**
* React hook for monitoring component memory usage
*/
const useMemoryMonitor = (componentName) => {
const React = require("react");
const monitor = React.useMemo(() => new MemoryMonitor(), []);
React.useEffect(() => {
monitor.startMonitoring();
return () => {
monitor.stopMonitoring();
};
}, [monitor]);
return monitor;
};
/**
* Performance benchmark utility
*/
class PerformanceBenchmark {
constructor(name) {
this.name = name;
this.runs = [];
}
/**
* Run a benchmark test
*/
async run(testFunction, iterations = 100) {
console.log(`Running benchmark: ${this.name} (${iterations} iterations)`);
const results = [];
for (let i = 0; i < iterations; i++) {
const startTime = process.hrtime.bigint();
await testFunction();
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
results.push(duration);
}
this.runs.push({
timestamp: Date.now(),
iterations,
results,
});
return this.getLatestStatistics();
}
/**
* Get statistics for the latest benchmark run
*/
getLatestStatistics() {
if (this.runs.length === 0) return null;
const latest = this.runs[this.runs.length - 1];
const results = latest.results;
results.sort((a, b) => a - b);
const min = results[0];
const max = results[results.length - 1];
const median = results[Math.floor(results.length / 2)];
const average = results.reduce((sum, val) => sum + val, 0) / results.length;
const p95 = results[Math.floor(results.length * 0.95)];
const p99 = results[Math.floor(results.length * 0.99)];
return {
iterations: latest.iterations,
min,
max,
median,
average,
p95,
p99,
standardDeviation: Math.sqrt(
results.reduce((sum, val) => sum + Math.pow(val - average, 2), 0) /
results.length
),
};
}
/**
* Log benchmark results
*/
logResults() {
const stats = this.getLatestStatistics();
if (!stats) {
console.log(`No benchmark results for: ${this.name}`);
return;
}
console.log(`\n=== Benchmark Results: ${this.name} ===`);
console.log(`Iterations: ${stats.iterations}`);
console.log(`Average: ${stats.average.toFixed(2)}ms`);
console.log(`Median: ${stats.median.toFixed(2)}ms`);
console.log(`Min: ${stats.min.toFixed(2)}ms`);
console.log(`Max: ${stats.max.toFixed(2)}ms`);
console.log(`95th percentile: ${stats.p95.toFixed(2)}ms`);
console.log(`99th percentile: ${stats.p99.toFixed(2)}ms`);
console.log(`Standard deviation: ${stats.standardDeviation.toFixed(2)}ms`);
console.log("=====================================\n");
}
}
/**
* Virtual scrolling utilities
*/
const VirtualScrollUtils = {
/**
* Calculate optimal buffer size for virtual scrolling
*/
calculateOptimalBuffer(itemCount, visibleCount, itemHeight = 1) {
if (itemCount < 100) return Math.min(5, Math.floor(visibleCount * 0.5));
if (itemCount < 1000) return Math.min(10, Math.floor(visibleCount * 0.8));
return Math.min(20, visibleCount);
},
/**
* Calculate visible range for virtual scrolling
*/
calculateVisibleRange(scrollTop, itemHeight, containerHeight, buffer = 5) {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = startIndex + visibleCount + buffer * 2;
return { startIndex, endIndex, visibleCount };
},
/**
* Estimate item height based on content
*/
estimateItemHeight(content, maxWidth = 80) {
if (typeof content === "string") {
return Math.max(1, Math.ceil(content.length / maxWidth));
}
return 1;
},
};
/**
* Component optimization utilities
*/
const OptimizationUtils = {
/**
* Create a memoized component with custom comparison
*/
createMemoizedComponent(Component, compareProps) {
const React = require("react");
return React.memo(Component, compareProps);
},
/**
* Create a debounced callback hook
*/
useDebouncedCallback(callback, delay, deps) {
const React = require("react");
return React.useCallback(debounce(callback, delay), deps);
},
/**
* Create a throttled callback hook
*/
useThrottledCallback(callback, limit, deps) {
const React = require("react");
return React.useCallback(throttle(callback, limit), deps);
},
/**
* Shallow comparison for props
*/
shallowEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
for (let key of keys1) {
if (obj1[key] !== obj2[key]) {
return false;
}
}
return true;
},
};
module.exports = {
PerformanceProfiler,
usePerformanceProfiler,
MemoryMonitor,
useMemoryMonitor,
PerformanceBenchmark,
VirtualScrollUtils,
OptimizationUtils,
debounce,
throttle,
};

View File

@@ -0,0 +1,172 @@
/**
* Responsive Layout Utilities
* Helper functions for adapting layouts to different terminal sizes
* Requirements: 10.2, 10.3, 10.4
*/
/**
* Calculate responsive dimensions for components
*/
const getResponsiveDimensions = (layoutConfig, componentType) => {
const { isSmall, isMedium, isLarge, maxContentWidth, maxContentHeight } =
layoutConfig;
const dimensions = {
menu: {
width: isSmall
? maxContentWidth
: isMedium
? Math.floor(maxContentWidth * 0.7)
: Math.floor(maxContentWidth * 0.6),
height: isSmall
? Math.floor(maxContentHeight * 0.8)
: maxContentHeight - 2,
},
form: {
width: isSmall ? maxContentWidth : Math.min(60, maxContentWidth),
height: maxContentHeight - 4,
},
progress: {
width: isSmall ? maxContentWidth - 4 : Math.min(50, maxContentWidth - 10),
height: isSmall ? 8 : 10,
},
logs: {
width: maxContentWidth,
height: maxContentHeight - 6,
},
sidebar: {
width: isLarge ? 30 : isMedium ? 25 : 0,
height: maxContentHeight,
},
};
return (
dimensions[componentType] || {
width: maxContentWidth,
height: maxContentHeight,
}
);
};
/**
* Get responsive column layout
*/
const getColumnLayout = (layoutConfig, itemCount) => {
const { columnsCount, maxContentWidth } = layoutConfig;
if (itemCount <= columnsCount) {
return {
columns: itemCount,
itemWidth: Math.floor(maxContentWidth / itemCount) - 2,
};
}
return {
columns: columnsCount,
itemWidth: Math.floor(maxContentWidth / columnsCount) - 2,
rows: Math.ceil(itemCount / columnsCount),
};
};
/**
* Calculate scrollable area dimensions
*/
const getScrollableDimensions = (layoutConfig, totalItems, itemHeight = 1) => {
const { maxContentHeight } = layoutConfig;
const availableHeight = maxContentHeight - 4; // Leave space for headers/footers
const visibleItems = Math.floor(availableHeight / itemHeight);
const needsScrolling = totalItems > visibleItems;
return {
visibleItems,
totalItems,
needsScrolling,
scrollHeight: availableHeight,
itemHeight,
};
};
/**
* Get responsive text truncation length
*/
const getTextTruncationLength = (layoutConfig, containerWidth) => {
const { isSmall, isMedium } = layoutConfig;
if (isSmall) {
return Math.max(20, containerWidth - 10);
} else if (isMedium) {
return Math.max(40, containerWidth - 8);
}
return Math.max(60, containerWidth - 6);
};
/**
* Get responsive padding and margins
*/
const getResponsiveSpacing = (layoutConfig) => {
const { isSmall, isMedium } = layoutConfig;
return {
padding: isSmall ? 1 : 2,
margin: isSmall ? 0 : 1,
gap: isSmall ? 0 : 1,
};
};
/**
* Check if component should be hidden on small screens
*/
const shouldHideOnSmallScreen = (layoutConfig, componentType) => {
const { isSmall } = layoutConfig;
const hideOnSmall = ["sidebar", "secondary-info", "decorative-elements"];
return isSmall && hideOnSmall.includes(componentType);
};
/**
* Get adaptive font styling for different screen sizes
*/
const getAdaptiveFontStyle = (layoutConfig, textType = "normal") => {
const { isSmall } = layoutConfig;
const styles = {
title: {
bold: true,
color: isSmall ? "white" : "blue",
},
subtitle: {
bold: !isSmall,
color: "gray",
},
normal: {
color: "white",
},
emphasis: {
bold: true,
color: "yellow",
},
error: {
bold: true,
color: "red",
},
success: {
bold: true,
color: "green",
},
};
return styles[textType] || styles.normal;
};
module.exports = {
getResponsiveDimensions,
getColumnLayout,
getScrollableDimensions,
getTextTruncationLength,
getResponsiveSpacing,
shouldHideOnSmallScreen,
getAdaptiveFontStyle,
};

View File

@@ -0,0 +1,362 @@
/**
* Windows-Specific Keyboard Event Handlers
* Optimized keyboard handling for Windows terminal environments
* Requirements: 1.5, 4.4
*/
const { WindowsKeyboardOptimizations } = require("./windowsOptimizations.js");
const { getWindowsTerminalCapabilities } = require("./modernTerminal.js");
/**
* Windows Keyboard Handler Class
*/
class WindowsKeyboardHandler {
constructor(options = {}) {
this.debounceDelay = options.debounceDelay || 50;
this.enableEnhancedKeys = options.enableEnhancedKeys !== false;
this.keyDebouncer = WindowsKeyboardOptimizations.createKeyDebouncer(
this.debounceDelay
);
this.capabilities = getWindowsTerminalCapabilities();
this.listeners = new Map();
this.isActive = false;
}
/**
* Start listening for keyboard events
*/
start() {
if (this.isActive) return;
this.isActive = true;
// Configure stdin for raw mode if possible
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
// Add keyboard event listener
this.keyListener = (data) => this.handleKeyInput(data);
process.stdin.on("data", this.keyListener);
// Windows-specific signal handlers
if (process.platform === "win32") {
// Handle Ctrl+C gracefully on Windows
process.on("SIGINT", () => this.handleWindowsExit("SIGINT"));
// Handle Windows-specific break signal
process.on("SIGBREAK", () => this.handleWindowsExit("SIGBREAK"));
}
}
/**
* Stop listening for keyboard events
*/
stop() {
if (!this.isActive) return;
this.isActive = false;
if (this.keyListener) {
process.stdin.off("data", this.keyListener);
this.keyListener = null;
}
if (process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
}
/**
* Handle raw keyboard input
*/
handleKeyInput(data) {
if (!this.isActive) return;
// Parse key event
const keyEvent = this.parseKeyEvent(data);
if (!keyEvent) return;
// Apply debouncing
const debouncedEvent = this.keyDebouncer(keyEvent.input, keyEvent.key);
if (!debouncedEvent) return;
// Emit to listeners
this.emit("key", debouncedEvent.input, debouncedEvent.key);
}
/**
* Parse raw keyboard input into key events
*/
parseKeyEvent(data) {
const input = data.toString();
// Handle Windows-specific key sequences
if (this.capabilities.isWindowsTerminal && this.enableEnhancedKeys) {
return this.parseWindowsTerminalKeys(input);
} else if (this.capabilities.isPowerShell) {
return this.parsePowerShellKeys(input);
} else if (this.capabilities.isCommandPrompt) {
return this.parseCommandPromptKeys(input);
}
// Fallback to basic parsing
return this.parseBasicKeys(input);
}
/**
* Parse Windows Terminal enhanced key sequences
*/
parseWindowsTerminalKeys(input) {
const key = {};
// Enhanced key sequences
const enhancedSequences = {
"\x1b[1;5A": { name: "up", ctrl: true },
"\x1b[1;5B": { name: "down", ctrl: true },
"\x1b[1;5C": { name: "right", ctrl: true },
"\x1b[1;5D": { name: "left", ctrl: true },
"\x1b[1;2A": { name: "up", shift: true },
"\x1b[1;2B": { name: "down", shift: true },
"\x1b[1;2C": { name: "right", shift: true },
"\x1b[1;2D": { name: "left", shift: true },
"\x1b[1;3A": { name: "up", meta: true },
"\x1b[1;3B": { name: "down", meta: true },
"\x1b[1;3C": { name: "right", meta: true },
"\x1b[1;3D": { name: "left", meta: true },
};
if (enhancedSequences[input]) {
return {
input,
key: { ...enhancedSequences[input], sequence: input },
};
}
return this.parseBasicKeys(input);
}
/**
* Parse PowerShell key sequences
*/
parsePowerShellKeys(input) {
// PowerShell has good Unicode support but limited enhanced sequences
return this.parseBasicKeys(input);
}
/**
* Parse Command Prompt key sequences
*/
parseCommandPromptKeys(input) {
// Command Prompt has limited key sequence support
const key = {};
// Basic control sequences
switch (input) {
case "\x03": // Ctrl+C
key.name = "c";
key.ctrl = true;
break;
case "\x1a": // Ctrl+Z
key.name = "z";
key.ctrl = true;
break;
case "\x08": // Backspace
key.name = "backspace";
break;
case "\x7f": // Delete
key.name = "delete";
break;
case "\r": // Enter (Windows)
case "\r\n": // Enter (Windows with LF)
key.name = "return";
break;
case "\x1b": // Escape
key.name = "escape";
break;
default:
return this.parseBasicKeys(input);
}
return { input, key: { ...key, sequence: input } };
}
/**
* Parse basic key sequences (fallback)
*/
parseBasicKeys(input) {
const key = {};
// Single character keys
if (input.length === 1) {
const code = input.charCodeAt(0);
if (code >= 32 && code <= 126) {
// Printable ASCII
key.name = input.toLowerCase();
key.sequence = input;
} else if (code < 32) {
// Control characters
key.name = String.fromCharCode(code + 96); // Convert to letter
key.ctrl = true;
key.sequence = input;
}
}
// Arrow keys and function keys
if (input.startsWith("\x1b[")) {
const match = input.match(/^\x1b\[([ABCD])/);
if (match) {
const directions = { A: "up", B: "down", C: "right", D: "left" };
key.name = directions[match[1]];
key.sequence = input;
}
}
return { input, key };
}
/**
* Handle Windows-specific exit signals
*/
handleWindowsExit(signal) {
this.emit("exit", signal);
// Graceful cleanup
this.stop();
// Allow other handlers to run
process.nextTick(() => {
process.exit(0);
});
}
/**
* Add event listener
*/
on(event, listener) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(listener);
}
/**
* Remove event listener
*/
off(event, listener) {
if (!this.listeners.has(event)) return;
const listeners = this.listeners.get(event);
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
/**
* Emit event to listeners
*/
emit(event, ...args) {
if (!this.listeners.has(event)) return;
this.listeners.get(event).forEach((listener) => {
try {
listener(...args);
} catch (error) {
console.error("Error in keyboard event listener:", error);
}
});
}
/**
* Get current keyboard handler statistics
*/
getStats() {
return {
isActive: this.isActive,
capabilities: this.capabilities,
debounceDelay: this.debounceDelay,
enableEnhancedKeys: this.enableEnhancedKeys,
listenerCount: Array.from(this.listeners.values()).reduce(
(sum, arr) => sum + arr.length,
0
),
};
}
}
/**
* Create optimized keyboard handler for Windows
*/
function createWindowsKeyboardHandler(options = {}) {
return new WindowsKeyboardHandler(options);
}
/**
* Windows-specific keyboard utilities
*/
const WindowsKeyboardUtils = {
/**
* Check if a key combination is a Windows system shortcut
*/
isSystemShortcut: (input, key) => {
if (!key) return false;
// Common Windows system shortcuts to avoid intercepting
const systemShortcuts = [
{ ctrl: true, name: "c" }, // Copy (but we might want to handle this)
{ ctrl: true, name: "v" }, // Paste
{ ctrl: true, name: "x" }, // Cut
{ ctrl: true, name: "z" }, // Undo
{ ctrl: true, name: "y" }, // Redo
{ ctrl: true, name: "a" }, // Select All
{ ctrl: true, name: "s" }, // Save
{ ctrl: true, name: "o" }, // Open
{ ctrl: true, name: "n" }, // New
{ ctrl: true, name: "w" }, // Close Window
{ ctrl: true, name: "q" }, // Quit
{ meta: true, name: "tab" }, // Alt+Tab
{ meta: true, name: "f4" }, // Alt+F4
];
return systemShortcuts.some(
(shortcut) =>
shortcut.ctrl === key.ctrl &&
shortcut.meta === key.meta &&
shortcut.shift === key.shift &&
shortcut.name === key.name
);
},
/**
* Get Windows-friendly key description
*/
getKeyDescription: (input, key) => {
if (!key) return input;
const parts = [];
if (key.ctrl) parts.push("Ctrl");
if (key.meta) parts.push("Alt");
if (key.shift) parts.push("Shift");
if (key.name) {
const name = key.name.charAt(0).toUpperCase() + key.name.slice(1);
parts.push(name);
}
return parts.join("+") || input;
},
};
module.exports = {
WindowsKeyboardHandler,
createWindowsKeyboardHandler,
WindowsKeyboardUtils,
};

View File

@@ -0,0 +1,365 @@
/**
* Windows-Specific Performance Optimizations
* Provides optimizations specifically for Windows terminal environments
* Requirements: 1.5, 4.4
*/
const { getWindowsTerminalCapabilities } = require("./modernTerminal.js");
/**
* Windows Terminal Rendering Optimizations
*/
const WindowsRenderingOptimizations = {
/**
* Cache for terminal capabilities to avoid repeated detection
*/
_capabilitiesCache: null,
_cacheTimestamp: null,
_cacheTimeout: 5000, // 5 seconds
/**
* Get cached terminal capabilities or detect new ones
*/
getCachedCapabilities: () => {
const now = Date.now();
// Return cached capabilities if still valid
if (
WindowsRenderingOptimizations._capabilitiesCache &&
WindowsRenderingOptimizations._cacheTimestamp &&
now - WindowsRenderingOptimizations._cacheTimestamp <
WindowsRenderingOptimizations._cacheTimeout
) {
return WindowsRenderingOptimizations._capabilitiesCache;
}
// Detect and cache new capabilities
const capabilities = getWindowsTerminalCapabilities();
WindowsRenderingOptimizations._capabilitiesCache = capabilities;
WindowsRenderingOptimizations._cacheTimestamp = now;
return capabilities;
},
/**
* Clear capabilities cache (useful for testing or environment changes)
*/
clearCache: () => {
WindowsRenderingOptimizations._capabilitiesCache = null;
WindowsRenderingOptimizations._cacheTimestamp = null;
},
/**
* Get optimized character set for Windows terminals
*/
getOptimizedCharacterSet: () => {
const capabilities = WindowsRenderingOptimizations.getCachedCapabilities();
if (capabilities.isCommandPrompt) {
// Command Prompt - use ASCII fallbacks for best performance
return {
progress: {
filled: "#",
empty: "-",
partial: "=",
},
status: {
success: "v",
error: "x",
warning: "!",
info: "i",
active: "*",
},
navigation: {
arrow: ">",
bullet: "*",
selected: ">",
},
borders: {
horizontal: "-",
vertical: "|",
corner: "+",
},
};
} else if (capabilities.isPowerShell) {
// PowerShell - use Unicode but avoid complex characters
return {
progress: {
filled: "█",
empty: "░",
partial: "▌",
},
status: {
success: "✓",
error: "✗",
warning: "⚠",
info: "",
active: "●",
},
navigation: {
arrow: "►",
bullet: "•",
selected: "►",
},
borders: {
horizontal: "─",
vertical: "│",
corner: "┌",
},
};
} else {
// Windows Terminal - full Unicode support
return {
progress: {
filled: "█",
empty: "░",
partial: ["▏", "▎", "▍", "▌", "▋", "▊", "▉"],
},
status: {
success: "✅",
error: "❌",
warning: "⚠️",
info: "",
active: "🔵",
},
navigation: {
arrow: "▶️",
bullet: "•",
selected: "👉",
},
borders: {
horizontal: "─",
vertical: "│",
corner: "╭",
},
};
}
},
/**
* Optimize string rendering for Windows terminals
*/
optimizeString: (text, maxLength = null) => {
const capabilities = WindowsRenderingOptimizations.getCachedCapabilities();
// For Command Prompt, avoid complex Unicode that might cause rendering issues
if (capabilities.isCommandPrompt) {
// Replace problematic Unicode characters with ASCII equivalents
text = text
.replace(/[─━]/g, "-")
.replace(/[│┃]/g, "|")
.replace(/[┌┏╭]/g, "+")
.replace(/[┐┓╮]/g, "+")
.replace(/[└┗╰]/g, "+")
.replace(/[┘┛╯]/g, "+")
.replace(/[█▉▊▋▌▍▎▏]/g, "#")
.replace(/[░▒▓]/g, "-")
.replace(/[●○]/g, "*")
.replace(/[►▶]/g, ">")
.replace(/[✓✔]/g, "v")
.replace(/[✗✖]/g, "x");
}
// Truncate if needed
if (maxLength && text.length > maxLength) {
const ellipsis = capabilities.supportsUnicode ? "…" : "...";
text = text.substring(0, maxLength - ellipsis.length) + ellipsis;
}
return text;
},
/**
* Get optimal update frequency for Windows terminals
*/
getOptimalUpdateFrequency: () => {
const capabilities = WindowsRenderingOptimizations.getCachedCapabilities();
if (capabilities.isCommandPrompt) {
return 250; // 4 FPS for Command Prompt
} else if (capabilities.isPowerShell) {
return 100; // 10 FPS for PowerShell
} else {
return 50; // 20 FPS for Windows Terminal
}
},
};
/**
* Windows Keyboard Event Optimizations
*/
const WindowsKeyboardOptimizations = {
/**
* Normalize Windows keyboard events
*/
normalizeKeyEvent: (input, key) => {
// Windows-specific key mappings
const windowsKeyMappings = {
// Windows line endings
"\r\n": { name: "return", ctrl: false, meta: false, shift: false },
"\r": { name: "return", ctrl: false, meta: false, shift: false },
// Windows control sequences
"\x03": { name: "c", ctrl: true, meta: false, shift: false }, // Ctrl+C
"\x1a": { name: "z", ctrl: true, meta: false, shift: false }, // Ctrl+Z
"\x08": { name: "backspace", ctrl: false, meta: false, shift: false },
"\x7f": { name: "delete", ctrl: false, meta: false, shift: false },
// Windows Terminal enhanced sequences
"\x1b[1;5A": { name: "up", ctrl: true, meta: false, shift: false },
"\x1b[1;5B": { name: "down", ctrl: true, meta: false, shift: false },
"\x1b[1;5C": { name: "right", ctrl: true, meta: false, shift: false },
"\x1b[1;5D": { name: "left", ctrl: true, meta: false, shift: false },
"\x1b[1;2A": { name: "up", ctrl: false, meta: false, shift: true },
"\x1b[1;2B": { name: "down", ctrl: false, meta: false, shift: true },
};
// Check for Windows-specific mappings first
if (windowsKeyMappings[input]) {
return {
input,
key: windowsKeyMappings[input],
};
}
// If no key provided and no mapping found, return null key
if (!key) {
return { input, key: null };
}
return { input, key };
},
/**
* Debounce rapid key events (common in Windows terminals)
*/
createKeyDebouncer: (delay = 50) => {
let lastKeyTime = 0;
let lastKey = null;
return (input, key) => {
const now = Date.now();
// If same key pressed within delay, ignore
if (input === lastKey && now - lastKeyTime < delay) {
return null;
}
lastKey = input;
lastKeyTime = now;
return WindowsKeyboardOptimizations.normalizeKeyEvent(input, key);
};
},
};
/**
* Windows File System Optimizations
*/
const WindowsFileSystemOptimizations = {
/**
* Normalize Windows file paths for cross-platform compatibility
*/
normalizePath: (path) => {
if (typeof path !== "string") return path;
// Convert backslashes to forward slashes
let normalized = path.replace(/\\/g, "/");
// Handle UNC paths
if (normalized.startsWith("//")) {
return normalized;
}
// Handle drive letters
if (normalized.match(/^[A-Za-z]:/)) {
return normalized;
}
return normalized;
},
/**
* Get Windows-appropriate temporary directory
*/
getTempDirectory: () => {
return (
process.env.TEMP ||
process.env.TMP ||
process.env.LOCALAPPDATA + "\\Temp" ||
"C:\\Windows\\Temp"
);
},
/**
* Get Windows user directories
*/
getUserDirectories: () => {
return {
home: process.env.USERPROFILE || process.env.HOME,
documents: process.env.USERPROFILE + "\\Documents",
desktop: process.env.USERPROFILE + "\\Desktop",
appData: process.env.APPDATA,
localAppData: process.env.LOCALAPPDATA,
temp: WindowsFileSystemOptimizations.getTempDirectory(),
};
},
};
/**
* Windows Performance Monitoring
*/
const WindowsPerformanceMonitor = {
/**
* Monitor rendering performance
*/
createRenderingMonitor: () => {
let frameCount = 0;
let startTime = Date.now();
let lastFrameTime = startTime;
return {
startFrame: () => {
lastFrameTime = Date.now();
},
endFrame: () => {
frameCount++;
const now = Date.now();
const frameTime = now - lastFrameTime;
return {
frameTime,
fps: frameCount / ((now - startTime) / 1000),
totalFrames: frameCount,
};
},
reset: () => {
frameCount = 0;
startTime = Date.now();
lastFrameTime = startTime;
},
};
},
/**
* Monitor memory usage
*/
getMemoryUsage: () => {
const usage = process.memoryUsage();
return {
heapUsed: Math.round((usage.heapUsed / 1024 / 1024) * 100) / 100, // MB
heapTotal: Math.round((usage.heapTotal / 1024 / 1024) * 100) / 100, // MB
external: Math.round((usage.external / 1024 / 1024) * 100) / 100, // MB
rss: Math.round((usage.rss / 1024 / 1024) * 100) / 100, // MB
};
},
};
module.exports = {
WindowsRenderingOptimizations,
WindowsKeyboardOptimizations,
WindowsFileSystemOptimizations,
WindowsPerformanceMonitor,
};