From 7467433a618ca98c88f6fbcf71b04b9d860ed6eb Mon Sep 17 00:00:00 2001 From: "954801926@qq.com" Date: Sun, 6 Sep 2026 17:28:15 +0800 Subject: [PATCH] feat(M5): command history/library + completion + session logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/main/commands.ts | 288 ++++++++++++++++++ src/main/ipc.ts | 42 ++- src/main/pty.ts | 38 +++ src/renderer/src/commands/CommandsPanel.tsx | 254 +++++++++++++++ src/renderer/src/commands/commands.css | 164 ++++++++++ .../src/connections/ConnectionSidebar.tsx | 13 +- src/renderer/src/connections/connections.css | 18 ++ src/renderer/src/terminal/TerminalView.tsx | 251 ++++++++++++++- src/renderer/src/terminal/terminal.css | 101 ++++++ src/renderer/src/workspace/Workspace.tsx | 20 ++ tests/.commands-store.cjs | 269 ++++++++++++++++ tests/commands-store.mjs | 138 +++++++++ 12 files changed, 1593 insertions(+), 3 deletions(-) create mode 100644 src/main/commands.ts create mode 100644 src/renderer/src/commands/CommandsPanel.tsx create mode 100644 src/renderer/src/commands/commands.css create mode 100644 tests/.commands-store.cjs create mode 100644 tests/commands-store.mjs diff --git a/src/main/commands.ts b/src/main/commands.ts new file mode 100644 index 0000000..4ce0e23 --- /dev/null +++ b/src/main/commands.ts @@ -0,0 +1,288 @@ +/** + * Command history / command library + session output logs (M5). + * + * Persistence mirrors the connections store: plain JSON files under + * / without sqlite. Two concerns live here: + * + * - Commands: /commands.json as `{ history, library }`. + * History is capped, deduped against the newest entry and sorted by + * lastUsedAt; library items are sorted by createdAt and support id-based + * update-or-create. + * - Session logs: /logs/-.log plain-text + * files plus an index file (/logs/index.json) of SessionLogMeta + * that survives restart. PTY data is pushed in by the session layer via the + * registered data logger; streams stop on session close. + * + * The only Electron binding is `app.getPath` (data dir) and `shell.openPath` + * (open the logs folder). Both are injectable so this module can be bundled + * and tested under plain Node. + */ + +import { app, shell } from 'electron' +import { randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs' +import { appendFile } from 'fs/promises' +import { join } from 'path' +import type { CommandItem, SessionLogMeta } from '../shared/commands' + +/** Upper bound on recorded history entries. */ +const HISTORY_CAP = 500 + +interface CommandsFile { + history: CommandItem[] + library: CommandItem[] +} + +function emptyFile(): CommandsFile { + return { history: [], library: [] } +} + +/** Default data root: (computed by the store itself). */ +export function defaultCommandsPath(userDataPath: string): string { + return userDataPath +} + +function two(n: number): string { + return String(n).padStart(2, '0') +} + +/** YYYYMMDD-HHmmss timestamp used in log file names. */ +function stamp(d = new Date()): string { + return ( + `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}` + + `-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}` + ) +} + +export class CommandsStore { + /** /commands.json */ + private readonly file: string + /** /logs */ + private readonly logsDir: string + /** /logs/index.json */ + private readonly indexFile: string + private readonly openPathImpl?: (p: string) => Promise + + /** All known logs keyed by absolute file path (hydrated from index on boot). */ + private readonly metasByFile = new Map() + /** Logs currently accumulating output, keyed by sessionId. */ + private readonly activeBySession = new Map() + + constructor(userData?: string, openPathImpl?: (p: string) => Promise) { + const ud = userData ?? app.getPath('userData') + this.file = join(ud, 'commands.json') + this.logsDir = join(ud, 'logs') + this.indexFile = join(this.logsDir, 'index.json') + this.openPathImpl = openPathImpl + this.hydrateIndex() + } + + // ---- commands file (history + library) ----------------------------------- + + private loadCommands(): CommandsFile { + try { + const raw: unknown = JSON.parse(readFileSync(this.file, 'utf8')) + if (raw && typeof raw === 'object' && Array.isArray((raw as CommandsFile).history)) { + const data = raw as CommandsFile + return { + history: Array.isArray(data.history) ? data.history : [], + library: Array.isArray(data.library) ? data.library : [] + } + } + } catch { + // missing / corrupted file -> start fresh + } + return emptyFile() + } + + private saveCommands(data: CommandsFile): void { + mkdirSync(join(this.file, '..'), { recursive: true }) + writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8') + } + + /** Record one executed command: trim; dedupe vs newest; move to front; cap. */ + recordCommand(cmd: string): void { + const trimmed = cmd.trim() + if (!trimmed) return + 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() + } else { + data.history.unshift({ + id: 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(): CommandItem[] { + return this.loadCommands() + .history.slice() + .sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)) + } + + clearHistory(): void { + const data = this.loadCommands() + data.history = [] + this.saveCommands(data) + } + + /** Library sorted newest first. */ + listLibrary(): CommandItem[] { + 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: CommandItem): CommandItem { + const data = this.loadCommands() + let stored: CommandItem | undefined + 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 : 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: string): void { + 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 ----------------------------------------------------------- + + private hydrateIndex(): void { + try { + if (!existsSync(this.indexFile)) return + const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8')) + if (!Array.isArray(raw)) return + for (const x of raw) { + if ( + x && + typeof (x as SessionLogMeta).sessionId === 'string' && + typeof (x as SessionLogMeta).file === 'string' + ) { + const meta = x as SessionLogMeta + this.metasByFile.set(meta.file, meta) + if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta) + } + } + } catch { + // index missing / corrupt -> rebuild on next write + } + } + + /** Write the full log index so listSessionLogs survives restart. */ + private persistIndex(): void { + try { + mkdirSync(this.logsDir, { recursive: true }) + const all = Array.from(this.metasByFile.values()).sort( + (a, b) => b.startedAt - a.startedAt + ) + writeFileSync(this.indexFile, JSON.stringify(all, null, 2), 'utf8') + } catch { + // best effort: never break the session over an index write failure + } + } + + /** Finalize a log (set endedAt, drop from active, persist index). */ + private finishLog(meta: SessionLogMeta): void { + 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: string): SessionLogMeta { + const prev = this.activeBySession.get(sessionId) + if (prev) this.finishLog(prev) + + mkdirSync(this.logsDir, { recursive: true }) + const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log` + const file = join(this.logsDir, fileName) + // Create the file eagerly so the first async append cannot race a missing fd. + describeFile(file) + const meta: SessionLogMeta = { 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: string, data: string): void { + const meta = this.activeBySession.get(sessionId) + if (!meta) { + // 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 + }) + } + + /** Stop logging a session, stamping endedAt into the registry + index. */ + logStop(sessionId: string): void { + const meta = this.activeBySession.get(sessionId) + if (!meta) return + this.finishLog(meta) + } + + /** All known session logs (including persisted ones), newest first. */ + listSessionLogs(): SessionLogMeta[] { + return Array.from(this.metasByFile.values()).sort((a, b) => b.startedAt - a.startedAt) + } + + /** Reveal the logs directory in the OS file manager. */ + openLogsDir(): void { + try { + mkdirSync(this.logsDir, { recursive: true }) + if (this.openPathImpl) { + void this.openPathImpl(this.logsDir) + } else { + void shell.openPath(this.logsDir) + } + } catch { + // best effort + } + } +} + +/** Create a log file empty so the path is real before async writes begin. */ +function describeFile(p: string): void { + try { + writeFileSync(p, '', 'utf8') + } catch { + // best effort; appends will also create it lazily + } +} \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts index aae72c4..34fb5e8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,7 +4,6 @@ import { homedir } from 'os' import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc' import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' -import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty' import { startPolling, stopPolling } from './sysinfo' import { registerSettingsIpc } from './settingsStore' import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' @@ -24,10 +23,35 @@ import { pickDirectory } from './sftp' import { broadcast } from './broadcast' +import { CommandsStore, defaultCommandsPath } from './commands' +import type { CommandItem } from '../shared/commands' +import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty' + +let commandsStore: CommandsStore | undefined + +/** M5: inject a custom command store (tests) or default to userData-backed. */ +export function setCommandsStore(store: CommandsStore): void { + commandsStore = store +} + +export function getCommandsStore(): CommandsStore { + if (!commandsStore) { + commandsStore = new CommandsStore(defaultCommandsPath(app.getPath('userData'))) + } + return commandsStore +} export function registerIpc(): void { const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData'))) const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData'))) + const cmds = getCommandsStore() + + // Route PTY output into the session log (M5). logWrite decides whether a + // session is actively logging; logStop finalizes on session close / kill. + registerLogHooks( + (id, data) => cmds.logWrite(id, data), + (id) => cmds.logStop(id) + ) // Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays // Electron-free). @@ -123,5 +147,21 @@ export function registerIpc(): void { ) ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id)) + // ---- command history / library + session logs (M5) ---- + ipcMain.handle(Ipc.COMMANDS_RECORD, (_event, cmd: string) => cmds.recordCommand(cmd)) + ipcMain.handle(Ipc.COMMANDS_HISTORY_LIST, () => cmds.listHistory()) + ipcMain.handle(Ipc.COMMANDS_HISTORY_CLEAR, () => cmds.clearHistory()) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_LIST, () => cmds.listLibrary()) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_SAVE, (_event, item: CommandItem) => + cmds.saveLibraryItem(item) + ) + ipcMain.handle(Ipc.COMMANDS_LIBRARY_DELETE, (_event, id: string) => + cmds.deleteLibraryItem(id) + ) + ipcMain.handle(Ipc.LOG_START, (_event, sessionId: string) => cmds.logStart(sessionId)) + ipcMain.handle(Ipc.LOG_STOP, (_event, sessionId: string) => cmds.logStop(sessionId)) + ipcMain.handle(Ipc.LOG_LIST, () => cmds.listSessionLogs()) + ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir()) + registerSettingsIpc() } \ No newline at end of file diff --git a/src/main/pty.ts b/src/main/pty.ts index a082d79..cc3083e 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -13,6 +13,38 @@ import { registerSftpClientProvider, closeSftp } from './sftp' // M3 polling engine without importing src/main/sysinfo.ts separately. export { startPolling, stopPolling } from './sysinfo' +/** + * M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the + * sysinfo injection pattern so pty.ts routes PTY data into the session log + * without importing the store directly. `log` runs on every onData chunk (the + * logger itself decides whether a session is actively logging), `stop` runs on + * session close / kill to finalize the log file. + */ +type DataLogger = (id: string, data: string) => void +let dataLogger: DataLogger | undefined +let logStopper: (id: string) => void | undefined + +export function registerLogHooks(log: DataLogger, stop: (id: string) => void): void { + dataLogger = log + logStopper = stop +} + +function safeLog(id: string, data: string): void { + try { + dataLogger?.(id, data) + } catch { + // never crash the event loop + } +} + +function safeStopLog(id: string): void { + try { + logStopper?.(id) + } catch { + // never crash the event loop + } +} + /** * Session routing table. A session is either a local pty or an established ssh * shell; the generic PTY_* (data plane) channels address both. PTY_DATA / @@ -115,6 +147,7 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { pty.onData((data) => { try { appendReplay(id, data) + safeLog(id, data) broadcast(Ipc.PTY_DATA, { id, data }) } catch { // never crash the event loop @@ -124,6 +157,7 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { pty.onExit(({ exitCode }) => { try { sessions.delete(id) + safeStopLog(id) broadcast(Ipc.PTY_EXIT, { id, exitCode }) } catch { // never crash the event loop @@ -173,6 +207,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin try { const text = data.toString('utf8') appendReplay(handle.id, text) + safeLog(handle.id, text) deps.broadcast(Ipc.PTY_DATA, { id: handle.id, data: text }) } catch { // never crash the event loop @@ -183,6 +218,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin try { stopPolling(handle.id) closeSftp(handle.id) + safeStopLog(handle.id) sessions.delete(handle.id) deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) } catch { @@ -220,6 +256,7 @@ export function killPty(id: string): void { stopPolling(id) closeSftp(id) closeSftp(id) + safeStopLog(id) replayBuffers.delete(id) const session = sessions.get(id) if (!session) return @@ -248,6 +285,7 @@ export function killAllPtys(): void { for (const id of sessions.keys()) { stopPolling(id) closeSftp(id) + safeStopLog(id) const session = sessions.get(id) if (!session) continue if (session.kind === 'ssh') { diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx new file mode 100644 index 0000000..3f6513c --- /dev/null +++ b/src/renderer/src/commands/CommandsPanel.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useState } from 'react' +import { Button, Modal, Popconfirm, Tabs } from 'antd' +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' +import type { CommandItem } from '@shared/commands' +import './commands.css' + +export interface CommandsPanelProps { + /** run a command in the currently active terminal/ssh panel */ + onRun: (cmd: string) => void +} + +/** Short "time ago" for history rows (kept tiny — mirrors the connection patch). */ + +/** local date/time string for a row's sub-line */ +export function formatClock(ts: number): string { + return new Date(ts).toLocaleString() +} + +/** + * [M5] 「命令」 sidebar section: command history (listHistory/clearHistory) and + * a small command library (saveLibraryItem/deleteLibraryItem). Clicking a row + * runs the command via the parent workspace's `onRun`. + */ +export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element { + const [history, setHistory] = useState([]) + const [library, setLibrary] = useState([]) + const [loaded, setLoaded] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [name, setName] = useState('') + const [command, setCommand] = useState('') + const [note, setNote] = useState('') + + const refresh = useCallback((): void => { + void Promise.all([window.api.listHistory(), window.api.listLibrary()]) + .then(([h, l]) => { + setHistory(h) + setLibrary(l) + }) + .catch(() => { + setHistory([]) + setLibrary([]) + }) + .finally(() => setLoaded(true)) + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + const handleClearHistory = useCallback(async (): Promise => { + await window.api.clearHistory() + refresh() + }, [refresh]) + + const handleDeleteLibrary = useCallback( + async (id: string): Promise => { + await window.api.deleteLibraryItem(id) + refresh() + }, + [refresh] + ) + + const handleAdd = useCallback((): void => { + setName('') + setCommand('') + setNote('') + setAddOpen(true) + }, []) + + const handleSave = useCallback(async (): Promise => { + const trimmed = command.trim() + if (!trimmed) return + await window.api.saveLibraryItem({ + id: window.crypto.randomUUID(), + name: name.trim() || undefined, + command: trimmed, + note: note.trim() || undefined, + createdAt: Date.now(), + lastUsedAt: Date.now() + }) + setAddOpen(false) + refresh() + }, [name, command, note, refresh]) + + return ( +
+ +
+ 历史命令 + {history.length > 0 && ( + void handleClearHistory()} + > + + + )} +
+ {history.length === 0 ? ( +
{loaded ? '暂无历史命令' : '加载中…'}
+ ) : ( +
    + {history.map((item) => ( + + ))} +
+ )} +
+ ) + }, + { + key: 'library', + label: '命令库', + children: ( +
+
+ 命令库 +
+ {library.length === 0 ? ( +
{loaded ? '命令库为空' : '加载中…'}
+ ) : ( +
    + {library.map((item) => ( + void handleDeleteLibrary(item.id)} + /> + ))} +
+ )} +
+ ) + } + ]} + /> + + void handleSave()} + onCancel={() => setAddOpen(false)} + className="commands-modal" + > +
+ + + +
+
+ + ) +} + +function Row({ + item, + onRun, + onDelete +}: { + item: CommandItem + onRun: (cmd: string) => void + onDelete?: () => void +}): React.JSX.Element { + return ( +
  • onRun(item.command)} title={item.command}> +
    +
    {item.name ?? item.command}
    +
    + {item.name ? item.command : ''} + {item.name ? ' · ' : ''} + {formatClock(item.createdAt)} +
    +
    + {item.name && onDelete && ( + +
  • + ) +} \ No newline at end of file diff --git a/src/renderer/src/commands/commands.css b/src/renderer/src/commands/commands.css new file mode 100644 index 0000000..f84d7b2 --- /dev/null +++ b/src/renderer/src/commands/commands.css @@ -0,0 +1,164 @@ +/* Command history / library sidebar section (commands/**). */ + +.commands-panel { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; +} + +.commands-panel .ant-tabs { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.commands-panel .ant-tabs-nav { + margin: 0; +} + +.commands-panel .ant-tabs-content-holder { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.commands-panel .ant-tabs-content, +.commands-panel .ant-tabs-tabpane { + height: 100%; +} + +.commands-section { + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.commands-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 2px 4px 6px; +} + +.commands-section-title { + flex: 1 1 auto; + font-size: 11px; + font-weight: 600; + color: #8a8a8a; + letter-spacing: 0.6px; +} + +.commands-clear-btn { + color: #8a8a8a; + font-size: 11px; + padding: 0 2px; +} + +.commands-clear-btn:hover { + color: #cf6679 !important; +} + +.commands-empty { + padding: 20px 8px; + text-align: center; + color: #8a8a8a; + font-size: 12px; +} + +.commands-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +.commands-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 6px 6px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.commands-row:hover { + background: var(--chrome-hover); +} + +.commands-row-main { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.commands-row-name { + font-size: 13px; + color: var(--chrome-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.commands-row-sub { + font-size: 11px; + color: #8a8a8a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.commands-row-del { + flex: 0 0 auto; + display: none; + color: #9a9a9a; +} + +.commands-row:hover .commands-row-del { + display: inline-flex; +} + +.commands-row-del:hover { + color: #cf6679 !important; +} + +/* ---- add-library modal ---- */ +.commands-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.commands-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.commands-field-label { + font-size: 12px; + color: var(--chrome-fg); +} + +.commands-input { + width: 100%; + padding: 5px 8px; + border: 1px solid var(--chrome-border); + border-radius: 4px; + background: var(--chrome-bg); + color: var(--chrome-fg); + font: inherit; + box-sizing: border-box; + outline: none; +} + +.commands-input:focus { + border-color: var(--chrome-accent); +} \ No newline at end of file diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx index 665671d..e240203 100644 --- a/src/renderer/src/connections/ConnectionSidebar.tsx +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -4,6 +4,7 @@ import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' import type { SshConnection } from '@shared/connections' import { AuthTags } from './Common' import { ConnectionEditDialog } from './ConnectionEditDialog' +import { CommandsPanel } from '../commands/CommandsPanel' import './connections.css' export interface ConnectionSidebarProps { @@ -16,6 +17,8 @@ export interface ConnectionSidebarProps { activePanelId?: string | null /** focus a local terminal panel; e.g. dockview api.getPanel(id)?.setActive(). */ onFocusPanel?: (panelId: string) => void + /** [M5] run a command in the workspace's currently active panel. */ + onRunCommand?: (cmd: string) => void } export interface ConnectionSidebarHandle { @@ -52,7 +55,8 @@ export const ConnectionSidebar = forwardRef )} + +
    +
    + 命令 +
    + undefined)} /> +
    , + data: string +): { line: string; submit?: string } { + const b = outBuf.current + if (!data) return { line: b } + // A chunk that begins with escape/control sequences (e.g. the terminal's + // clear-line) only removes buffer content; never appends. + const first = data[0] + if (b === '' && first === '\x1b') return { line: '' } + if (first === '\r') return submitOr(b) + if (first === '\x7f') { + outBuf.current = b.slice(0, -1) + return { line: outBuf.current } + } + if (first === '\x03') { + outBuf.current = '' + return { line: '' } + } + if (first === '\x1b') return { line: b } + outBuf.current = b + data + return { line: outBuf.current } +} + +function submitOr(b: string): { line: string; submit?: string } { + const t = b.trim() + return t ? { line: '', submit: t } : { line: '', submit: '' } +} + +/** M5: build the inline suggestion list (history + library, prefix, capped 5). */ +function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] { + const q = buffer.trim() + if (!q) return [] + const out: CommandItem[] = [] + for (const item of cache) { + if (item.command.startsWith(q)) out.push(item) + if (out.length >= 5) break + } + return out +} + +/** M5: send only the part of `full` that isn't already in the pty buffer. */ +function differenceWrite(sessionId: string, full: string, buf: string): void { + const obj = window.api + if (full.startsWith(buf) && buf) { + obj.writePty(sessionId, full.slice(buf.length)) + } else { + obj.writePty(sessionId, '\x03') + obj.writePty(sessionId, full) + } +} + export const TerminalView: ForwardRefExoticComponent }> = forwardRef(function TerminalViewInner({ sessionId, onClose, className }, ref) { const settings = useSettingsStore((s) => s.settings) @@ -109,6 +170,15 @@ export const TerminalView: ForwardRefExoticComponent([]) const streamRef = useRef(null) + // M5: per-session line-capture buffer + completion candidate cache. + const lineBufRef = useRef('') + const completionCacheRef = useRef([]) + const completionKeyRef = useRef('') + const recordingRef = useRef(false) + // M5: completion selection tracked in refs so the construction-time key + // handler always reads the live values without a stale closure. + const suggestionsRef = useRef([]) + const selIndexRef = useRef(0) // Compile keyword-highlight rules once per rules change; keep them in a ref so // the (stable, single-construction) pty/term subscriptions always read the @@ -118,14 +188,42 @@ export const TerminalView: ForwardRefExoticComponent { + if (completionCacheRef.current.length > 0) return + const key = `${sessionId}|${window.location.pathname}` + if (completionKeyRef.current === key) return + completionKeyRef.current = key + void Promise.all([window.api.listHistory(), window.api.listLibrary()]) + .then(([h, l]) => { + // Newest-first history is already ordered; library keeps insertion order. + completionCacheRef.current = [...h, ...l] + }) + .catch(() => undefined) + }, [sessionId]) + const [searchOpen, setSearchOpen] = useState(false) const [searchTerm, setSearchTerm] = useState('') const [resultInfo, setResultInfo] = useState('') const [paste, setPaste] = useState(null) const [dead, setDead] = useState(false) const [exitCode, setExitCode] = useState(0) + const [recording, setRecording] = useState(false) + // Inline command-completion overlays built from history/library (M5). + const [suggestions, setSuggestions] = useState([]) + const [suggestionIndex, setSuggestionIndex] = useState(0) const tSettings = settings.terminal + // [M5] Mirror completion selection into state's single source of truth. Kept + // as a pair of effects so the construction-time key handler (below) always + // reads the live values through refs, never a stale closure. + useEffect(() => { + suggestionsRef.current = suggestions + }, [suggestions]) + useEffect(() => { + selIndexRef.current = suggestionIndex + }, [suggestionIndex]) + const applyRenderers = useCallback((mode: TerminalSettings['rendererMode']) => { const term = termRef.current if (!term) return @@ -202,6 +300,28 @@ export const TerminalView: ForwardRefExoticComponent => { + try { + await window.api.logStart(sessionId) + recordingRef.current = true + setRecording(true) + } catch { + /* log start denied — stay idle */ + } + }, [sessionId]) + + /** [M5] Stop recording; finalize the session's log file. */ + const handleLogStop = useCallback(async (): Promise => { + try { + await window.api.logStop(sessionId) + recordingRef.current = false + setRecording(false) + } catch { + /* log stop denied — keep the current visual state */ + } + }, [sessionId]) + const beginPaste = useCallback( (text: string) => { if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { @@ -237,6 +357,31 @@ export const TerminalView: ForwardRefExoticComponent { + const cmd = selected.command + const buf = lineBufRef.current + differenceWrite(sessionId, cmd, buf) + lineBufRef.current = cmd + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + termRef.current?.focus() + }, + [sessionId] + ) + + /** [M5] Nudge the completion selection up (↑) or down (↓). */ + const moveSelection = useCallback((dir: 1 | -1): void => { + const n = suggestionsRef.current.length + if (n === 0) return + const next = (selIndexRef.current + dir + n) % n + selIndexRef.current = next + setSuggestionIndex(next) + }, []) + const onTerminalKey = useCallback( (e: KeyboardEvent): boolean => { if (e.type !== 'keydown') return true @@ -258,9 +403,34 @@ export const TerminalView: ForwardRefExoticComponent 0) { + if (e.key === 'Escape') { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + return false + } + if (e.key === 'ArrowDown') { + moveSelection(1) + return false + } + if (e.key === 'ArrowUp') { + moveSelection(-1) + return false + } + if (e.key === 'Tab') { + const selected = list[selIndexRef.current] ?? list[0] + if (selected) handleTab(selected) + return false + } + } return true }, - [closeSearch, copySelection, pasteFromClipboard] + [closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab] ) // ---- terminal instance: built once per session ---- @@ -310,6 +480,15 @@ export const TerminalView: ForwardRefExoticComponent { if (!deadRef.current) window.api.writePty(sessionId, data) + // M5: line capture → recordCommand + inline completion overlay. + const step = stepLineBuffer(lineBufRef, data) + if (typeof step.submit === 'string' && step.submit) { + void window.api.recordCommand(step.submit).catch(() => undefined) + } + if (deadRef.current) { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + } else if (step.line) { + ensureCompletionCache() + const list = buildSuggestions(completionCacheRef.current, step.line) + suggestionsRef.current = list + selIndexRef.current = Math.min(selIndexRef.current, Math.max(list.length - 1, 0)) + if (list.length === 0) selIndexRef.current = 0 + setSuggestions(list) + setSuggestionIndex(selIndexRef.current) + } else { + suggestionsRef.current = [] + selIndexRef.current = 0 + setSuggestions([]) + setSuggestionIndex(0) + } }), term.onSelectionChange(() => { if (!useSettingsStore.getState().settings.terminal.copyOnSelect) return @@ -481,6 +684,52 @@ export const TerminalView: ForwardRefExoticComponent )} +
    + + +
    + {suggestions.length > 0 && ( +
    e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > +
    + {suggestions.map((s, i) => ( + + ))} +
    +
    ↑/↓ 选择 · Tab 接受 · Esc 关闭
    +
    + )} {paste !== null && !dead && (
    检测到多行/长文本粘贴 diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css index a598ed8..3195aac 100644 --- a/src/renderer/src/terminal/terminal.css +++ b/src/renderer/src/terminal/terminal.css @@ -1,5 +1,6 @@ /* Terminal surface */ .terminal-view { + position: relative; display: flex; flex-direction: column; min-width: 0; @@ -103,6 +104,106 @@ white-space: nowrap; } +/* ---- session log record / open-dir buttons (floating, top-left) ---- */ +.term-recbar { + position: absolute; + top: 4px; + left: 4px; + z-index: 4; + display: flex; + align-items: center; + gap: 2px; +} + +.term-rec-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #8a8a8a; + font-size: 12px; + cursor: pointer; +} + +.term-rec-btn:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +.term-rec-btn.is-recording { + color: var(--chrome-accent); +} + +/* ---- M5 inline command-completion overlay ---- */ +.term-suggestbar { + flex: none; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px; + background: var(--chrome-hover); + border-bottom: 1px solid var(--chrome-border); + user-select: none; +} + +.term-suggest-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.term-suggest-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 2px 6px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--chrome-fg); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.term-suggest-item:hover, +.term-suggest-item.is-active { + background: rgba(59, 142, 234, 0.18); + color: #ffffff; +} + +.term-suggest-cmd { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; +} + +.term-suggest-tag { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +.term-suggest-hint { + font-size: 11px; + color: #8a8a8a; + opacity: 0.9; +} + /* ---- paste risk confirm bar ---- */ .term-pastebar { display: flex; diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 7da1e22..60f0ecc 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -394,6 +394,25 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX await window.api.deleteLayout(meta.id) }, []) + /** [M5] Run a command in the currently active terminal/ssh session. */ + const runCommand = useCallback( + (cmd: string): void => { + const panel = activePanelRef.current + if (!panel) { + message.info('没有激活的终端:请至少打开一个本地终端或 SSH 会话') + return + } + const sid = sessionIdOf(panel) + if (!sid) { + message.info('当前激活面板没有可用的会话') + return + } + window.api.writePty(sid, `${cmd}\r`) + panel.api.setActive() + }, + [message] + ) + const layoutMenu: MenuProps = { items: [ { key: 'save', label: '保存为模板' }, @@ -425,6 +444,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX localPanels={localPanels} activePanelId={activePanelId} onFocusPanel={onFocusPanel} + onRunCommand={runCommand} /> )}
    diff --git a/tests/.commands-store.cjs b/tests/.commands-store.cjs new file mode 100644 index 0000000..c22da1c --- /dev/null +++ b/tests/.commands-store.cjs @@ -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 { + /** /commands.json */ + file; + /** /logs */ + logsDir; + /** /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 +}); diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs new file mode 100644 index 0000000..785daf9 --- /dev/null +++ b/tests/commands-store.mjs @@ -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 -.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 +// 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) \ No newline at end of file