diff --git a/src/main/commands.ts b/src/main/commands.ts index 4ce0e23..b4a9646 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -24,9 +24,13 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs' import { appendFile } from 'fs/promises' import { join } from 'path' import type { CommandItem, SessionLogMeta } from '../shared/commands' +import { DEFAULT_SETTINGS } from '../shared/settings' +import { loadSettings } from './settingsStore' -/** Upper bound on recorded history entries. */ +/** Upper bound on recorded history entries when no limit is configured. */ const HISTORY_CAP = 500 +/** Hard ceiling: the history is also the completion source, so it stays small. */ +export const HISTORY_LIMIT_MAX = 500 interface CommandsFile { history: CommandItem[] @@ -67,6 +71,12 @@ export class CommandsStore { private readonly metasByFile = new Map() /** Logs currently accumulating output, keyed by sessionId. */ private readonly activeBySession = new Map() + /** + * Pending append per log file. appendFile is async, so two rapid logWrite + * calls would otherwise race and land out of order. Chaining each new append + * onto the previous one keeps the file append-only in call order. + */ + private readonly pendingAppends = new Map>() constructor(userData?: string, openPathImpl?: (p: string) => Promise) { const ud = userData ?? app.getPath('userData') @@ -100,15 +110,32 @@ export class CommandsStore { writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8') } - /** Record one executed command: trim; dedupe vs newest; move to front; cap. */ + /** + * Record one executed command. + * + * Deduplication is over the *whole* history, not just the newest entry: a + * command used three commands ago is the same command, so re-running it moves + * the existing entry to the front (refreshing `lastUsedAt`) instead of adding + * a second copy. History reads back sorted by `lastUsedAt`, so hoisting is + * exactly what "most recently used" means — and it keeps the list from + * filling up with one line repeated. + * + * Silently does nothing when 设置 → 终端 → 记录命令历史 is off: the setting is + * read here (not at the call site) so every recording path honours it, and + * existing history is left untouched rather than cleared. + */ recordCommand(cmd: string): void { const trimmed = cmd.trim() if (!trimmed) return + const { historyEnabled, historyLimit } = loadHistoryPrefs() + if (!historyEnabled) return + const cap = normalizeLimit(historyLimit) const data = this.loadCommands() - const top = data.history[0] - if (top && top.command === trimmed) { - // Same as the newest entry: refresh its recency (already at the front). - top.lastUsedAt = Date.now() + const existing = data.history.findIndex((item) => item.command === trimmed) + if (existing >= 0) { + const [item] = data.history.splice(existing, 1) + item.lastUsedAt = Date.now() + data.history.unshift(item) } else { data.history.unshift({ id: randomUUID(), @@ -116,16 +143,23 @@ export class CommandsStore { createdAt: Date.now(), lastUsedAt: Date.now() }) - if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP } + if (data.history.length > cap) data.history.length = cap this.saveCommands(data) } - /** History sorted by most recently used. */ + /** + * History sorted by most recently used, trimmed to the configured limit. + * + * The trim happens on read as well as on write so lowering the limit in + * settings takes effect at once instead of waiting for the next command. + */ listHistory(): CommandItem[] { + const { historyLimit } = loadHistoryPrefs() return this.loadCommands() .history.slice() .sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)) + .slice(0, normalizeLimit(historyLimit)) } clearHistory(): void { @@ -246,8 +280,18 @@ export class CommandsStore { // not logging (or already stopped) -> ignore, prevents stop/append race return } - appendFile(meta.file, data, 'utf8').catch(() => { - // file may have been removed after stop -> ignore + // Chain onto any in-flight append so rapid writes land in call order. + const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve() + const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then( + () => undefined, + () => { + // file may have been removed after stop -> ignore + } + ) + this.pendingAppends.set(meta.file, next) + // Avoid unbounded growth of the chain map once the file settles. + next.finally(() => { + if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file) }) } @@ -285,4 +329,36 @@ function describeFile(p: string): void { } catch { // best effort; appends will also create it lazily } +} + +/** + * History preferences straight from settings. + * + * Read on every use rather than cached: the store is constructed once at boot + * but the user can flip the switch at any time, and a stale copy would keep + * recording after they turned it off. + */ +function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } { + try { + const terminal = loadSettings().terminal + return { + historyEnabled: terminal.historyEnabled !== false, + historyLimit: normalizeLimit(terminal.historyLimit) + } + } catch { + // Settings unreadable (e.g. store used outside Electron in a test): fall + // back to the same defaults the app ships with. + return { + historyEnabled: DEFAULT_SETTINGS.terminal.historyEnabled, + historyLimit: DEFAULT_SETTINGS.terminal.historyLimit + } + } +} + +/** Clamp a configured limit into 1..HISTORY_LIMIT_MAX; unusable → the default. */ +function normalizeLimit(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SETTINGS.terminal.historyLimit + const rounded = Math.floor(value) + if (rounded < 1) return 1 + return Math.min(rounded, HISTORY_LIMIT_MAX) } \ No newline at end of file diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 5b8c5e0..10d9a22 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -288,6 +288,33 @@ export function RenderSettingsTab(): React.JSX.Element { /> } /> + void updateTerminal({ historyEnabled: checked })} + /> + } + /> + { + if (typeof value === 'number') void updateTerminal({ historyLimit: value }) + }} + style={{ width: 110 }} + /> + } + /> ) } diff --git a/src/shared/settings.ts b/src/shared/settings.ts index fad6be7..cfff41d 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -36,6 +36,10 @@ export interface TerminalSettings { pasteRiskConfirm: boolean /** inline command suggestion popup while typing (history + library) */ suggestEnabled: boolean + /** record executed commands into the history panel */ + historyEnabled: boolean + /** max entries kept in command history */ + historyLimit: number /** accent color for the active tab outline, rail indicator and SSH badges */ tabAccentColor: string } @@ -114,6 +118,8 @@ export const DEFAULT_SETTINGS: AppSettings = { copyOnSelect: false, pasteRiskConfirm: true, suggestEnabled: true, + historyEnabled: true, + historyLimit: 100, tabAccentColor: '#3fb950' }, customThemes: [], diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs index 8251dce..1b2f96b 100644 --- a/tests/commands-store.mjs +++ b/tests/commands-store.mjs @@ -6,10 +6,11 @@ * 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 + * --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs \ + * --alias:@shared=./src/shared * Run: node tests/commands-store.mjs (must exit 0) */ -import { mkdtempSync, readFileSync } from 'fs' +import { mkdtempSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { createRequire } from 'module' @@ -25,16 +26,23 @@ const ok = (cond, msg) => { } const wait = (ms) => new Promise((r) => setTimeout(r, ms)) -// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) ------- +// ---- 1. Temp userData must exist before the bundle loads ------------------------ +// The settings store resolves its own path via app.getPath('userData'). The stub +// is bundled *into* the module under test, so requiring it here gives a different +// instance — `__setUserData` would not reach the bundle. The env var is read when +// the stub loads, so it has to be set before the bundle is required below. +const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-')) +process.env.OT_STUB_USERDATA = userData + +// ---- 1b. 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') + 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 --alias:@shared=./src/shared') } -// ---- 2. Temp userData; capture openDir target ---------------------------------- -const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-')) +// ---- 2. Store over the temp userData; capture openDir target ------------------- let openedPath = null const store = new commandsMod.CommandsStore(userData, async (p) => { openedPath = p @@ -53,23 +61,78 @@ 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. +// Re-run an OLDER command -> dedupe is over the whole history, so it moves the +// existing entry to the front instead of adding a copy. 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') +ok(hist.length === 3, 'older command re-run does NOT add a copy (whole-history dedupe)') +ok(hist[0].command === 'echo hello', 'older re-run is hoisted to the front') +// The hoisted entry keeps its original createdAt (it is the same command, not a new one). +ok(new Set(hist.map((h) => h.command)).size === 3, 'history holds 3 distinct commands') -// Cap: fill until well above 500 keeps only the newest 500. -for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`) +// A command used long ago stays single even after many others in between. +for (let i = 0; i < 20; i++) store.recordCommand(`noise-${i}`) +store.recordCommand('ls -la') hist = store.listHistory() -ok(hist.length === 500, `history capped at 500 (got ${hist.length})`) -ok(hist[0].command === 'cmd-519', 'newest filler at front') +ok(hist.filter((h) => h.command === 'ls -la').length === 1, 'a command seen 20 entries ago is not duplicated') +ok(hist[0].command === 'ls -la', 'and it is hoisted to the front') -// Clear. +// Clear before the cap test so the assertions below are exact. store.clearHistory() ok(store.listHistory().length === 0, 'clearHistory empties history') +// ---- 3b. History limit + the recording switch ----------------------------------- +const writeSettings = (terminal) => + writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8') +const DEFAULT_LIMIT = 100 + +// The bundled default raises past the old hard cap of 500 only when configured; +// out of the box the list is trimmed to the default limit. +for (let i = 0; i < 150; i++) store.recordCommand(`filler-${i}`) +hist = store.listHistory() +ok(hist.length === DEFAULT_LIMIT, `history trimmed to the default limit of ${DEFAULT_LIMIT} (got ${hist.length})`) +ok(hist[0].command === 'filler-149', 'newest filler at front') + +// Lower the limit -> takes effect immediately, without needing a new command. +writeSettings({ historyLimit: 20 }) +hist = store.listHistory() +ok(hist.length === 20, `lowered limit applies on read (got ${hist.length})`) + +// Raise it -> entries that survived the *disk* cap (the configured limit at write +// time) become visible again, up to the new limit. Nothing was resurrected from +// beyond the write-time cap; the write cap is the real bound on disk growth. +writeSettings({ historyLimit: 50 }) +store.recordCommand('after-raise') +hist = store.listHistory() +ok(hist.length === 50, `raising the limit reveals still-stored entries (got ${hist.length})`) +ok(hist[0].command === 'after-raise', 'new command recorded after raising the limit') + +// Nonsense / out-of-range limits fall back to something sane instead of throwing. +writeSettings({ historyLimit: 0 }) +ok(store.listHistory().length === 1, 'limit 0 clamps to 1 rather than emptying history') +writeSettings({ historyLimit: 99999 }) +ok(store.listHistory().length === 50, 'absurd limit is clamped to the hard ceiling') + +// The switch: recording stops, and existing history is preserved (not cleared). +writeSettings({ historyLimit: 50, historyEnabled: false }) +const before = store.listHistory() +store.recordCommand('must-not-be-recorded') +store.recordCommand('must-not-be-recorded') +hist = store.listHistory() +ok(!hist.some((h) => h.command === 'must-not-be-recorded'), 'historyEnabled=false records nothing') +ok(hist.length === before.length, 'turning the switch off keeps existing history intact') + +// Turning it back on resumes recording. +writeSettings({ historyLimit: 50, historyEnabled: true }) +store.recordCommand('recorded-again') +ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording') + +// Default (keys absent from settings.json) records, matching a fresh install. +writeSettings({}) +store.recordCommand('default-on') +ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording') +store.clearHistory() + // ---- 4. Library CRUD + ordering ------------------------------------------------ const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' }) await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion diff --git a/tests/electron-stub.cjs b/tests/electron-stub.cjs index c9f130b..dbbb31e 100644 --- a/tests/electron-stub.cjs +++ b/tests/electron-stub.cjs @@ -1,12 +1,65 @@ -// Minimal electron stub for bundling src/main/pty.ts under plain Node -// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches. +// Minimal electron stub for bundling src/main/*.ts under plain Node +// (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled +// modules touch. +// +// `app.getPath('userData')` is configurable via `__setUserData` so a test can +// point the settings store at a temp dir and exercise settings-driven behavior. +let userDataPath = process.env.OT_STUB_USERDATA || '' module.exports = { BrowserWindow: { getAllWindows: () => [] }, - app: {}, - ipcMain: {}, + app: { + getPath: (name) => { + if (name === 'userData' && userDataPath) return userDataPath + throw new Error(`electron stub: app.getPath(${name}) is not configured`) + }, + setLoginItemSettings: () => {}, + isPackaged: false + }, + ipcMain: { + handle: () => {}, + on: () => {} + }, + globalShortcut: { + register: () => true, + unregister: () => {}, + unregisterAll: () => {}, + isRegistered: () => false + }, + webContents: {}, + powerSaveBlocker: { + start: () => 1, + stop: () => {}, + isStarted: () => false + }, + shell: { + openPath: async () => '' + }, safeStorage: { isEncryptionAvailable: () => false + }, + nativeTheme: { + shouldUseDarkColors: true, + on: () => {} + }, + Menu: { + buildFromTemplate: () => ({ popup: () => {} }), + setApplicationMenu: () => {} + }, + Tray: class { + setToolTip() {} + setContextMenu() {} + on() {} + destroy() {} + static getBounds() { + return { x: 0, y: 0, width: 0, height: 0 } + } + }, + nativeImage: { + createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) }) + }, + __setUserData: (p) => { + userDataPath = p } }