Just a whole lot of crap
This commit is contained in:
495
src/services/LogService.js
Normal file
495
src/services/LogService.js
Normal 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;
|
||||
396
src/services/TagAnalysisService.js
Normal file
396
src/services/TagAnalysisService.js
Normal 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
501
src/services/logReader.js
Normal 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;
|
||||
345
src/services/scheduleManagement.js
Normal file
345
src/services/scheduleManagement.js
Normal 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
509
src/services/tagAnalysis.js
Normal 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;
|
||||
973
src/tui-entry.js
973
src/tui-entry.js
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? "..." : "")
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
229
src/tui/components/common/ErrorBoundary.jsx
Normal file
229
src/tui/components/common/ErrorBoundary.jsx
Normal 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;
|
||||
118
src/tui/components/common/ErrorDisplay.jsx
Normal file
118
src/tui/components/common/ErrorDisplay.jsx
Normal 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;
|
||||
223
src/tui/components/common/FocusIndicator.jsx
Normal file
223
src/tui/components/common/FocusIndicator.jsx
Normal 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,
|
||||
};
|
||||
324
src/tui/components/common/FormInput.jsx
Normal file
324
src/tui/components/common/FormInput.jsx
Normal 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 };
|
||||
145
src/tui/components/common/HelpOverlay.jsx
Normal file
145
src/tui/components/common/HelpOverlay.jsx
Normal 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;
|
||||
141
src/tui/components/common/InputField.jsx
Normal file
141
src/tui/components/common/InputField.jsx
Normal 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;
|
||||
125
src/tui/components/common/LoadingIndicator.jsx
Normal file
125
src/tui/components/common/LoadingIndicator.jsx
Normal 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 };
|
||||
369
src/tui/components/common/MemoryOptimizedComponent.jsx
Normal file
369
src/tui/components/common/MemoryOptimizedComponent.jsx
Normal 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,
|
||||
};
|
||||
241
src/tui/components/common/MenuList.jsx
Normal file
241
src/tui/components/common/MenuList.jsx
Normal 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;
|
||||
60
src/tui/components/common/MinimumSizeWarning.jsx
Normal file
60
src/tui/components/common/MinimumSizeWarning.jsx
Normal 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;
|
||||
422
src/tui/components/common/ModernInteractiveBox.jsx
Normal file
422
src/tui/components/common/ModernInteractiveBox.jsx
Normal 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,
|
||||
};
|
||||
253
src/tui/components/common/ModernProgressBar.jsx
Normal file
253
src/tui/components/common/ModernProgressBar.jsx
Normal 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,
|
||||
};
|
||||
410
src/tui/components/common/ModernStatusIndicator.jsx
Normal file
410
src/tui/components/common/ModernStatusIndicator.jsx
Normal 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,
|
||||
};
|
||||
325
src/tui/components/common/OptimizedMenuList.jsx
Normal file
325
src/tui/components/common/OptimizedMenuList.jsx
Normal 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;
|
||||
261
src/tui/components/common/OptimizedProgressBar.jsx
Normal file
261
src/tui/components/common/OptimizedProgressBar.jsx
Normal 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;
|
||||
203
src/tui/components/common/Pagination.jsx
Normal file
203
src/tui/components/common/Pagination.jsx
Normal 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 };
|
||||
47
src/tui/components/common/ResponsiveContainer.jsx
Normal file
47
src/tui/components/common/ResponsiveContainer.jsx
Normal 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;
|
||||
54
src/tui/components/common/ResponsiveGrid.jsx
Normal file
54
src/tui/components/common/ResponsiveGrid.jsx
Normal 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;
|
||||
50
src/tui/components/common/ResponsiveText.jsx
Normal file
50
src/tui/components/common/ResponsiveText.jsx
Normal 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;
|
||||
107
src/tui/components/common/ScrollableContainer.jsx
Normal file
107
src/tui/components/common/ScrollableContainer.jsx
Normal 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;
|
||||
312
src/tui/components/common/VirtualScrollableContainer.jsx
Normal file
312
src/tui/components/common/VirtualScrollableContainer.jsx
Normal 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;
|
||||
15
src/tui/components/common/index.js
Normal file
15
src/tui/components/common/index.js
Normal 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
@@ -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()}`
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
582
src/tui/components/screens/OptimizedLogViewerScreen.jsx
Normal file
582
src/tui/components/screens/OptimizedLogViewerScreen.jsx
Normal 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
@@ -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")
|
||||
),
|
||||
|
||||
525
src/tui/components/screens/ViewLogsScreen.jsx
Normal file
525
src/tui/components/screens/ViewLogsScreen.jsx
Normal 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;
|
||||
259
src/tui/hooks/useAccessibility.js
Normal file
259
src/tui/hooks/useAccessibility.js
Normal 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;
|
||||
32
src/tui/hooks/useAppState.js
Normal file
32
src/tui/hooks/useAppState.js
Normal 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
71
src/tui/hooks/useHelp.js
Normal 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;
|
||||
436
src/tui/hooks/useMemoryManagement.js
Normal file
436
src/tui/hooks/useMemoryManagement.js
Normal 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,
|
||||
};
|
||||
382
src/tui/hooks/useModernTerminal.js
Normal file
382
src/tui/hooks/useModernTerminal.js
Normal 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;
|
||||
44
src/tui/hooks/useNavigation.js
Normal file
44
src/tui/hooks/useNavigation.js
Normal 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;
|
||||
460
src/tui/hooks/useServices.js
Normal file
460
src/tui/hooks/useServices.js
Normal 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;
|
||||
98
src/tui/hooks/useTerminalSize.js
Normal file
98
src/tui/hooks/useTerminalSize.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
38
src/tui/providers/ServiceProvider.jsx
Normal file
38
src/tui/providers/ServiceProvider.jsx
Normal 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;
|
||||
540
src/tui/services/LogService.js
Normal file
540
src/tui/services/LogService.js
Normal 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;
|
||||
318
src/tui/services/ScheduleService.js
Normal file
318
src/tui/services/ScheduleService.js
Normal 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;
|
||||
524
src/tui/services/TagAnalysisService.js
Normal file
524
src/tui/services/TagAnalysisService.js
Normal 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;
|
||||
320
src/tui/utils/accessibility.js
Normal file
320
src/tui/utils/accessibility.js
Normal 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,
|
||||
};
|
||||
199
src/tui/utils/keyboardHandlers.js
Normal file
199
src/tui/utils/keyboardHandlers.js
Normal 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,
|
||||
};
|
||||
549
src/tui/utils/memoryLeakDetector.js
Normal file
549
src/tui/utils/memoryLeakDetector.js
Normal 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,
|
||||
};
|
||||
716
src/tui/utils/modernTerminal.js
Normal file
716
src/tui/utils/modernTerminal.js
Normal 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,
|
||||
};
|
||||
477
src/tui/utils/performanceUtils.js
Normal file
477
src/tui/utils/performanceUtils.js
Normal 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,
|
||||
};
|
||||
172
src/tui/utils/responsiveLayout.js
Normal file
172
src/tui/utils/responsiveLayout.js
Normal 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,
|
||||
};
|
||||
362
src/tui/utils/windowsKeyboardHandlers.js
Normal file
362
src/tui/utils/windowsKeyboardHandlers.js
Normal 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,
|
||||
};
|
||||
365
src/tui/utils/windowsOptimizations.js
Normal file
365
src/tui/utils/windowsOptimizations.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user