mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
Add the Global history clear-all action with confirmation and five-language copy. Clear local shell history, autocomplete and compose recall while preserving drafts, keeping peer windows consistent on clears, and allowing new commands afterwards. Verified with 116 focused checks, two clean independent final reviews, real Electron multi-window SSH-fixture interaction and restart, current-head Codex review, and both required CI jobs. Co-authored-by: fyhax1ki <fyhax1ki@gmail.com>
582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
/**
|
|
* Persistent command history store for terminal autocomplete.
|
|
* Stores commands per host with frequency tracking and timestamp ordering.
|
|
* Uses localStorageAdapter as the persistence layer (works in renderer process).
|
|
*
|
|
* Lives in application state rather than the UI layer so the vault hook and the
|
|
* terminal UI depend on one lower-layer API, and so every renderer (main window
|
|
* and detached terminal popups) shares the same clear semantics.
|
|
*/
|
|
|
|
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
|
import {
|
|
STORAGE_KEY_COMMAND_HISTORY,
|
|
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
|
} from "../../infrastructure/config/storageKeys";
|
|
|
|
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
|
const MAX_ENTRIES = 10000;
|
|
const MAX_ENTRIES_PER_HOST = 5000;
|
|
|
|
export interface HistoryEntry {
|
|
command: string;
|
|
hostId: string;
|
|
/** OS type for cross-host matching */
|
|
os: "linux" | "windows" | "macos";
|
|
/** Number of times this exact command was executed */
|
|
frequency: number;
|
|
/** Timestamp of last execution */
|
|
lastUsedAt: number;
|
|
/** Timestamp of first execution */
|
|
createdAt: number;
|
|
}
|
|
|
|
interface HistoryStore {
|
|
entries: HistoryEntry[];
|
|
version: number;
|
|
/**
|
|
* Monotonic counter bumped by every global clear. Other renderers compare it
|
|
* to detect a clear performed elsewhere, and in-flight histfile imports use
|
|
* it to discard work that started before the clear.
|
|
*/
|
|
clearGeneration?: number;
|
|
}
|
|
|
|
let cachedStore: HistoryStore | null = null;
|
|
/** Last clear generation this renderer has observed. */
|
|
let knownClearGeneration: number | null = null;
|
|
|
|
function readClearGeneration(store: HistoryStore): number {
|
|
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
|
|
}
|
|
|
|
function loadStore(): HistoryStore {
|
|
if (cachedStore) return cachedStore;
|
|
try {
|
|
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
|
if (parsed) {
|
|
cachedStore = parsed;
|
|
knownClearGeneration = readClearGeneration(parsed);
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// Corrupted data, reset
|
|
}
|
|
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
|
|
knownClearGeneration = 0;
|
|
return cachedStore;
|
|
}
|
|
|
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function persistStoreNow(store: HistoryStore): boolean {
|
|
// A peer clear is visible in storage before its queued event is handled.
|
|
// Never flush a cached pre-clear snapshot over that newer empty store.
|
|
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
|
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
|
|
cachedStore = persisted;
|
|
knownClearGeneration = readClearGeneration(persisted);
|
|
notifyCommandHistoryCleared();
|
|
return true;
|
|
}
|
|
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
|
if (ok) return true;
|
|
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
|
const now = Date.now();
|
|
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
|
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
|
return localStorageAdapter.write(STORAGE_KEY, store);
|
|
}
|
|
|
|
function saveStore(store: HistoryStore): void {
|
|
cachedStore = store;
|
|
// Debounce saves to avoid excessive writes
|
|
if (saveTimer) clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => {
|
|
persistStoreNow(store);
|
|
saveTimer = null;
|
|
}, 500);
|
|
}
|
|
|
|
function cancelPendingSave(): void {
|
|
if (saveTimer) {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush any pending debounced history write immediately.
|
|
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
|
|
* flag is not persisted before the imported commands land in storage.
|
|
* Returns false when the write could not be persisted.
|
|
*/
|
|
export function flushCommandHistoryStore(): boolean {
|
|
if (!cachedStore) return true;
|
|
cancelPendingSave();
|
|
return persistStoreNow(cachedStore);
|
|
}
|
|
|
|
/**
|
|
* React to a write performed by another renderer (main window or a detached
|
|
* terminal popup).
|
|
*
|
|
* A bumped clear generation drops the cache and pending save, then notifies
|
|
* live autocomplete controllers. Ordinary peer writes must not cancel a local
|
|
* command that is still waiting for its debounced save.
|
|
*/
|
|
function applyExternalCommandHistoryWrite(
|
|
rawNewValue: string | null,
|
|
rawOldValue: string | null,
|
|
): void {
|
|
let incomingGeneration: number | null = null;
|
|
if (rawNewValue) {
|
|
try {
|
|
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
|
|
} catch {
|
|
incomingGeneration = null;
|
|
}
|
|
}
|
|
|
|
let previousGeneration = knownClearGeneration;
|
|
if (previousGeneration === null) {
|
|
// A fresh popup can have compose recall before its first autocomplete query.
|
|
// The event's old value is its baseline, even after an earlier global clear.
|
|
try {
|
|
previousGeneration = rawOldValue
|
|
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
|
|
: 0;
|
|
} catch {
|
|
previousGeneration = 0;
|
|
}
|
|
}
|
|
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
|
|
cancelPendingSave();
|
|
cachedStore = null;
|
|
knownClearGeneration = incomingGeneration;
|
|
notifyCommandHistoryCleared();
|
|
} else {
|
|
if (!saveTimer) cachedStore = null;
|
|
if (incomingGeneration !== null && knownClearGeneration === null) {
|
|
knownClearGeneration = incomingGeneration;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The native `storage` event fires in *other* same-origin windows (main window
|
|
// and detached terminal popups) when a key changes there.
|
|
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
window.addEventListener("storage", (event) => {
|
|
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Current clear generation. Callers capture this before async work and compare
|
|
* afterwards to detect a clear that happened while they were waiting.
|
|
*/
|
|
export function getCommandHistoryClearGeneration(): number {
|
|
if (knownClearGeneration === null) loadStore();
|
|
return knownClearGeneration ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Record that the user explicitly cleared all command history.
|
|
*
|
|
* Histfile seeding honors this tombstone: a clear is a user decision to delete
|
|
* locally recorded history, so a later first-time import must not restore the
|
|
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
|
|
* clear that happened before the import started).
|
|
*/
|
|
export function markCommandHistoryCleared(): void {
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
|
|
}
|
|
|
|
/** True once the user has cleared all command history at least once. */
|
|
export function hasCommandHistoryBeenCleared(): boolean {
|
|
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
|
|
}
|
|
|
|
type CommandHistoryClearedListener = () => void;
|
|
const clearedListeners = new Set<CommandHistoryClearedListener>();
|
|
|
|
/**
|
|
* Subscribe to command-history clears, including clears performed in another
|
|
* renderer. Live autocomplete controllers use this to drop visible suggestions
|
|
* and ghost text and abort in-flight completions, so a stale row cannot be
|
|
* accepted and recorded after the history was cleared.
|
|
*/
|
|
export function subscribeCommandHistoryCleared(
|
|
listener: CommandHistoryClearedListener,
|
|
): () => void {
|
|
clearedListeners.add(listener);
|
|
return () => {
|
|
clearedListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
function notifyCommandHistoryCleared(): void {
|
|
for (const listener of [...clearedListeners]) listener();
|
|
}
|
|
|
|
/**
|
|
* Record a command execution. Updates frequency if the command already exists
|
|
* for this host, otherwise creates a new entry.
|
|
*/
|
|
export function recordCommand(
|
|
command: string,
|
|
hostId: string,
|
|
os: "linux" | "windows" | "macos" = "linux",
|
|
): void {
|
|
const trimmed = command.trim();
|
|
if (!trimmed || trimmed.length > 2000) return;
|
|
|
|
const store = loadStore();
|
|
const now = Date.now();
|
|
|
|
// Find existing entry for same command + host
|
|
const existingIdx = store.entries.findIndex(
|
|
(e) => e.command === trimmed && e.hostId === hostId,
|
|
);
|
|
|
|
if (existingIdx >= 0) {
|
|
store.entries[existingIdx].frequency++;
|
|
store.entries[existingIdx].lastUsedAt = now;
|
|
} else {
|
|
store.entries.push({
|
|
command: trimmed,
|
|
hostId,
|
|
os,
|
|
frequency: 1,
|
|
lastUsedAt: now,
|
|
createdAt: now,
|
|
});
|
|
}
|
|
|
|
// Enforce per-host limit (evict by score, not insertion order)
|
|
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
|
|
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
|
|
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
|
|
const toRemove = new Set(
|
|
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
|
|
);
|
|
store.entries = store.entries.filter(
|
|
(e) => e.hostId !== hostId || !toRemove.has(e.command),
|
|
);
|
|
}
|
|
|
|
// Enforce global limit
|
|
if (store.entries.length > MAX_ENTRIES) {
|
|
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
|
store.entries = store.entries.slice(0, MAX_ENTRIES);
|
|
}
|
|
|
|
saveStore(store);
|
|
}
|
|
|
|
/** Remove one command from autocomplete history for a specific host. */
|
|
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) return false;
|
|
|
|
const store = loadStore();
|
|
const nextEntries = store.entries.filter(
|
|
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
|
|
);
|
|
if (nextEntries.length === store.entries.length) return false;
|
|
|
|
store.entries = nextEntries;
|
|
cancelPendingSave();
|
|
return persistStoreNow(store);
|
|
}
|
|
|
|
/**
|
|
* Score an entry for ranking at a specific timestamp.
|
|
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
|
|
*/
|
|
function scoreEntryAt(entry: HistoryEntry, now: number): number {
|
|
const ageMs = now - entry.lastUsedAt;
|
|
const ageHours = ageMs / (1000 * 60 * 60);
|
|
// Exponential decay: halve relevance every 24 hours
|
|
const recencyScore = Math.pow(0.5, ageHours / 24);
|
|
return entry.frequency * recencyScore;
|
|
}
|
|
|
|
export interface HistoryQueryOptions {
|
|
/** Filter by host ID (strict isolation — only this host's history) */
|
|
hostId?: string;
|
|
/** Maximum number of results */
|
|
limit?: number;
|
|
}
|
|
|
|
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
|
/** Base command name, e.g. `cd` or `ls` */
|
|
commandName: string;
|
|
/** Exact command text to exclude from results */
|
|
excludeCommand?: string;
|
|
/** Optional path prefix to require on the current argument */
|
|
argumentPrefix?: string;
|
|
}
|
|
|
|
/**
|
|
* Query history entries matching a prefix.
|
|
* Returns entries sorted by relevance (frequency * recency).
|
|
*/
|
|
export function queryHistory(
|
|
prefix: string,
|
|
options: HistoryQueryOptions = {},
|
|
): HistoryEntry[] {
|
|
const { hostId, limit = 20 } = options;
|
|
if (limit <= 0) return [];
|
|
const store = loadStore();
|
|
const lowerPrefix = prefix.toLowerCase();
|
|
const now = Date.now(); // Cache once per query
|
|
|
|
const filtered = store.entries.filter((entry) => {
|
|
// Must match prefix
|
|
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
|
// Must not be identical to prefix
|
|
if (entry.command === prefix) return false;
|
|
|
|
// Host filtering: strict per-host isolation
|
|
if (hostId) {
|
|
return entry.hostId === hostId;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Sort by score (frequency * recency)
|
|
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
|
|
|
// Deduplicate by command text (keep highest scored)
|
|
const seen = new Set<string>();
|
|
const results: HistoryEntry[] = [];
|
|
for (const entry of filtered) {
|
|
if (seen.has(entry.command)) continue;
|
|
seen.add(entry.command);
|
|
results.push(entry);
|
|
if (results.length >= limit) break;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Fuzzy query: matches commands containing all characters of the query
|
|
* in order (not necessarily contiguous). Used as a fallback when prefix
|
|
* matching yields few results.
|
|
*/
|
|
export function fuzzyQueryHistory(
|
|
query: string,
|
|
options: HistoryQueryOptions = {},
|
|
): HistoryEntry[] {
|
|
const { hostId, limit = 10 } = options;
|
|
if (limit <= 0) return [];
|
|
const store = loadStore();
|
|
const lowerQuery = query.toLowerCase();
|
|
const now = Date.now(); // Cache once per query
|
|
|
|
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
|
|
|
for (const entry of store.entries) {
|
|
// Host filtering
|
|
if (hostId) {
|
|
if (entry.hostId !== hostId) continue;
|
|
}
|
|
|
|
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
|
if (matchScore > 0 && entry.command !== query) {
|
|
scored.push({ entry, matchScore });
|
|
}
|
|
}
|
|
|
|
scored.sort((a, b) =>
|
|
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
|
);
|
|
|
|
const seen = new Set<string>();
|
|
const results: HistoryEntry[] = [];
|
|
for (const { entry } of scored) {
|
|
if (seen.has(entry.command)) continue;
|
|
seen.add(entry.command);
|
|
results.push(entry);
|
|
if (results.length >= limit) break;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Query the most recently used history entries for the same command name.
|
|
* Useful when the user is currently completing a path argument and wants
|
|
* a few recent command-line examples (e.g. recent `cd ...` commands).
|
|
*/
|
|
export function queryRecentHistoryByCommand(
|
|
options: RecentHistoryQueryOptions,
|
|
): HistoryEntry[] {
|
|
const {
|
|
commandName,
|
|
excludeCommand,
|
|
argumentPrefix,
|
|
hostId,
|
|
limit = 3,
|
|
} = options;
|
|
if (!commandName || limit <= 0) return [];
|
|
|
|
const store = loadStore();
|
|
const trimmedCommandName = commandName.trim().toLowerCase();
|
|
const commandPrefix = `${trimmedCommandName} `;
|
|
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
|
|
|
const filtered = store.entries.filter((entry) => {
|
|
const lowerCommand = entry.command.toLowerCase();
|
|
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
|
return false;
|
|
}
|
|
if (excludeCommand && entry.command === excludeCommand) return false;
|
|
|
|
if (normalizedArgumentPrefix) {
|
|
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
|
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (hostId) {
|
|
return entry.hostId === hostId;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
|
|
const seen = new Set<string>();
|
|
const results: HistoryEntry[] = [];
|
|
for (const entry of filtered) {
|
|
if (seen.has(entry.command)) continue;
|
|
seen.add(entry.command);
|
|
results.push(entry);
|
|
if (results.length >= limit) break;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function getCurrentCommandToken(command: string): string {
|
|
const tokens = tokenizeShellLike(command);
|
|
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
|
|
}
|
|
|
|
function normalizeArgumentToken(token: string): string {
|
|
return token
|
|
.trim()
|
|
.replace(/^['"]/, "")
|
|
.replace(/['"]$/, "")
|
|
.replace(/\\ /g, " ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function tokenizeShellLike(input: string): string[] {
|
|
const tokens: string[] = [];
|
|
let current = "";
|
|
let inSingleQuote = false;
|
|
let inDoubleQuote = false;
|
|
let escaped = false;
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
const ch = input[i];
|
|
|
|
if (escaped) {
|
|
current += ch;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "\\") {
|
|
escaped = true;
|
|
current += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote;
|
|
current += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
current += ch;
|
|
continue;
|
|
}
|
|
|
|
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
|
if (current.length > 0) {
|
|
tokens.push(current);
|
|
current = "";
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current += ch;
|
|
}
|
|
|
|
tokens.push(current);
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Compute a fuzzy match score. Returns 0 for no match.
|
|
* Higher score = better match quality.
|
|
* Rewards: first-char match, consecutive matches, word-boundary matches.
|
|
*/
|
|
function fuzzyScore(query: string, target: string): number {
|
|
if (query.length === 0) return 0;
|
|
if (query.length > target.length) return 0;
|
|
|
|
let score = 0;
|
|
let queryIdx = 0;
|
|
let prevMatchIdx = -2;
|
|
|
|
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
|
if (target[i] === query[queryIdx]) {
|
|
queryIdx++;
|
|
// First character bonus
|
|
if (i === 0) score += 10;
|
|
// Consecutive match bonus
|
|
if (i === prevMatchIdx + 1) score += 5;
|
|
// Word boundary bonus
|
|
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
|
target[i - 1] === "-" || target[i - 1] === "_") {
|
|
score += 3;
|
|
}
|
|
score += 1;
|
|
prevMatchIdx = i;
|
|
}
|
|
}
|
|
|
|
// All query characters must be matched
|
|
return queryIdx === query.length ? score : 0;
|
|
}
|
|
|
|
/**
|
|
* Clear all history for a specific host, or all history if no hostId given.
|
|
* A global clear also bumps the shared clear generation so other renderers and
|
|
* in-flight histfile imports can detect it.
|
|
*/
|
|
export function clearHistory(hostId?: string): void {
|
|
const store = loadStore();
|
|
if (hostId) {
|
|
store.entries = store.entries.filter((e) => e.hostId !== hostId);
|
|
} else {
|
|
store.entries = [];
|
|
store.clearGeneration = readClearGeneration(store) + 1;
|
|
}
|
|
// Destructive clears must be durable: cancel any pending debounced write and
|
|
// persist synchronously so a later stale flush cannot restore the entries.
|
|
cancelPendingSave();
|
|
persistStoreNow(store);
|
|
knownClearGeneration = readClearGeneration(store);
|
|
if (!hostId) notifyCommandHistoryCleared();
|
|
}
|