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:
954801926@qq.com committed 2026-09-06 17:28:15 +08:00
1 parent 7977c264be
commit 7467433a61
12 files changed
+1593 -3

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
/**
* Command history / command library + session output logs (M5).
*
* Persistence mirrors the connections store: plain JSON files under
* <userData>/ without sqlite. Two concerns live here:
*
* - Commands: <userData>/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: <userData>/logs/<YYYYMMDD-HHmmss>-<id8>.log plain-text
* files plus an index file (<userData>/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: <userData> (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 {
/** <userData>/commands.json */
private readonly file: string
/** <userData>/logs */
private readonly logsDir: string
/** <userData>/logs/index.json */
private readonly indexFile: string
private readonly openPathImpl?: (p: string) => Promise<unknown>
/** All known logs keyed by absolute file path (hydrated from index on boot). */
private readonly metasByFile = new Map<string, SessionLogMeta>()
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
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
}
}
+41 -1
View File
@@ -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()
}
+38
View File
@@ -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') {
+254
View File
@@ -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<CommandItem[]>([])
const [library, setLibrary] = useState<CommandItem[]>([])
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<void> => {
await window.api.clearHistory()
refresh()
}, [refresh])
const handleDeleteLibrary = useCallback(
async (id: string): Promise<void> => {
await window.api.deleteLibraryItem(id)
refresh()
},
[refresh]
)
const handleAdd = useCallback((): void => {
setName('')
setCommand('')
setNote('')
setAddOpen(true)
}, [])
const handleSave = useCallback(async (): Promise<void> => {
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 (
<div className="commands-panel">
<Tabs
size="small"
centered
items={[
{
key: 'history',
label: '历史',
children: (
<div className="commands-section">
<div className="commands-section-head">
<span className="commands-section-title">历史命令</span>
{history.length > 0 && (
<Popconfirm
title="清空全部命令历史?"
okText="清空"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void handleClearHistory()}
>
<Button type="text" size="small" danger className="commands-clear-btn">
清空
</Button>
</Popconfirm>
)}
</div>
{history.length === 0 ? (
<div className="commands-empty">{loaded ? '暂无历史命令' : '加载中…'}</div>
) : (
<ul className="commands-list">
{history.map((item) => (
<Row key={item.id} item={item} onRun={onRun} />
))}
</ul>
)}
</div>
)
},
{
key: 'library',
label: '命令库',
children: (
<div className="commands-section">
<div className="commands-section-head">
<span className="commands-section-title">命令库</span>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={handleAdd}
aria-label="新增命令"
title="新增命令"
/>
</div>
{library.length === 0 ? (
<div className="commands-empty">{loaded ? '命令库为空' : '加载中…'}</div>
) : (
<ul className="commands-list">
{library.map((item) => (
<Row
key={item.id}
item={item}
onRun={onRun}
onDelete={() => void handleDeleteLibrary(item.id)}
/>
))}
</ul>
)}
</div>
)
}
]}
/>
<Modal
open={addOpen}
title="新增命令"
okText="保存"
cancelText="取消"
okButtonProps={{ disabled: command.trim().length === 0 }}
onOk={() => void handleSave()}
onCancel={() => setAddOpen(false)}
className="commands-modal"
>
<div className="commands-form">
<label className="commands-field">
<span className="commands-field-label">名称</span>
<input
className="commands-input"
type="text"
value={name}
placeholder="(可选)"
spellCheck={false}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className="commands-field">
<span className="commands-field-label">命令</span>
<input
className="commands-input"
type="text"
value={command}
placeholder="如 ls -la"
spellCheck={false}
autoFocus
onChange={(e) => setCommand(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter' && command.trim()) void handleSave()
}}
/>
</label>
<label className="commands-field">
<span className="commands-field-label">备注</span>
<input
className="commands-input"
type="text"
value={note}
placeholder="(可选)"
spellCheck={false}
onChange={(e) => setNote(e.target.value)}
/>
</label>
</div>
</Modal>
</div>
)
}
function Row({
item,
onRun,
onDelete
}: {
item: CommandItem
onRun: (cmd: string) => void
onDelete?: () => void
}): React.JSX.Element {
return (
<li className="commands-row" onClick={() => onRun(item.command)} title={item.command}>
<div className="commands-row-main" title={item.note}>
<div className="commands-row-name">{item.name ?? item.command}</div>
<div className="commands-row-sub">
{item.name ? item.command : ''}
{item.name ? ' · ' : ''}
{formatClock(item.createdAt)}
</div>
</div>
{item.name && onDelete && (
<Popconfirm
title="删除该命令?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={onDelete}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
className="commands-row-del"
aria-label="删除命令"
/>
</Popconfirm>
)}
</li>
)
}
+164
View File
@@ -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);
}
@@ -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<ConnectionSidebarHandle, ConnectionS
onConnect,
localPanels = [],
activePanelId,
onFocusPanel
onFocusPanel,
onRunCommand
}: ConnectionSidebarProps,
ref
): React.JSX.Element {
@@ -236,6 +240,13 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div>
)}
</section>
<section className="connections-section commands-sidebar-section">
<div className="connections-section-head">
<span className="connections-section-title">命令</span>
</div>
<CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} />
</section>
</div>
<ConnectionEditDialog
@@ -401,4 +401,22 @@
flex: 0 0 auto;
font-size: 11px;
color: #6a6a6a;
}
/* [M5] command history/library sidebar section fills the rest of the list. */
.commands-sidebar-section {
flex: 1 1 0;
min-height: 160px;
display: flex;
flex-direction: column;
margin-bottom: 0;
}
.commands-sidebar-section .connections-section-head {
flex: 0 0 auto;
}
.commands-sidebar-section .commands-panel {
flex: 1 1 auto;
min-height: 0;
}
+250 -1
View File
@@ -6,7 +6,9 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
@@ -91,6 +93,65 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
)
}
/**
* [M5] Feed one data chunk into the per-session line buffer. Returns the
* accumulated (non-empty) line for the completion overlay, or '' when the
* buffer is empty (some control data only). Enter/backspace/espace/ctrl-c are
* handled here; escape sequences (\x1b) and the leading non-printable control
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer.
*/
function stepLineBuffer(
outBuf: React.MutableRefObject<string>,
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<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className }, ref) {
const settings = useSettingsStore((s) => s.settings)
@@ -109,6 +170,15 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const onCloseRef = useRef(onClose)
const compiledRef = useRef<CompiledRule[]>([])
const streamRef = useRef<HighlightStream | null>(null)
// M5: per-session line-capture buffer + completion candidate cache.
const lineBufRef = useRef<string>('')
const completionCacheRef = useRef<CommandItem[]>([])
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<CommandItem[]>([])
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<TerminalViewProps & { ref?:
compiledRef.current = compiledRules
streamRef.current?.setRules(compiledRules)
// M5: load completion candidates once per session (history + library cached).
const ensureCompletionCache = useCallback((): void => {
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<string | null>(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<CommandItem[]>([])
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<TerminalViewProps & { ref?:
termRef.current?.focus()
}, [])
/** [M5] Start recording this session's output to a log file. */
const handleLogStart = useCallback(async (): Promise<void> => {
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<void> => {
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<TerminalViewProps & { ref?:
termRef.current?.focus()
}, [beginPaste])
/** [M5] Accept the selected suggestion: substitute the buffer via a pty write. */
const handleTab = useCallback(
(selected: CommandItem): void => {
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<TerminalViewProps & { ref?:
void pasteFromClipboard()
return false
}
// M5: inline completion keyboard handling (pure overlay; never blocks the
// underlying pty write when suggestions are absent).
const list = suggestionsRef.current
if (list.length > 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<TerminalViewProps & { ref?:
deadRef.current = false
setDead(false)
setExitCode(0)
// M5: reset per-session state on (re)bind — fresh line buffer, no leftovers
// from a template apply or panel reuse; recording resumes idle.
lineBufRef.current = ''
setSuggestions([])
setSuggestionIndex(0)
if (recordingRef.current) {
recordingRef.current = false
setRecording(false)
}
applyRenderers(tSettings.rendererMode)
scheduleFit()
@@ -340,6 +519,30 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const disposables: { dispose(): void }[] = [
term.onData((data) => {
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<TerminalViewProps & { ref?:
result={resultInfo}
/>
)}
<div className="term-recbar">
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
aria-label={recording ? '停止记录' : '开始记录'}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
}}
>
{recording ? <PauseOutlined /> : <SoundOutlined />}
</button>
<button
type="button"
className="term-rec-btn"
title="打开日志目录"
aria-label="打开日志目录"
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
</button>
</div>
{suggestions.length > 0 && (
<div
className="term-suggestbar"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<div className="term-suggest-list">
{suggestions.map((s, i) => (
<button
key={s.id}
type="button"
className={`term-suggest-item${i === suggestionIndex ? ' is-active' : ''}`}
onClick={() => handleTab(s)}
title={s.command}
>
<span className="term-suggest-cmd">{s.command}</span>
{s.name && <span className="term-suggest-tag">{s.name}</span>}
</button>
))}
</div>
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
</div>
)}
{paste !== null && !dead && (
<div className="term-pastebar">
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
+101
View File
@@ -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;
+20
View File
@@ -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}
/>
)}
<div className="workspace-dockview">
+269
View File
@@ -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
});
+138
View File
@@ -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)