lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
246 lines (245 loc) • 7.58 kB
JavaScript
/**
* Command History System Implementation
* Provides ZSH-compatible history functionality
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export class HistorySystem {
entries = [];
currentIndex = -1;
config;
isEnabled = true;
constructor(config) {
this.config = {
maxSize: 10000,
filePath: path.join(os.homedir(), '.lsh_history'),
shareHistory: false,
ignoreDups: true,
ignoreSpace: false,
expireDuplicatesFirst: true,
...config,
};
this.loadHistory();
}
/**
* Add a command to history
*/
addCommand(command, exitCode) {
if (!this.isEnabled)
return;
// Skip empty commands
if (!command.trim())
return;
// Skip commands starting with space if ignoreSpace is enabled
if (this.config.ignoreSpace && command.startsWith(' '))
return;
// Remove duplicates if configured
if (this.config.ignoreDups) {
this.removeDuplicateCommand(command);
}
const entry = {
lineNumber: this.entries.length + 1,
command: command.trim(),
timestamp: Date.now(),
exitCode,
};
this.entries.push(entry);
// Trim history if it exceeds max size
if (this.entries.length > this.config.maxSize) {
this.entries = this.entries.slice(-this.config.maxSize);
this.renumberEntries();
}
this.currentIndex = this.entries.length - 1;
this.saveHistory();
}
/**
* Get history entry by line number
*/
getEntry(lineNumber) {
return this.entries.find(entry => entry.lineNumber === lineNumber);
}
/**
* Get history entry by command prefix
*/
getEntryByPrefix(prefix) {
return this.entries
.slice()
.reverse()
.find(entry => entry.command.startsWith(prefix));
}
/**
* Get all history entries
*/
getAllEntries() {
return [...this.entries];
}
/**
* Search history for commands matching pattern
*/
searchHistory(pattern) {
const regex = new RegExp(pattern, 'i');
return this.entries.filter(entry => regex.test(entry.command));
}
/**
* Get previous command in history
*/
getPreviousCommand() {
if (this.currentIndex > 0) {
this.currentIndex--;
return this.entries[this.currentIndex].command;
}
return null;
}
/**
* Get next command in history
*/
getNextCommand() {
if (this.currentIndex < this.entries.length - 1) {
this.currentIndex++;
return this.entries[this.currentIndex].command;
}
return null;
}
/**
* Reset history navigation index
*/
resetIndex() {
this.currentIndex = this.entries.length - 1;
}
/**
* Expand history references like !! !n !string
*/
expandHistory(command) {
let result = command;
// Handle !! (last command)
result = result.replace(/!!/g, () => {
const lastEntry = this.entries[this.entries.length - 1];
return lastEntry ? lastEntry.command : '!!';
});
// Handle !n (command number n)
result = result.replace(/!(\d+)/g, (match, numStr) => {
const num = parseInt(numStr, 10);
const entry = this.getEntry(num);
return entry ? entry.command : match;
});
// Handle !string (last command starting with string)
result = result.replace(/!([a-zA-Z0-9_]+)/g, (match, prefix) => {
const entry = this.getEntryByPrefix(prefix);
return entry ? entry.command : match;
});
// Handle ^old^new (quick substitution)
result = result.replace(/\^([^^]+)\^([^^]*)/g, (match, old, replacement) => {
const lastEntry = this.entries[this.entries.length - 1];
if (lastEntry) {
return lastEntry.command.replace(new RegExp(old, 'g'), replacement);
}
return match;
});
return result;
}
/**
* Clear history
*/
clearHistory() {
this.entries = [];
this.currentIndex = -1;
this.saveHistory();
}
/**
* Get history statistics
*/
getStats() {
const unique = new Set(this.entries.map(e => e.command)).size;
const oldest = this.entries.length > 0 ? new Date(this.entries[0].timestamp) : null;
const newest = this.entries.length > 0 ? new Date(this.entries[this.entries.length - 1].timestamp) : null;
return {
total: this.entries.length,
unique,
oldest,
newest,
};
}
/**
* Enable/disable history
*/
setEnabled(enabled) {
this.isEnabled = enabled;
}
/**
* Update configuration
*/
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
}
/**
* Load history from file
*/
loadHistory() {
try {
if (fs.existsSync(this.config.filePath)) {
const content = fs.readFileSync(this.config.filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
this.entries = lines.map((line, index) => {
// Parse history line format: timestamp:command
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const timestamp = parseInt(line.substring(0, colonIndex), 10);
const command = line.substring(colonIndex + 1);
return {
lineNumber: index + 1,
command,
timestamp: isNaN(timestamp) ? Date.now() : timestamp,
};
}
else {
return {
lineNumber: index + 1,
command: line,
timestamp: Date.now(),
};
}
});
}
}
catch (_error) {
// If loading fails, start with empty history
this.entries = [];
}
}
/**
* Save history to file
*/
saveHistory() {
try {
const content = this.entries
.map(entry => `${entry.timestamp}:${entry.command}`)
.join('\n');
// Ensure directory exists
const dir = path.dirname(this.config.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.config.filePath, content, 'utf8');
}
catch (_error) {
// Silently fail if we can't save history
}
}
/**
* Remove duplicate command from history
*/
removeDuplicateCommand(command) {
const trimmedCommand = command.trim();
this.entries = this.entries.filter(entry => entry.command !== trimmedCommand);
}
/**
* Renumber entries after trimming
*/
renumberEntries() {
this.entries.forEach((entry, index) => {
entry.lineNumber = index + 1;
});
}
}
export default HistorySystem;