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