feat(M5): command history/library + completion + session logs
- main/commands.ts: JSON-backed history (500 cap, dedupe), command library CRUD, per-session plain-text output logs (start/stop/index/open dir) - pty data path feeds the session logger via injected hooks - TerminalView: input line capture -> history recording, prefix completion popup (history + library, Tab/Arrows/Esc), session log toggle buttons - Sidebar: 命令 section — history (click to run, clear) and library (add/delete, click to run into active session) - tests: commands-store self-test ALL PASSED; full suite green
This commit is contained in:
1 parent
7977c264be
commit
7467433a61
12 files changed
+1593
-3
No files matched your search
@@ -0,0 +1,269 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __commonJS = (cb, mod) => function __require() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// tests/electron-stub.cjs
|
||||
var require_electron_stub = __commonJS({
|
||||
"tests/electron-stub.cjs"(exports2, module2) {
|
||||
module2.exports = {
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => []
|
||||
},
|
||||
app: {},
|
||||
ipcMain: {},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// src/main/commands.ts
|
||||
var commands_exports = {};
|
||||
__export(commands_exports, {
|
||||
CommandsStore: () => CommandsStore,
|
||||
defaultCommandsPath: () => defaultCommandsPath
|
||||
});
|
||||
module.exports = __toCommonJS(commands_exports);
|
||||
var import_electron = __toESM(require_electron_stub());
|
||||
var import_crypto = require("crypto");
|
||||
var import_fs = require("fs");
|
||||
var import_promises = require("fs/promises");
|
||||
var import_path = require("path");
|
||||
var HISTORY_CAP = 500;
|
||||
function emptyFile() {
|
||||
return { history: [], library: [] };
|
||||
}
|
||||
function defaultCommandsPath(userDataPath) {
|
||||
return userDataPath;
|
||||
}
|
||||
function two(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
function stamp(d = /* @__PURE__ */ new Date()) {
|
||||
return `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`;
|
||||
}
|
||||
var CommandsStore = class {
|
||||
/** <userData>/commands.json */
|
||||
file;
|
||||
/** <userData>/logs */
|
||||
logsDir;
|
||||
/** <userData>/logs/index.json */
|
||||
indexFile;
|
||||
openPathImpl;
|
||||
/** All known logs keyed by absolute file path (hydrated from index on boot). */
|
||||
metasByFile = /* @__PURE__ */ new Map();
|
||||
/** Logs currently accumulating output, keyed by sessionId. */
|
||||
activeBySession = /* @__PURE__ */ new Map();
|
||||
constructor(userData, openPathImpl) {
|
||||
const ud = userData ?? import_electron.app.getPath("userData");
|
||||
this.file = (0, import_path.join)(ud, "commands.json");
|
||||
this.logsDir = (0, import_path.join)(ud, "logs");
|
||||
this.indexFile = (0, import_path.join)(this.logsDir, "index.json");
|
||||
this.openPathImpl = openPathImpl;
|
||||
this.hydrateIndex();
|
||||
}
|
||||
// ---- commands file (history + library) -----------------------------------
|
||||
loadCommands() {
|
||||
try {
|
||||
const raw = JSON.parse((0, import_fs.readFileSync)(this.file, "utf8"));
|
||||
if (raw && typeof raw === "object" && Array.isArray(raw.history)) {
|
||||
const data = raw;
|
||||
return {
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
library: Array.isArray(data.library) ? data.library : []
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return emptyFile();
|
||||
}
|
||||
saveCommands(data) {
|
||||
(0, import_fs.mkdirSync)((0, import_path.join)(this.file, ".."), { recursive: true });
|
||||
(0, import_fs.writeFileSync)(this.file, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
|
||||
recordCommand(cmd) {
|
||||
const trimmed = cmd.trim();
|
||||
if (!trimmed) return;
|
||||
const data = this.loadCommands();
|
||||
const top = data.history[0];
|
||||
if (top && top.command === trimmed) {
|
||||
top.lastUsedAt = Date.now();
|
||||
} else {
|
||||
data.history.unshift({
|
||||
id: (0, import_crypto.randomUUID)(),
|
||||
command: trimmed,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now()
|
||||
});
|
||||
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP;
|
||||
}
|
||||
this.saveCommands(data);
|
||||
}
|
||||
/** History sorted by most recently used. */
|
||||
listHistory() {
|
||||
return this.loadCommands().history.slice().sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0));
|
||||
}
|
||||
clearHistory() {
|
||||
const data = this.loadCommands();
|
||||
data.history = [];
|
||||
this.saveCommands(data);
|
||||
}
|
||||
/** Library sorted newest first. */
|
||||
listLibrary() {
|
||||
return this.loadCommands().library.slice().sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
/** Insert (no id) or update (has id); returns the stored entry. */
|
||||
saveLibraryItem(item) {
|
||||
const data = this.loadCommands();
|
||||
let stored;
|
||||
if (typeof item.id === "string" && item.id.length > 0) {
|
||||
stored = data.library.find((x) => x.id === item.id);
|
||||
}
|
||||
if (stored) {
|
||||
stored.name = item.name;
|
||||
stored.command = item.command;
|
||||
stored.note = item.note;
|
||||
stored.group = item.group;
|
||||
stored.params = item.params;
|
||||
} else {
|
||||
stored = {
|
||||
id: item.id && item.id.length > 0 ? item.id : (0, import_crypto.randomUUID)(),
|
||||
name: item.name,
|
||||
command: item.command,
|
||||
note: item.note,
|
||||
group: item.group,
|
||||
params: item.params,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
data.library.push(stored);
|
||||
}
|
||||
this.saveCommands(data);
|
||||
return stored;
|
||||
}
|
||||
deleteLibraryItem(id) {
|
||||
const data = this.loadCommands();
|
||||
const next = data.library.filter((x) => x.id !== id);
|
||||
if (next.length < data.library.length) {
|
||||
data.library = next;
|
||||
this.saveCommands(data);
|
||||
}
|
||||
}
|
||||
// ---- session logs -----------------------------------------------------------
|
||||
hydrateIndex() {
|
||||
try {
|
||||
if (!(0, import_fs.existsSync)(this.indexFile)) return;
|
||||
const raw = JSON.parse((0, import_fs.readFileSync)(this.indexFile, "utf8"));
|
||||
if (!Array.isArray(raw)) return;
|
||||
for (const x of raw) {
|
||||
if (x && typeof x.sessionId === "string" && typeof x.file === "string") {
|
||||
const meta = x;
|
||||
this.metasByFile.set(meta.file, meta);
|
||||
if (meta.endedAt === void 0) this.activeBySession.set(meta.sessionId, meta);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
/** Write the full log index so listSessionLogs survives restart. */
|
||||
persistIndex() {
|
||||
try {
|
||||
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
|
||||
const all = Array.from(this.metasByFile.values()).sort(
|
||||
(a, b) => b.startedAt - a.startedAt
|
||||
);
|
||||
(0, import_fs.writeFileSync)(this.indexFile, JSON.stringify(all, null, 2), "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
/** Finalize a log (set endedAt, drop from active, persist index). */
|
||||
finishLog(meta) {
|
||||
meta.endedAt = Date.now();
|
||||
this.activeBySession.delete(meta.sessionId);
|
||||
this.persistIndex();
|
||||
}
|
||||
/** Start logging a session; any prior active log for the id is stopped first. */
|
||||
logStart(sessionId) {
|
||||
const prev = this.activeBySession.get(sessionId);
|
||||
if (prev) this.finishLog(prev);
|
||||
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
|
||||
const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log`;
|
||||
const file = (0, import_path.join)(this.logsDir, fileName);
|
||||
describeFile(file);
|
||||
const meta = { sessionId, file, fileName, startedAt: Date.now() };
|
||||
this.metasByFile.set(file, meta);
|
||||
this.activeBySession.set(sessionId, meta);
|
||||
this.persistIndex();
|
||||
return meta;
|
||||
}
|
||||
/** Append output for an active session. No-op when not logging (post-stop safe). */
|
||||
logWrite(sessionId, data) {
|
||||
const meta = this.activeBySession.get(sessionId);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
(0, import_promises.appendFile)(meta.file, data, "utf8").catch(() => {
|
||||
});
|
||||
}
|
||||
/** Stop logging a session, stamping endedAt into the registry + index. */
|
||||
logStop(sessionId) {
|
||||
const meta = this.activeBySession.get(sessionId);
|
||||
if (!meta) return;
|
||||
this.finishLog(meta);
|
||||
}
|
||||
/** All known session logs (including persisted ones), newest first. */
|
||||
listSessionLogs() {
|
||||
return Array.from(this.metasByFile.values()).sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
/** Reveal the logs directory in the OS file manager. */
|
||||
openLogsDir() {
|
||||
try {
|
||||
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
|
||||
if (this.openPathImpl) {
|
||||
void this.openPathImpl(this.logsDir);
|
||||
} else {
|
||||
void import_electron.shell.openPath(this.logsDir);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
};
|
||||
function describeFile(p) {
|
||||
try {
|
||||
(0, import_fs.writeFileSync)(p, "", "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
CommandsStore,
|
||||
defaultCommandsPath
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* M5 command-store + session-log self-test (commands-store.mjs).
|
||||
*
|
||||
* Bundles src/main/commands.ts for plain Node with an in-memory electron stub,
|
||||
* then verifies: history record/dedupe/cap/clear, library CRUD + ordering, and
|
||||
* session log write/read/index behavior against a temp userData dir.
|
||||
*
|
||||
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
|
||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
|
||||
* Run: node tests/commands-store.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
|
||||
const fail = (msg) => {
|
||||
console.error(`FAIL: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const ok = (cond, msg) => {
|
||||
if (!cond) fail(msg)
|
||||
console.log(` ok: ${msg}`)
|
||||
}
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
|
||||
let commandsMod
|
||||
try {
|
||||
commandsMod = require_('./.commands-store.cjs')
|
||||
} catch {
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
|
||||
}
|
||||
|
||||
// ---- 2. Temp userData; capture openDir target ----------------------------------
|
||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||
let openedPath = null
|
||||
const store = new commandsMod.CommandsStore(userData, async (p) => {
|
||||
openedPath = p
|
||||
})
|
||||
|
||||
// ---- 3. History record / dedupe / cap ------------------------------------------
|
||||
store.recordCommand(' echo hello ') // trims to 'echo hello'
|
||||
store.recordCommand('ls -la')
|
||||
store.recordCommand('ps aux')
|
||||
let hist = store.listHistory()
|
||||
ok(hist.length === 3, 'history records 3 distinct commands')
|
||||
ok(hist[0].command === 'ps aux', 'newest first by lastUsedAt')
|
||||
|
||||
// Re-run the newest command -> no new entry, recency refreshed, still length 3.
|
||||
store.recordCommand('ps aux')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
|
||||
|
||||
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
|
||||
// entry (spec dedupes only against the newest entry) with blank-recency guard.
|
||||
store.recordCommand('echo hello')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
|
||||
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
|
||||
|
||||
// Cap: fill until well above 500 keeps only the newest 500.
|
||||
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
|
||||
ok(hist[0].command === 'cmd-519', 'newest filler at front')
|
||||
|
||||
// Clear.
|
||||
store.clearHistory()
|
||||
ok(store.listHistory().length === 0, 'clearHistory empties history')
|
||||
|
||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
||||
const b = store.saveLibraryItem({ command: 'df -h', name: 'disk', group: 'ops' })
|
||||
ok(typeof a.id === 'string' && a.id.length > 0, 'saveLibraryItem assigns an id + createdAt')
|
||||
let lib = store.listLibrary()
|
||||
ok(lib.length === 2, 'library has 2 items')
|
||||
ok(lib[0].id === b.id, 'library newest first by createdAt')
|
||||
|
||||
// Update existing by id.
|
||||
const updated = store.saveLibraryItem({ ...b, command: 'df -hT', note: 'human + type' })
|
||||
lib = store.listLibrary()
|
||||
ok(lib.length === 2, 'update keeps count at 2')
|
||||
ok(lib.find((x) => x.id === b.id).command === 'df -hT', 'update applied fields')
|
||||
ok(lib.find((x) => x.id === b.id).createdAt === b.createdAt, 'update preserves createdAt')
|
||||
|
||||
// Delete.
|
||||
store.deleteLibraryItem(a.id)
|
||||
lib = store.listLibrary()
|
||||
ok(lib.length === 1 && lib[0].id === b.id, 'deleteLibraryItem removes the item')
|
||||
|
||||
// ---- 5. Session logs ------------------------------------------------------------
|
||||
const sid = '11111111-2222-3333-4444-555555555555'
|
||||
const start = store.logStart(sid)
|
||||
ok(!!start.fileName && start.fileName.startsWith('20') && start.file.endsWith('11111111.log'), 'logStart names file <stamp>-<id8>.log')
|
||||
ok(start.endedAt === undefined, 'logStart has no endedAt yet')
|
||||
ok(store.listSessionLogs().length === 1, 'listSessionLogs sees the active log')
|
||||
|
||||
// Writes land asynchronously in the file.
|
||||
store.logWrite(sid, 'hello line one\n')
|
||||
store.logWrite(sid, 'hello line two\n')
|
||||
await wait(100)
|
||||
const content = readFileSync(start.file, 'utf8')
|
||||
ok(content === 'hello line one\nhello line two\n', 'async appends are written in order')
|
||||
|
||||
// Stop -> endedAt stamped + stays in list.
|
||||
store.logStop(sid)
|
||||
let logs = store.listSessionLogs()
|
||||
ok(logs[0].endedAt !== undefined && logs[0].endedAt >= start.startedAt, 'logStop stamps endedAt')
|
||||
ok(logs.length === 1, 'stopped log still listed')
|
||||
|
||||
// Writes after stop are ignored (no reopen, no throw).
|
||||
store.logWrite(sid, 'after stop\n')
|
||||
await wait(100)
|
||||
ok(readFileSync(start.file, 'utf8') === 'hello line one\nhello line two\n', 'writes after stop are ignored')
|
||||
|
||||
// A fresh store (same userData) restores the log from the index file.
|
||||
const store2 = new commandsMod.CommandsStore(userData)
|
||||
logs = store2.listSessionLogs()
|
||||
ok(logs.length === 1, 'new store hydrates log list from index.json')
|
||||
ok(logs[0].sessionId === sid && logs[0].endedAt !== undefined, 'hydrated meta intact')
|
||||
|
||||
// Re-logStart same id stops the previous and creates a new file (delay so the
|
||||
// <HHmmss> stamp differs).
|
||||
await wait(1100)
|
||||
const start2 = store.logStart(sid)
|
||||
ok(start2.file !== start.file, 're-logStart creates a new file')
|
||||
logs = store.listSessionLogs()
|
||||
ok(logs.length === 2, 're-logStart yields 2 (old is stopped, new is active)')
|
||||
ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log finalized on re-start')
|
||||
|
||||
// ---- 6. openLogsDir ------------------------------------------------------------
|
||||
store.openLogsDir()
|
||||
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
|
||||
|
||||
console.log('\n[commands] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
Reference in new issue
Block a user