feat(M6): broadcast input + main-process ZMODEM + quick input panel + shortcuts

- broadcast: session target picker popover, fan-out write path, <2 targets auto-disable, per-tab target dots
- zmodem: main-process engine over ssh sessions (rz send / sz receive), progress reuses transfer panel, offer 120s / stall 90s watchdogs, e2e test with real zmodem.js peer (both directions)
- quick input panel: bottom-right overlay (command line + auto-\r + library + recent), broadcast-aware send
- shortcuts: Ctrl+=/-/0 font size (persisted), globalShowHide accelerator via globalShortcut, Ctrl+PgUp/PgDn terminal cycling
- contracts: ZMODEM_OFFER/RESPOND/DONE channels, SystemSettings.globalShowHide, TransferKind zmodem-upload/download
- review fixes: xterm-helper-textarea font hotkey pass-through, zmodem error-vs-cancelled progress state, offer timer clear, receive stream destroy
This commit is contained in:
954801926@qq.com committed 2026-09-06 20:15:49 +08:00
1 parent 0953ac4f41
commit 0663e5affa
30 files changed
+9028 -37

No files matched your search

+3472 -2
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -21,10 +21,12 @@
"@xterm/xterm": "^6.0.0",
"antd": "^6.6.2",
"dockview-react": "^8.2.0",
"electron-updater": "^6.8.9",
"font-list": "^2.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"ssh2": "^1.17.0",
"zmodem.js": "^0.1.10",
"zustand": "^5.0.15"
},
"devDependencies": {
@@ -33,6 +35,7 @@
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"electron": "^44.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"typescript": "~5.9.0",
"vite": "^7.0.0"
+37
View File
@@ -0,0 +1,37 @@
import { BrowserWindow, globalShortcut } from 'electron'
/**
* Register the global show/hide toggle for the main window.
*
* - accelerator '' / undefined => disabled (no global key bound).
* - Passing an invalid accelerator string makes Electron's register() throw;
* we swallow that here so a bad user-supplied value never crashes the app.
* - register() returning false means the accelerator is already taken by
* another application; we only log a warning and keep running.
*/
export function applyGlobalShortcut(accelerator: string | undefined): void {
// No other global shortcuts are registered anywhere in this app, so a full
// unregister is safe and guarantees a clean re-register on every change.
globalShortcut.unregisterAll()
if (!accelerator) return
const handler = (): void => {
const win = BrowserWindow.getAllWindows()[0]
if (!win || win.isDestroyed()) return
if (win.isVisible() && win.isFocused()) {
win.hide()
} else {
win.show()
win.focus()
}
}
try {
const ok = globalShortcut.register(accelerator, handler)
if (!ok) {
console.warn(`[global-shortcut] register "${accelerator}" failed (conflict with another app)`)
}
} catch (err) {
console.warn(`[global-shortcut] register "${accelerator}" threw`, err)
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, globalShortcut, shell } from 'electron'
import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
@@ -62,6 +62,7 @@ app.whenReady().then(() => {
app.on('before-quit', () => {
killAllPtys()
globalShortcut.unregisterAll()
})
app.on('window-all-closed', () => {
+7
View File
@@ -26,6 +26,8 @@ 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'
import { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc'
let commandsStore: CommandsStore | undefined
@@ -85,6 +87,11 @@ export function registerIpc(): void {
)
ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id))
// ---- zmodem (M6: main-process engine over ssh sessions) ----
// ZMODEM_OFFER / ZMODEM_DONE are broadcasts (main -> renderer); this is the
// renderer's answer, forwarded to the engine (which routes on resp.id).
ipcMain.on(Ipc.ZMODEM_RESPOND, (_event, resp: ZmodemResponse) => respondZmodem(resp))
// ---- sysinfo (M3: remote hardware monitoring, ssh sessions only) ----
ipcMain.on(Ipc.SYSINFO_START, (_event, id: string) => startPolling(id))
ipcMain.on(Ipc.SYSINFO_STOP, (_event, id: string) => stopPolling(id))
+36 -9
View File
@@ -8,6 +8,7 @@ import { connectSsh, type SshSessionHandle } from './ssh'
import type { HostKeyCheckResult } from './knownHosts'
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
import { registerSftpClientProvider, closeSftp } from './sftp'
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
// M3 polling engine without importing src/main/sysinfo.ts separately.
@@ -203,21 +204,43 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
})
sessions.set(handle.id, { kind: 'ssh', ssh: handle })
handle.stream.on('data', (data: Buffer) => {
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
// ZMODEM (M6): attach the in-process engine to the raw ssh stream. Only ssh
// sessions are supported -- node-pty/Windows ConPTY hands out UTF-8 strings
// only and would corrupt binary frames. The sentry inspects every data chunk:
// non-ZMODEM traffic is routed back through toTerminal below; once a ZMODEM
// header is spotted the transfer suppresses the terminal data plane (and
// writePty drops user keystrokes while isZmodemActive is true).
attachZmodem(handle.id, {
broadcast: (channel, ...args) => deps.broadcast(channel, ...args),
toTerminal: (id, text) => {
appendReplay(id, text)
safeLog(id, text)
deps.broadcast(Ipc.PTY_DATA, { id, data: text })
},
writeStream: (id, data) => {
const s = sessions.get(id)
if (s?.kind === 'ssh') {
try {
s.ssh.stream.write(data)
} catch {
// stream may already be dead
}
}
}
})
handle.stream.on('data', (data: Buffer) => {
// Route through the ZMODEM sentry. It forwards non-ZMODEM bytes to
// deps.toTerminal and absorbs the transfer untouched; while a transfer is
// active the engine suppresses the normal data plane entirely.
feedZmodem(handle.id, data)
})
handle.stream.on('close', () => {
try {
stopPolling(handle.id)
closeSftp(handle.id)
detachZmodem(handle.id)
safeStopLog(handle.id)
sessions.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
@@ -230,6 +253,9 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
}
export function writePty(id: string, data: string): void {
// While a ZMODEM transfer is active the stream carries binary frames; user
// keystrokes must be dropped (they would corrupt the transfer).
if (isZmodemActive(id)) return
const session = sessions.get(id)
if (!session) return
try {
@@ -255,7 +281,7 @@ export function resizePty(id: string, cols: number, rows: number): void {
export function killPty(id: string): void {
stopPolling(id)
closeSftp(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
replayBuffers.delete(id)
const session = sessions.get(id)
@@ -285,6 +311,7 @@ export function killAllPtys(): void {
for (const id of sessions.keys()) {
stopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
const session = sessions.get(id)
if (!session) continue
+8 -2
View File
@@ -12,10 +12,11 @@ import {
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false }
const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
@@ -74,9 +75,12 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const sys = (raw as { system?: unknown }).system
if (sys !== null && typeof sys === 'object') {
const candidate = sys as Record<string, unknown>
const globalShowHide =
typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : ''
system = {
launchAtLogin: candidate.launchAtLogin === true,
preventSleep: candidate.preventSleep === true
preventSleep: candidate.preventSleep === true,
globalShowHide
}
}
}
@@ -111,6 +115,8 @@ function applySystemSettings(system: SystemSettings): void {
powerSaveBlocker.stop(sleepBlockerId)
sleepBlockerId = undefined
}
// Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide)
}
/** Apply system side effects for the settings loaded at startup. */
+125
View File
@@ -0,0 +1,125 @@
/**
* Minimal ambient typings for the `zmodem.js` package (v0.1.10).
*
* zmodem.js ships no type declarations and `@types/zmodem.js` is not installed;
* npm install is out of scope here, so this file declares exactly the surface
* src/main/zmodem.ts consumes. Verified against the sources in
* node_modules/zmodem.js/src/{zsentry,zsession,zvalidation,zheader}.js:
*
* - Sentry.consume() accepts a Uint8Array / ArrayBuffer / octet Array.
* - Detection.confirm() returns the matched Session; the Session's `.type`
* is "receive" (remote `sz` -> we download) or "send" (remote `rz` -> we
* upload).
* - The byte arrays passed to `to_terminal` / `sender` are plain number[].
*/
declare module 'zmodem.js' {
/** one batch of a file being received (see Offer) */
export interface FileDetails {
name: string
/** can be null when the peer streams an unknown size; zmodem.js accepts */
size?: number | null
/** POSIX mode bits (0x8000 | perm); optional */
mode?: number | null
/** Date or epoch seconds; optional */
mtime?: Date | number | null
files_remaining?: number | null
bytes_remaining?: number | null
}
/** A sender-side single-file transfer handle (result of send_offer()). */
export interface Transfer {
get_details(): FileDetails
get_offset(): number
/** Send a non-terminal chunk. */
send(arrayLike: number[] | Uint8Array): void
/** Send the terminal chunk; resolves when the receiver confirms file end. */
end(arrayLike?: number[] | Uint8Array): Promise<void>
}
/** A receiver-side offerable file (shown on a receive Session's "offer"). */
export interface Offer {
get_details(): FileDetails
get_offset(): number
/** Skip this file (not an error, unlike abort()). */
skip(): Promise<never>
/**
* Accept the offer. With { on_input: fn } the payload of every data
* subpacket is delivered to `fn` (streaming, no full spool in memory).
*/
accept(opts?: {
offset?: number
on_input?: 'spool_array' | 'spool_uint8array' | ((payload: Uint8Array) => void)
}): Promise<unknown>
on(event: 'input', cb: (payload: Uint8Array) => void): Offer
on(event: 'complete', cb: () => void): Offer
}
/** Base ZMODEM session (Send/Receive share this). */
export interface Session {
type: 'send' | 'receive'
set_sender(fn: (octets: number[]) => void): void
has_ended(): boolean
aborted(): boolean
/** Abort the transfer (sends the ZMODEM CAN sequence to the peer). */
abort(): void
/** Register a session_end handler (fires once the transfer is done). */
on(event: 'session_end', cb: () => void): void
/** Catch-all overload (keeps the Send|Receive union from collapsing to never). */
on(event: never, cb: (...args: unknown[]) => void): void
}
/** Send session (we upload, remote ran rz). */
export interface SendSession extends Session {
type: 'send'
/** Offer one file; resolves with the Transfer or undefined if skipped. */
send_offer(params: FileDetails): Promise<Transfer | undefined>
/** Ends a send session gracefully; resolves when the receiver acks ZFIN. */
close(): Promise<void>
}
/** Receive session (we download, remote ran sz). */
export interface ReceiveSession extends Session {
type: 'receive'
on(event: 'offer', cb: (offer: Offer) => void): void
/** Begin receiving: tells the peer we are ready for the first offer. */
start(): Promise<void>
}
export interface SentryOptions {
/** Non-ZMODEM octets to forward to the terminal. */
to_terminal(octets: number[]): void
/** Called with a Detection when a ZMODEM header is spotted. */
on_detect(detection: Detection): void
/** Called when a pending Detection is retracted (no session after all). */
on_retract(): void
/** Send octets to the peer (the ssh stream). */
sender(octets: number[]): void
}
export interface Detection {
/** Whether the detection is still valid (not retracted). */
is_valid(): boolean
/** Confirm the ZMODEM session and return the active Session object. */
confirm(): SendSession | ReceiveSession
/** Give the session's role: 'send' or 'receive'. */
get_session_role(): 'send' | 'receive'
/** Reject: tell the peer to abort the session. */
deny(): void
}
export const Sentry: new (options: SentryOptions) => Sentry
export interface Sentry {
consume(input: Uint8Array | ArrayBuffer | number[]): void
get_confirmed_session(): Session | null
}
export const ZMLIB: { ABORT_SEQUENCE: number[] }
export const ZDLE: new () => unknown
export const CRC: { crc16(input: ArrayLike<number>): number }
export const Header: Record<string, unknown>
export const Subpacket: Record<string, unknown>
export const Session: {
Send: new (zrinitHeader: unknown) => SendSession
Receive: new () => ReceiveSession
}
}
+476
View File
@@ -0,0 +1,476 @@
/**
* ZMODEM engine (M6) - lives in the MAIN process and only ever runs over an SSH
* session stream, using zmodem.js@0.1.10.
*
* LOCAL PTY SESSIONS ARE DELIBERATELY NOT SUPPORTED: node-pty / Windows ConPTY
* only hand us UTF-8 strings and are lossy for arbitrary bytes, so a binary
* ZMODEM stream would be corrupted (and there is no file dialog on the local
* side anyway). The engine is therefore only ever attached to an ssh2 channel,
* where we get raw Buffers.
*
* Design mirrors pty.ts's dependency-injection pattern: this module holds no
* Electron import. pty.ts injects `broadcast`/`toTerminal`/`writeStream`, and
* routes the ssh stream's 'data' through `feedZmodem`. While a transfer is
* active we suppress the normal terminal data-plane (binary frames must never
* reach the terminal), and pty.ts's writePty drops user keystrokes via
* isZmodemActive.
*/
import { basename, join } from 'path'
import { createReadStream, createWriteStream } from 'fs'
import { stat } from 'fs/promises'
import type { Writable } from 'stream'
import * as Zmodem from 'zmodem.js'
import { Ipc } from '../shared/ipc'
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
import type { TransferProgressEvent } from '../shared/sftp'
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
const OFFER_TIMEOUT_MS = 120_000
/** Abort a transfer that makes no byte progress for this long (ms). */
const STALL_TIMEOUT_MS = 90_000
const ABORT_BUFFER = Buffer.from(Zmodem.ZMLIB.ABORT_SEQUENCE)
export interface ZmodemDeps {
/** Emit a main->renderer broadcast (ZMODEM_OFFER / ZMODEM_DONE / TRANSFER_PROGRESS). */
broadcast(channel: string, ...args: unknown[]): void
/**
* Forward NON-ZMODEM octets back through the normal terminal data path
* (utf8 + replay + session log + PTY_DATA). pty.ts injects this; the engine
* only calls it when no transfer is active.
*/
toTerminal(sessionId: string, text: string): void
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
writeStream(sessionId: string, data: Buffer): void
}
interface Engine {
id: string
deps: ZmodemDeps
sentry: Zmodem.Sentry
detection: Zmodem.Detection | null
session: Zmodem.SendSession | Zmodem.ReceiveSession | null
/** True from header detection until the session ends / teardown. */
active: boolean
mode: 'send' | 'receive' | null
dir: string | null
/** True once the detection is confirmed and files start flowing. */
confirmed: boolean
transferId: string
/** one progress terminal event at most */
progressEmitted: boolean
doneEmitted: boolean
offerTimer: NodeJS.Timeout | null
stallTimer: NodeJS.Timeout | null
receiveStream: Writable | null
bytes: number
totalBytes: number
}
const engines = new Map<string, Engine>()
function current(sessionId: string): Engine | undefined {
return engines.get(sessionId)
}
function cancelTimers(engine: Engine): void {
if (engine.offerTimer) clearTimeout(engine.offerTimer)
if (engine.stallTimer) clearTimeout(engine.stallTimer)
engine.offerTimer = null
engine.stallTimer = null
}
function emitProgress(engine: Engine, patch: Partial<TransferProgressEvent>): void {
const evt: TransferProgressEvent = {
transferId: engine.transferId,
kind: engine.mode === 'receive' ? 'zmodem-download' : 'zmodem-upload',
state: 'running',
file: '',
bytes: engine.bytes,
totalBytes: engine.totalBytes,
...patch
}
try {
engine.deps.broadcast(Ipc.TRANSFER_PROGRESS, evt)
} catch {
// never crash the event loop
}
}
function emitDone(engine: Engine, ok: boolean, message?: string): void {
if (engine.doneEmitted) return
engine.doneEmitted = true
const evt: ZmodemDoneEvent = { id: engine.id, ok, message }
try {
engine.deps.broadcast(Ipc.ZMODEM_DONE, evt)
} catch {
// never crash the event loop
}
}
/**
* End-of-session / cancel / error common path: stop suppressing the terminal
* data plane, drop timers, release streams, and emit the terminal events once.
*/
function finalize(
engine: Engine,
ok: boolean,
message?: string,
failState: 'cancelled' | 'error' = 'error'
): void {
engine.active = false
cancelTimers(engine)
if (engine.receiveStream) {
try {
engine.receiveStream.end()
} catch {
// already closed
}
engine.receiveStream = null
}
engine.session = null // the zsession already ended; drop the reference
engine.detection = null
if (!engine.progressEmitted) {
emitProgress(engine, { state: ok ? 'done' : failState, file: message ?? '' })
}
engine.progressEmitted = true
if (ok) emitDone(engine, true)
else emitDone(engine, false, message ?? '传输失败')
}
/** Called by the zsession's own 'session_end' event (its `this` is the session). */
function makeSessionEnd(engine: Engine): () => void {
return () => finalize(engine, true)
}
function touchActivity(engine: Engine): void {
if (engine.stallTimer) clearTimeout(engine.stallTimer)
const timer = setTimeout(() => {
if (engine.stallTimer === null) return // already finalized
finalize(engine, false, '传输超时')
}, STALL_TIMEOUT_MS)
engine.stallTimer = timer
}
function wireSession(engine: Engine): void {
const session = engine.session
if (!session) return
const sendToPeer = (octets: number[]): void => {
try {
engine.deps.writeStream(engine.id, Buffer.from(octets))
} catch {
// stream gone mid-transfer
}
}
session.set_sender(sendToPeer)
;(session as unknown as { on: (event: string, cb: () => void) => void }).on('session_end', makeSessionEnd(engine))
touchActivity(engine)
}
// ---------------------------------------------------------------------------
// Sending (remote `rz` -> we upload local files)
// ---------------------------------------------------------------------------
async function sendOneFile(
engine: Engine,
session: Zmodem.SendSession,
file: { path: string; name: string; size: number; mtimeMs: number }
): Promise<void> {
const xfer = await session.send_offer({
name: file.name,
size: file.size,
mtime: new Date(file.mtimeMs),
mode: undefined
})
if (!xfer) return // receiver skipped the file
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
await new Promise<void>((resolve, reject) => {
const rs = createReadStream(file.path)
rs.on('data', (chunk: string | Buffer) => {
rs.pause()
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
try {
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
} catch (err) {
rs.destroy()
reject(err instanceof Error ? err : new Error('发送失败'))
return
}
engine.bytes += buf.length
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
touchActivity(engine)
rs.resume()
})
rs.on('end', () => {
xfer
.end()
.then(() => resolve())
.catch(reject)
})
rs.on('error', reject)
})
}
async function runSend(engine: Engine, paths: string[]): Promise<void> {
try {
const session = engine.session as Zmodem.SendSession
const files = await Promise.all(
paths.map(async (p) => {
const s = await stat(p)
return { path: p, name: basename(p), size: s.size, mtimeMs: s.mtimeMs }
})
)
engine.totalBytes = files.reduce((acc, f) => acc + f.size, 0)
for (const f of files) {
emitProgress(engine, { file: f.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
await sendOneFile(engine, session, f)
}
emitProgress(engine, { file: '', bytes: engine.totalBytes, totalBytes: engine.totalBytes })
// Final state + ZMODEM_DONE come from the session_end event fired by close().
await session.close()
} catch (err) {
finalize(engine, false, err instanceof Error ? err.message : '上传失败')
}
}
// ---------------------------------------------------------------------------
// Receiving (remote `sz` -> we download into a chosen local directory)
// ---------------------------------------------------------------------------
function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
const details = offer.get_details()
const name = basename(String(details.name ?? 'file'))
const dest = join(engine.dir ?? '.', name)
if (details.size) engine.totalBytes += details.size
const stream = createWriteStream(dest)
engine.receiveStream = stream
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
offer
.accept({
on_input: (payload: Uint8Array | number[]) => {
// zmodem.js delivers the payload as a plain octet Array (not a
// Uint8Array); coerce defensively to a Buffer before writing.
const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
if (!stream.destroyed && !stream.closed) {
stream.write(buf)
}
engine.bytes += buf.byteLength
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
touchActivity(engine)
}
})
.then(() => {
stream.end(() => {
if (engine.receiveStream === stream) engine.receiveStream = null
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
})
})
.catch((err: unknown) => {
try {
stream.destroy()
} catch {
// already gone
}
finalize(engine, false, err instanceof Error ? err.message : '接收失败')
})
}
// ---------------------------------------------------------------------------
// Public API (used by pty.ts / ipc.ts)
// ---------------------------------------------------------------------------
/**
* Attach a ZMODEM Sentry to an ssh session. Only call for ssh sessions (see
* module docs: local ptys are unsupported by design). deps are injected by
* pty.ts so this module stays Electron-free.
*/
export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
const engine: Engine = {
id: sessionId,
deps,
sentry: new Zmodem.Sentry({
to_terminal: (octets: number[]) => {
// Analysis: also defensive against active http-parsing leftovers.
if (engine.active) return // suppress binary terminal output during transfer
try {
deps.toTerminal(sessionId, Buffer.from(octets).toString('utf8'))
} catch {
// never crash the event loop
}
},
on_detect: (detection: Zmodem.Detection) => {
const role = detection.get_session_role()
engine.mode = role === 'receive' ? 'receive' : 'send'
engine.detection = detection
engine.active = true
engine.transferId = `zm-${sessionId}`
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
try {
deps.broadcast(Ipc.ZMODEM_OFFER, { id: sessionId, mode: engine.mode })
} catch {
// never crash the event loop
}
touchActivity(engine)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消')
}, OFFER_TIMEOUT_MS)
},
on_retract: () => {
// The detected "session" turned out not to be ZMODEM; return to normal.
engine.detection = null
engine.mode = null
engine.active = false
cancelTimers(engine)
},
sender: (octets: number[]) => {
try {
deps.writeStream(sessionId, Buffer.from(octets))
} catch {
// stream gone mid-transfer
}
}
}),
detection: null,
session: null,
active: false,
mode: null,
dir: null,
confirmed: false,
transferId: `zm-${sessionId}`,
progressEmitted: false,
doneEmitted: false,
offerTimer: null,
stallTimer: null,
receiveStream: null,
bytes: 0,
totalBytes: 0
}
engines.set(sessionId, engine)
}
/**
* Abort an unconfirmed (or pre-session) offer. No live zsession yet, so we write
* the CAN abort sequence directly so the remote program exits.
*/
function abortWithoutSession(engine: Engine, message: string): void {
try {
engine.deps.writeStream(engine.id, ABORT_BUFFER)
} catch {
// stream may be gone
}
engine.active = false
cancelTimers(engine)
if (!engine.progressEmitted) emitProgress(engine, { state: 'cancelled', file: message })
engine.progressEmitted = true
emitDone(engine, false, message)
}
/** Abort a confirmed session cleanly (sends CAN so remote rz/sz exits). */
function abortSession(engine: Engine, message: string): void {
try {
engine.session?.abort()
} catch {
// fall through to write the abort sequence ourselves
}
try {
engine.deps.writeStream(engine.id, ABORT_BUFFER)
} catch {
// stream may be gone
}
finalize(engine, false, message, 'cancelled')
}
/** Feed raw ssh-stream bytes into the engine. Only call when isZmodemActive(). */
export function feedZmodem(sessionId: string, data: Buffer): void {
const engine = current(sessionId)
if (!engine) return
try {
engine.sentry.consume(data)
} catch (err) {
// A zmodem.js Error (peer abort / corrupt frame) ends the session.
if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常')
}
}
/** Whether a zmodem transfer is active (pty.ts suppresses user writes while true). */
export function isZmodemActive(sessionId: string): boolean {
return current(sessionId)?.active ?? false
}
/**
* The renderer answered an offer (via ipc.ts). `cancelled` aborts; otherwise
* the flow proceeds according to the detected mode.
*/
export function respondZmodem(resp: ZmodemResponse): void {
const engine = current(resp.id)
if (!engine || engine.confirmed) return
if (resp.cancelled) {
abortWithoutSession(engine, '已取消')
return
}
const detection = engine.detection
if (!detection || !detection.is_valid()) {
abortWithoutSession(engine, '会话已失效')
return
}
engine.confirmed = true
// The offer was answered — stop the offer watchdog; the stall timer takes over.
if (engine.offerTimer) {
clearTimeout(engine.offerTimer)
engine.offerTimer = null
}
engine.session = detection.confirm()
if (!engine.session) {
abortWithoutSession(engine, '无法建立 ZMODEM 会话')
return
}
wireSession(engine)
if (engine.mode === 'receive') {
if (!resp.dir) {
abortSession(engine, '未指定保存目录')
return
}
engine.dir = resp.dir
const session = engine.session as Zmodem.ReceiveSession
session.on('offer', (offer) => handleOffer(engine, offer))
void session
.start()
.catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败'))
} else {
const paths = resp.paths ?? []
if (paths.length === 0) {
abortSession(engine, '未选择文件')
return
}
void runSend(engine, paths)
}
}
/** Detach + clean up (session close / kill). Safe to call any number of times. */
export function detachZmodem(sessionId: string): void {
const engine = current(sessionId)
if (!engine) return
engine.active = false
cancelTimers(engine)
if (engine.receiveStream) {
try {
engine.receiveStream.end()
} catch {
// ignore
}
engine.receiveStream = null
}
engine.session = null
engine.detection = null
engines.delete(sessionId)
}
+13 -1
View File
@@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
import { Ipc } from '../shared/ipc'
import type { AppSettings } from '../shared/settings'
import type { AppApi } from '../shared/api'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent } from '../shared/ipc'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -84,6 +84,18 @@ const api: AppApi = {
listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST),
saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input),
deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id),
onZmodemOffer: (cb: (e: ZmodemOfferEvent) => void) => {
const listener = (_: unknown, e: ZmodemOfferEvent): void => cb(e)
ipcRenderer.on(Ipc.ZMODEM_OFFER, listener)
return () => ipcRenderer.removeListener(Ipc.ZMODEM_OFFER, listener)
},
zmodemRespond: (resp: ZmodemResponse) => ipcRenderer.send(Ipc.ZMODEM_RESPOND, resp),
onZmodemDone: (cb: (e: ZmodemDoneEvent) => void) => {
const listener = (_: unknown, e: ZmodemDoneEvent): void => cb(e)
ipcRenderer.on(Ipc.ZMODEM_DONE, listener)
return () => ipcRenderer.removeListener(Ipc.ZMODEM_DONE, listener)
},
onHostKeyPrompt: (cb: (e: HostKeyPromptEvent) => void) => {
const listener = (_: unknown, e: HostKeyPromptEvent): void => cb(e)
ipcRenderer.on(Ipc.HOSTKEY_PROMPT, listener)
+59
View File
@@ -1,9 +1,12 @@
import ReactDOM from 'react-dom/client'
import { useEffect } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import { DEFAULT_SETTINGS } from '@shared/settings'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import './global.css'
// Dev convenience: allow opening the renderer in a plain browser (vite page)
@@ -69,6 +72,9 @@ if (typeof window !== 'undefined' && !window.api) {
savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false }
}),
deleteConnection: async () => undefined,
onZmodemOffer: () => noop,
zmodemRespond: noop,
onZmodemDone: () => noop,
onHostKeyPrompt: () => noop,
respondHostKey: noop,
getSettings: async () => DEFAULT_SETTINGS,
@@ -83,6 +89,57 @@ if (typeof window !== 'undefined' && !window.api) {
;(window as unknown as { api: AppApi }).api = api
}
const MIN_FONT = 8
const MAX_FONT = 40
const RESET_FONT = 14
/**
* Window-level font-size hotkeys (Ctrl+Equal/+ / Ctrl+Minus/- / Ctrl+Digit0).
* Uses the same updateTerminal store path as the settings dialog so TerminalView
* re-renders immediately. Skipped while typing in an input/textarea/contentEditable.
*/
function FontHotkeyListener(): null {
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (!e.ctrlKey || e.metaKey) return
const target = e.target as HTMLElement | null
const isXtermHelper =
target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea')
if (
target &&
!isXtermHelper &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return
}
let delta: number | undefined
if (e.key === '=' || e.key === '+') delta = 1
else if (e.key === '-') delta = -1
else if (e.key === '0') delta = 0
if (delta === undefined) return
e.preventDefault()
e.stopPropagation()
const current = useSettingsStore.getState().settings.terminal.fontSize
const next =
delta === 0
? RESET_FONT
: Math.min(MAX_FONT, Math.max(MIN_FONT, current + delta))
void updateTerminal({ fontSize: next })
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [updateTerminal])
return null
}
// antd <App> provides the context that App.useApp() consumers (workspace
// message/notification) rely on; without it they get empty stubs and crash.
// ErrorBoundary keeps any render crash from black-screening the whole window.
@@ -96,6 +153,8 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<AntdApp>
<ErrorBoundary>
<App />
<ZmodemOffers />
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ConfigProvider>
+17 -2
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { InputNumber, Radio, Select, Switch } from 'antd'
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
import type { ThemeColors } from '@shared/theme'
import { useSettingsStore, useResolvedTheme } from './store'
import {
@@ -84,7 +84,7 @@ export function FontSettingsTab(): React.JSX.Element {
<InputNumber
className="settings-input-number"
min={8}
max={32}
max={40}
value={settings.terminal.fontSize}
onChange={(v) => {
if (v !== null) void updateTerminal({ fontSize: v })
@@ -307,6 +307,21 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="全局唤起快捷键"
desc="Control+Shift+Alt+T 等,留空禁用"
control={
<Input
className="settings-select"
placeholder="如 Control+Shift+Alt+T,留空禁用"
value={settings.system.globalShowHide ?? ''}
onChange={(e) => void updateSystem({ globalShowHide: e.target.value })}
/>
}
/>
<div className="settings-block-hint settings-shortcut-hint">
配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。
</div>
</div>
)
}
+4
View File
@@ -106,6 +106,10 @@
margin: -8px 0 16px;
}
.settings-shortcut-hint {
margin: 2px 8px 0;
}
/* ---------- font tab: live terminal preview ---------- */
.settings-preview {
+7 -3
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons'
import type { TransferProgressEvent } from '@shared/sftp'
import type { TransferKind, TransferProgressEvent } from '@shared/sftp'
interface TransferState {
kind: 'upload' | 'download'
kind: TransferKind
file: string
bytes: number
totalBytes: number
@@ -89,7 +89,11 @@ export function TransferPanel(): React.JSX.Element | null {
? '传输完成'
: t.kind === 'upload'
? '上传中'
: '下载中'}
: t.kind === 'download'
? '下载中'
: t.kind === 'zmodem-upload'
? 'ZMODEM 上传中'
: 'ZMODEM 下载中'}
</span>
{(t.state === 'error' || t.state === 'done') && (
<button
+6 -6
View File
@@ -12,6 +12,7 @@ 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'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import './terminal.css'
@@ -143,12 +144,11 @@ function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] {
/** 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))
writeBroadcast(sessionId, full.slice(buf.length))
} else {
obj.writePty(sessionId, '\x03')
obj.writePty(sessionId, full)
writeBroadcast(sessionId, '\x03')
writeBroadcast(sessionId, full)
}
}
@@ -288,7 +288,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const data = nativePasteRef.current ?? text
nativePasteRef.current = null
setPaste(null)
if (!deadRef.current) window.api.writePty(sessionId, data)
if (!deadRef.current) writeBroadcast(sessionId, data)
termRef.current?.focus()
},
[sessionId]
@@ -518,7 +518,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
const disposables: { dispose(): void }[] = [
term.onData((data) => {
if (!deadRef.current) window.api.writePty(sessionId, data)
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: line capture → recordCommand + inline completion overlay.
const step = stepLineBuffer(lineBufRef, data)
if (typeof step.submit === 'string' && step.submit) {
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { App, Input } from 'antd'
import { create } from 'zustand'
import type { CommandItem } from '@shared/commands'
import { writeBroadcast } from './broadcastStore'
/** Open state shared between the tab-bar toggle button and the floating panel. */
export const useQuickInputStore = create<{
open: boolean
toggle: () => void
setOpen: (open: boolean) => void
}>((set) => ({
open: window.localStorage.getItem('ot.quickInputOpen') === '1',
toggle: () =>
set((state) => {
const next = !state.open
window.localStorage.setItem('ot.quickInputOpen', next ? '1' : '0')
return { open: next }
}),
setOpen: (open) => {
window.localStorage.setItem('ot.quickInputOpen', open ? '1' : '0')
set({ open })
}
}))
/**
* M6 quick-input panel (bottom-right floating). Three sections:
* 1. free-text command line (with auto-execute(\r) toggle)
* 2. library commands (api.listLibrary())
* 3. recent history (api.listHistory(), top 10)
*
* Sends target = the active session via `onResolveActiveSession`; when broadcast
* is enabled and the active session is a target, the send routes through the
* broadcast fan-out so it reaches every target. Lists refresh on open.
*/
export interface QuickInputPanelProps {
/** resolve the currently active terminal session id ('' when none) */
onResolveActiveSession: () => string
}
export function QuickInputPanel({ onResolveActiveSession }: QuickInputPanelProps): React.JSX.Element | null {
const { message } = App.useApp()
const open = useQuickInputStore((s) => s.open)
const close = useQuickInputStore((s) => s.setOpen)
// auto-execute(\r); persisted across sessions.
const [autoEnter, setAutoEnter] = useState<boolean>(() => window.localStorage.getItem('ot.quickInputEnter') !== '0')
const [value, setValue] = useState('')
const [history, setHistory] = useState<CommandItem[]>([])
const [library, setLibrary] = useState<CommandItem[]>([])
/** tracks the previous `open` so lists refresh on the false→true transition */
const prevOpenRef = useRef(open)
// Refresh lists every time the panel opens (and stay fresh if it was open).
const refresh = useCallback(async (): Promise<void> => {
try {
const [h, l] = await Promise.all([window.api.listHistory(), window.api.listLibrary()])
setHistory(h.slice(0, 10))
setLibrary(l)
} catch {
setHistory([])
setLibrary([])
}
}, [])
useEffect(() => {
if (open && !prevOpenRef.current) void refresh()
prevOpenRef.current = open
}, [open, refresh])
const send = useCallback(
(text: string) => {
const target = onResolveActiveSession()
if (!target) {
void message.info('没有激活的终端会话')
return
}
const data = autoEnter ? `${text}\r` : text
writeBroadcast(target, data)
// keep "最近命令" fresh immediately after a send
void window.api
.listHistory()
.then((h: CommandItem[]) => setHistory(h.slice(0, 10)))
.catch(() => undefined)
setValue('')
},
[autoEnter, onResolveActiveSession]
)
const onEnter = useCallback((): void => {
const t = value.trim()
if (t) send(t)
}, [value, send])
const onToggleAutoEnter = useCallback((checked: boolean): void => {
setAutoEnter(checked)
window.localStorage.setItem('ot.quickInputEnter', checked ? '1' : '0')
}, [])
if (!open) return null
return (
<div className="quick-input-panel">
<div className="quick-input-headbar">
<span className="quick-input-headbar-title">快捷输入</span>
<button type="button" className="quick-input-close" title="收起" aria-label="收起" onClick={() => close(false)}>
×
</button>
</div>
<div className="quick-input-row">
<Input
className="quick-input-field"
placeholder="输入命令…"
value={value}
spellCheck={false}
autoFocus
onChange={(e) => setValue(e.target.value)}
onPressEnter={onEnter}
/>
<label className="quick-input-autoenter" title="关闭后只键入不回车">
<input
type="checkbox"
checked={autoEnter}
onChange={(e) => onToggleAutoEnter(e.target.checked)}
/>
<span>自动执行(\r)</span>
</label>
</div>
<div className="quick-input-section">
<div className="quick-input-head">快捷命令</div>
{library.length === 0 ? (
<div className="quick-input-empty">暂无快捷命令</div>
) : (
<div className="quick-input-list">
{library.map((item) => (
<button
key={item.id}
type="button"
className="quick-input-item"
title={item.command}
onClick={() => send(item.command)}
>
<span className="quick-input-item-cmd">{item.command}</span>
{item.name && <span className="quick-input-item-tag">{item.name}</span>}
</button>
))}
</div>
)}
</div>
<div className="quick-input-section">
<div className="quick-input-head">最近命令</div>
{history.length === 0 ? (
<div className="quick-input-empty">暂无历史记录</div>
) : (
<div className="quick-input-list">
{history.map((item) => (
<button
key={item.id}
type="button"
className="quick-input-item"
title={item.command}
onClick={() => send(item.command)}
>
<span className="quick-input-item-cmd">{item.command}</span>
</button>
))}
</div>
)}
</div>
</div>
)
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { IDockviewPanelProps } from 'dockview-react'
import { Button, Tabs } from 'antd'
import { AreaChartOutlined, FolderOpenOutlined } from '@ant-design/icons'
@@ -6,6 +6,7 @@ import { AreaChartOutlined, FolderOpenOutlined } from '@ant-design/icons'
import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
import { FilePanel } from '../sftp/FilePanel'
import { useBroadcastStore } from './broadcastStore'
import type { TerminalParams } from './Workspace'
export interface TerminalPanelExtraProps {
@@ -38,6 +39,22 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
/** Right sidebar visibility for ssh sessions — expanded by default. */
const [sideOpen, setSideOpen] = useState(true)
// M6 broadcast: keep the session in the broadcast registry while mounted so
// it can be picked as a target / receive fan-out writes (idempotent upsert).
useEffect(() => {
useBroadcastStore.getState().registerSession({ id: sessionId, title: api.title ?? '', isSsh })
return () => useBroadcastStore.getState().unregisterSession(sessionId)
// eslint-disable-next-line react-hooks/exhaustive-deps -- api is stable; title kept fresh below
}, [sessionId])
// M6: keep the registered broadcast title in sync with the tab title.
useEffect(() => {
const disposable = api.onDidTitleChange((event) => {
useBroadcastStore.getState().registerSession({ id: sessionId, title: event.title, isSsh })
})
return () => disposable.dispose()
}, [api, sessionId, isSsh])
return (
<div className="workspace-terminal-panel">
{isSsh && (
@@ -4,6 +4,7 @@ import type { MenuProps } from 'antd'
import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -28,6 +29,8 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const selfIndex = panel.panels.findIndex((p) => p.api === api)
const isSsh = params?.sessionKind === 'ssh'
// M6 broadcast: show a dot on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
// Keep the tab title live (dockview re-renders tab components only on
// params changes; title updates arrive through this event).
@@ -68,6 +71,13 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
<div className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title="广播目标"
aria-label="广播目标"
/>
)}
<button
type="button"
className="workspace-terminal-tab-close"
+177 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { App, Button, Dropdown, Tooltip } from 'antd'
import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import {
ColumnHeightOutlined,
@@ -9,7 +9,9 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
SettingOutlined
SettingOutlined,
ShareAltOutlined,
ThunderboltOutlined
} from '@ant-design/icons'
import { DockviewReact } from 'dockview-react'
import type {
@@ -30,6 +32,8 @@ import { ConnectFlow } from './ConnectFlow'
import { HostKeyModal } from './HostKeyModal'
import { TerminalPanel } from './TerminalPanel'
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
import './workspace.css'
export interface WorkspaceProps {
@@ -225,6 +229,42 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
})
}, [])
/**
* M6: Ctrl+PgUp / Ctrl+PgDn switch between terminal tabs. Registered on
* `window` with capture:true so it runs before xterm's own key handling;
* Ctrl+PageDown/PageUp are the OS default terminal-tab chords, which the
* shell rarely needs, so we intercept them and cycle the active panel.
*/
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
const ctrl = e.ctrlKey
const code = e.code
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return
const current = apiRef.current
if (!current) return
const panels = current.panels
if (panels.length < 2) return
e.preventDefault()
e.stopPropagation()
const active = current.activePanel ?? activePanelRef.current
let idx = active ? panels.findIndex((p) => p.id === active.id) : -1
const step = code === 'PageDown' ? 1 : -1
// wrap around the ordered panel list; skip non-terminal panels (unlikely —
// every dockview panel here is a terminal) for robustness.
let next = active
for (let i = 1; i <= panels.length; i++) {
const candidate = panels[(idx + step * i + panels.length * 2) % panels.length]
if (sessionIdOf(candidate)) {
next = candidate
break
}
}
if (next && next !== active) next.api.setActive()
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [])
/** Add a terminal panel; `direction` splits relative to the active panel. */
const addTerminal = useCallback(
async (direction?: 'right' | 'below'): Promise<void> => {
@@ -407,7 +447,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
message.info('当前激活面板没有可用的会话')
return
}
window.api.writePty(sid, `${cmd}\r`)
writeBroadcast(sid, `${cmd}\r`)
panel.api.setActive()
},
[message]
@@ -424,6 +464,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}
}
/** Session id of the currently active terminal panel ('' when none). */
const resolveActiveSessionId = useCallback((): string => {
const panel = activePanelRef.current
if (!panel) return ''
return sessionIdOf(panel) ?? ''
}, [])
return (
<div className="workspace-root">
<IconRail
@@ -460,7 +507,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
<TerminalTab {...props} />
)
}}
rightHeaderActionsComponent={GroupAddButton}
rightHeaderActionsComponent={WorkspaceTabActions}
onReady={handleReady}
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
/>
@@ -477,6 +524,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
</div>
</div>
{/* M6: floating quick-input panel (bottom-right). Sends to the active
session; broadcast target → fan-out. Sits above the sftp transfer
overlay's 14px bottom offset so the two never overlap. */}
<QuickInputPanel onResolveActiveSession={resolveActiveSessionId} />
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
{requestedConn != null && (
<ConnectFlow
@@ -509,10 +561,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}
/**
* "+" rendered at the end of every group's tab strip: creates a terminal as a
* new tab inside THAT group (XTerminal-style per-pane tab addition).
* Right-side actions for every group's tab strip: the "+" new-terminal button
* (per-group), plus two workspace-level M6 toggles — broadcast target picker
* and quick-input panel — so they're visible in the tab bar.
*/
function GroupAddButton({
function WorkspaceTabActions({
containerApi,
group
}: IDockviewHeaderActionsProps): React.JSX.Element {
@@ -530,19 +583,135 @@ function GroupAddButton({
})()
}
return (
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<QuickInputToggleButton />
</div>
)
}
/** "+" renders the new-terminal button (per-group tab addition). */
function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element {
return (
<button
type="button"
className="workspace-tabbar-add"
title="在此分屏中新建终端"
aria-label="在此分屏中新建终端"
onClick={handleAdd}
onClick={onClick}
>
<PlusOutlined />
</button>
)
}
/** 广播 target picker: toggle + Popover with per-session checkboxes. */
function BroadcastToggleButton(): React.JSX.Element {
const enabled = useBroadcastStore((s) => s.enabled)
const targets = useBroadcastStore((s) => s.targets)
const sessions = useBroadcastStore((s) => s.sessions)
const toggleTarget = useBroadcastStore((s) => s.toggleTarget)
const setEnabled = useBroadcastStore((s) => s.setEnabled)
const cls = [
'workspace-tabbar-btn2',
enabled ? ' is-active' : ''
].join('')
return (
<Popover
trigger="click"
placement="bottomRight"
content={
<WorkspaceBroadcastPopover
enabled={enabled}
targets={targets}
sessions={sessions}
onToggleTarget={toggleTarget}
onSetEnabled={setEnabled}
/>
}
>
<button
type="button"
className={cls}
title={enabled ? `广播中 (${targets.size} 个目标)` : '广播输入'}
aria-label="广播输入"
aria-pressed={enabled}
>
<ShareAltOutlined />
{enabled && <span className="workspace-tabbar-bcast-count">{targets.size}</span>}
</button>
</Popover>
)
}
/** Popover body: list open sessions with a checkbox each; enable gate >= 2. */
function WorkspaceBroadcastPopover({
enabled,
targets,
sessions,
onToggleTarget,
onSetEnabled
}: {
enabled: boolean
targets: Set<string>
sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void
}): React.JSX.Element {
const label = (s: { id: string; title: string; isSsh: boolean }): string =>
`${s.title}${s.isSsh ? ' (SSH)' : ''}`
return (
<div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">广播开关</span>
<Checkbox
checked={enabled}
disabled={targets.size < 2}
onChange={(e) => onSetEnabled(e.target.checked)}
>
{enabled ? `已启用 (${targets.size})` : targets.size < 2 ? '需至少选择 2 个目标' : '未启用'}
</Checkbox>
</div>
<div className="workspace-bcast-pop-list">
{sessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">暂无打开的终端</div>
) : (
sessions.map((s) => (
<div key={s.id} className="workspace-bcast-pop-item">
<Checkbox checked={targets.has(s.id)} onChange={() => onToggleTarget(s.id)}>
{label(s)}
</Checkbox>
</div>
))
)}
</div>
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。</div>
</div>
)
}
/** 快捷输入 panel toggle (ThunderboltOutlined) — state lives in the shared store. */
function QuickInputToggleButton(): React.JSX.Element {
const toggle = useQuickInputStore((s) => s.toggle)
const open = useQuickInputStore((s) => s.open)
return (
<Tooltip title={open ? '收起快捷输入面板' : '打开快捷输入面板'}>
<button
type="button"
className={['workspace-tabbar-btn2', open ? ' is-active' : ''].join('')}
aria-label="快捷输入面板"
aria-pressed={open}
onClick={toggle}
>
<ThunderboltOutlined />
</button>
</Tooltip>
)
}
/**
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
* Top: workspace actions; bottom: settings.
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { App as AntdApp, Button, Modal, Space, Typography } from 'antd'
import type { ZmodemOfferEvent, ZmodemResponse } from '@shared/ipc'
/**
* M6 ZMODEM offer/done UX: renders one dialog per active zmodem offer.
*
* - mode='send' : remote ran `rz` -> user picks local files to upload.
* - mode='receive': remote ran `sz` -> user picks a local save directory.
*
* Binary details never touch the renderer; it only answers offers (paths/dir)
* and shows completion via App.useApp().message. Progress reuses the shared
* transfer panel (kind 'zmodem-upload' / 'zmodem-download').
*/
export default function ZmodemOffers(): null | React.JSX.Element {
const { message } = AntdApp.useApp()
const [offers, setOffers] = useState<Map<string, ZmodemOfferEvent>>(new Map())
const busyRef = useRef<Set<string>>(new Set())
const respond = useCallback((resp: ZmodemResponse) => {
window.api.zmodemRespond(resp)
busyRef.current.delete(resp.id)
setOffers((prev) => {
const next = new Map(prev)
next.delete(resp.id)
return next
})
}, [])
const handleCancel = useCallback(
(id: string) => respond({ id, cancelled: true }),
[respond]
)
const handlePickFiles = useCallback(
async (id: string) => {
if (busyRef.current.has(id)) return
busyRef.current.add(id)
try {
const paths = await window.api.pickFiles()
if (paths.length > 0) {
respond({ id, cancelled: false, paths })
} else {
busyRef.current.delete(id)
}
} catch {
busyRef.current.delete(id)
}
},
[respond]
)
const handlePickDir = useCallback(
async (id: string) => {
if (busyRef.current.has(id)) return
busyRef.current.add(id)
try {
const dir = await window.api.pickDirectory()
if (dir) {
respond({ id, cancelled: false, dir })
} else {
busyRef.current.delete(id)
}
} catch {
busyRef.current.delete(id)
}
},
[respond]
)
// queue: more than one offer may arrive before the user answers the first
useEffect(() => {
const off = window.api.onZmodemOffer((evt: ZmodemOfferEvent) => {
setOffers((prev) => {
const next = new Map(prev)
next.set(evt.id, evt)
return next
})
})
return off
}, [])
const offDone = useCallback(
(evt: { id: string; ok: boolean; message?: string }) => {
if (evt.ok) message.success('ZMODEM 传输完成')
else message.error(`ZMODEM 传输失败:${evt.message ?? '未知错误'}`)
},
[message]
)
useEffect(() => {
const off = window.api.onZmodemDone(offDone)
return off
}, [offDone])
const pending = [...offers.values()]
const first = pending[0]
if (!first) return null
const isReceive = first.mode === 'receive'
return (
<Modal
open
closable={false}
maskClosable={false}
keyboard={false}
title={isReceive ? '下载到本地' : '上传到服务器'}
footer={
<Space>
<Button
type="primary"
onClick={() => (isReceive ? void handlePickDir(first.id) : void handlePickFiles(first.id))}
>
{isReceive ? '选择保存目录' : '选择文件'}
</Button>
<Button onClick={() => handleCancel(first.id)}>取消</Button>
</Space>
}
>
<Typography.Text>
{isReceive
? '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。'
: '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。'}
</Typography.Text>
</Modal>
)
}
@@ -0,0 +1,100 @@
import { create } from 'zustand'
/** One open terminal session eligible to be a broadcast target. */
export interface BroadcastSession {
/** pty / ssh session id (also the dockview panel id) */
id: string
title: string
isSsh: boolean
}
export interface BroadcastState {
/** master switch; only settable while there are >= 2 open sessions */
enabled: boolean
/** session ids receiving broadcast input */
targets: Set<string>
/** open terminal sessions, registration order preserved */
sessions: BroadcastSession[]
/** register a session as broadcast-eligible (idempotent, keeps it fresh) */
registerSession: (session: BroadcastSession) => void
/** remove a session; if it was a target, prune it (may auto-disable <2 targets) */
unregisterSession: (id: string) => void
/** add/remove one target; never lets the set drop below 2 while enabled */
toggleTarget: (id: string) => void
/** turn broadcast on/off */
setEnabled: (enabled: boolean) => void
}
/**
* Resolve the set of sessions a keypress should be written to.
* - Broadcast disabled OR the originating session is not a target → the
* session alone (existing single-write behaviour; the origin's own fallback
* echo is handled by individual pty instances).
* - Broadcast enabled and origin is a target → every target session.
*/
export function broadcastFanOut(originId: string, targets: Set<string>, enabled: boolean): string[] {
if (!enabled) return [originId]
if (!targets.has(originId)) return [originId]
return [...targets]
}
function pruneOnTargetLoss(enabled: boolean, nextTargets: Set<string>): { enabled: boolean; targets: Set<string> } {
if (nextTargets.size < 2) return { enabled: false, targets: new Set() }
return { enabled, targets: nextTargets }
}
export const useBroadcastStore = create<BroadcastState>((set) => ({
enabled: false,
targets: new Set(),
sessions: [],
registerSession: (session) =>
set((state) => {
const exists = state.sessions.some((s) => s.id === session.id)
if (exists) {
// keep title/isSsh fresh (e.g. template apply retitles a panel)
const sessions = state.sessions.map((s) => (s.id === session.id ? session : s))
const targets = new Set(state.targets)
return { sessions, targets }
}
return { sessions: [...state.sessions, session] }
}),
unregisterSession: (id) =>
set((state) => {
const sessions = state.sessions.filter((s) => s.id !== id)
if (!state.targets.has(id)) return { sessions }
const targets = new Set(state.targets)
targets.delete(id)
return { sessions, ...pruneOnTargetLoss(state.enabled, targets) }
}),
toggleTarget: (id) =>
set((state) => {
const targets = new Set(state.targets)
if (targets.has(id)) {
targets.delete(id)
return pruneOnTargetLoss(state.enabled, targets)
}
targets.add(id)
// requires >= 2 targets to become/become-enabled
if (targets.size < 2) return { enabled: false, targets }
return { enabled: true, targets }
}),
setEnabled: (enabled) =>
set((state) => {
if (enabled && state.targets.size < 2) return { enabled: state.enabled }
return { enabled }
})
}))
/** Write `data` to the full broadcast fan-out for an originating session. */
export function writeBroadcast(originId: string, data: string): void {
const { targets, enabled, sessions } = useBroadcastStore.getState()
const out = broadcastFanOut(originId, targets, enabled)
for (const id of out) {
// never write to a session that is no longer open
if (sessions.some((s) => s.id === id)) window.api.writePty(id, data)
}
}
+246
View File
@@ -294,6 +294,252 @@
color: #ffffff;
}
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
.workspace-tabbar-actions {
display: flex;
align-items: center;
gap: 2px;
}
.workspace-tabbar-btn2 {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
width: 24px;
height: 24px;
margin: 0 2px;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: #8a8a8a;
font-size: 11px;
cursor: pointer;
}
.workspace-tabbar-btn2:hover {
background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31));
color: #ffffff;
}
.workspace-tabbar-btn2.is-active {
color: #3fb950;
background: rgba(63, 185, 80, 0.12);
}
.workspace-tabbar-bcast-count {
font-size: 10px;
font-weight: 600;
color: #3fb950;
line-height: 1;
}
/* ---- broadcast popover ---- */
.workspace-bcast-pop {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 220px;
}
.workspace-bcast-pop-switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--chrome-border);
}
.workspace-bcast-pop-label {
font-size: 12px;
color: var(--chrome-fg);
}
.workspace-bcast-pop-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
}
.workspace-bcast-pop-item {
padding: 2px 0;
}
.workspace-bcast-pop-item .ant-checkbox-wrapper {
font-size: 12px;
color: var(--chrome-fg);
}
.workspace-bcast-pop-empty {
padding: 10px 0;
text-align: center;
color: #8a8a8a;
font-size: 12px;
}
.workspace-bcast-pop-hint {
font-size: 11px;
color: #8a8a8a;
line-height: 1.4;
border-top: 1px solid var(--chrome-border);
padding-top: 6px;
}
/* ---- tab broadcast-target dot ---- */
.workspace-terminal-tab-bcast {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3fb950;
box-shadow: 0 0 4px rgba(63, 185, 80, 0.8);
}
/* ---- M6 quick-input floating panel (bottom-right) ----
bottom: 44px so it clears the sftp transfer overlay (right/bottom 14px). */
.quick-input-panel {
position: fixed;
right: 14px;
bottom: 44px;
z-index: 950;
width: 320px;
max-height: 60vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #1f1f1f;
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
color: var(--chrome-fg);
}
.quick-input-headbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.quick-input-headbar-title {
font-size: 12px;
font-weight: 600;
color: var(--chrome-fg);
}
.quick-input-close {
border: none;
background: none;
color: #8a8a8a;
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.quick-input-close:hover {
color: #ffffff;
}
.quick-input-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.quick-input-field .ant-input {
background: var(--chrome-bg);
border-color: var(--chrome-border);
color: var(--chrome-fg);
}
.quick-input-autoenter {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #9a9a9a;
cursor: pointer;
user-select: none;
}
.quick-input-autoenter input {
accent-color: var(--chrome-accent);
}
.quick-input-section {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.quick-input-head {
font-size: 11px;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.quick-input-list {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 140px;
overflow-y: auto;
}
.quick-input-item {
display: flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: var(--chrome-fg);
font: inherit;
font-size: 12px;
text-align: left;
padding: 3px 6px;
border-radius: 4px;
cursor: pointer;
min-width: 0;
}
.quick-input-item:hover {
background: rgba(59, 142, 234, 0.18);
color: #ffffff;
}
.quick-input-item-cmd {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
}
.quick-input-item-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);
}
.quick-input-empty {
padding: 8px 4px;
color: #8a8a8a;
font-size: 12px;
text-align: center;
}
/* ---- custom tab (title + close + context menu) ---- */
.workspace-terminal-tab {
position: relative;
+6
View File
@@ -18,6 +18,7 @@ import type {
import type { SysinfoMeta, SysinfoSample } from './sysinfo'
import type { SftpEntry, TransferProgressEvent } from './sftp'
import type { CommandItem, SessionLogMeta } from './commands'
import type { ZmodemDoneEvent, ZmodemOfferEvent, ZmodemResponse } from './ipc'
/**
* The single API surface exposed on `window.api` by the preload script.
@@ -85,6 +86,11 @@ export interface AppApi {
onHostKeyPrompt(cb: (e: HostKeyPromptEvent) => void): () => void
respondHostKey(promptId: string, action: HostKeyAction): void
// ---- zmodem (M6: main-process engine over ssh sessions; progress reuses transfer events) ----
onZmodemOffer(cb: (e: ZmodemOfferEvent) => void): () => void
zmodemRespond(resp: ZmodemResponse): void
onZmodemDone(cb: (e: ZmodemDoneEvent) => void): () => void
// ---- settings ----
getSettings(): Promise<AppSettings>
saveSettings(next: AppSettings): Promise<AppSettings>
+31
View File
@@ -81,6 +81,14 @@ export const Ipc = {
LOG_LIST: 'log:list',
LOG_OPEN_DIR: 'log:openDir',
// ---- zmodem (M6: main-process engine, ssh sessions only) ----
/** main -> renderer: remote ran rz (mode=send) or sz (mode=receive); renderer must ask the user */
ZMODEM_OFFER: 'zmodem:offer',
/** renderer -> main: user answered the zmodem offer */
ZMODEM_RESPOND: 'zmodem:respond',
/** main -> renderer: zmodem session finished (ok, cancelled or failed) */
ZMODEM_DONE: 'zmodem:done',
// ---- misc ----
APP_INFO: 'app:info'
} as const
@@ -128,3 +136,26 @@ export interface HostKeyResponse {
promptId: string
action: 'accept' | 'reject'
}
// ---- zmodem (M6) ----
/** 'send' = remote ran `rz`, we upload files; 'receive' = remote ran `sz`, we download */
export interface ZmodemOfferEvent {
id: string
mode: 'send' | 'receive'
}
export interface ZmodemResponse {
id: string
cancelled: boolean
/** local files to upload (mode=send) */
paths?: string[]
/** local directory to save into (mode=receive) */
dir?: string
}
export interface ZmodemDoneEvent {
id: string
ok: boolean
/** human-readable failure reason when ok=false */
message?: string
}
+3 -1
View File
@@ -41,6 +41,8 @@ export interface SystemSettings {
launchAtLogin: boolean
/** hold a powerSaveBlocker so the OS never auto-suspends while running */
preventSleep: boolean
/** Electron accelerator that toggles window visibility; '' = disabled (M6) */
globalShowHide?: string
}
export interface AppSettings {
@@ -95,5 +97,5 @@ export const DEFAULT_SETTINGS: AppSettings = {
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
system: { launchAtLogin: false, preventSleep: false }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface SftpEntry {
gid?: number
}
export type TransferKind = 'upload' | 'download'
export type TransferKind = 'upload' | 'download' | 'zmodem-upload' | 'zmodem-download'
export type TransferState = 'running' | 'done' | 'error' | 'cancelled'
export interface TransferProgressEvent {
+236
View File
@@ -0,0 +1,236 @@
// src/renderer/src/terminal/highlightEngine.ts
var ESCAPE_RE = /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g;
var MAX_PER_RULE = 300;
var MAX_CHUNK = 512 * 1024;
function hexToRgb(hex) {
let h = (hex || "").trim().replace(/^#/, "");
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 };
const n = parseInt(h, 16);
return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
}
function tokenize(input) {
const out = [];
let acc = "";
let i = 0;
let m;
while ((m = ESCAPE_RE.exec(input)) !== null) {
if (m.index > i) acc += input.slice(i, m.index);
if (acc.length > 0) {
out.push({ kind: "text", value: acc });
acc = "";
}
out.push({ kind: "seq", value: m[0] });
i = m.index + m[0].length;
}
if (i < input.length) acc += input.slice(i);
if (acc.length > 0) out.push({ kind: "text", value: acc });
return out;
}
function compileRules(rules2) {
const out = [];
for (const rule of rules2) {
if (!rule || rule.enabled === false) continue;
const pattern = rule.pattern;
if (typeof pattern !== "string" || pattern.length === 0) continue;
let regex;
try {
regex = new RegExp(pattern, "g");
} catch {
continue;
}
const fg = typeof rule.color?.fg === "string" ? rule.color.fg : "";
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue;
const bg = typeof rule.color?.bg === "string" ? rule.color.bg : void 0;
out.push({ id: rule.id, regex, fg, bg, priority: rule.priority });
}
out.sort((a, b) => a.priority - b.priority);
return out;
}
function wrap(text, fgHex, bgHex) {
const f = hexToRgb(fgHex);
let s = `\x1B[38;2;${f.r};${f.g};${f.b}`;
if (bgHex) {
const b = hexToRgb(bgHex);
s += `;48;2;${b.r};${b.g};${b.b}`;
}
return `${s}m${text}\x1B[0m`;
}
function overlaps(claimed, s, e) {
for (const c of claimed) {
if (s < c.end && e > c.start) return true;
}
return false;
}
function applyRun(text, rules2, budgets) {
if (text.length === 0) return "";
const claimed = [];
for (let ri = 0; ri < rules2.length; ri++) {
const rule = rules2[ri];
if (budgets[ri] <= 0) continue;
rule.regex.lastIndex = 0;
let m;
let made = 0;
while ((m = rule.regex.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
if (end <= start) {
rule.regex.lastIndex++;
continue;
}
if (!overlaps(claimed, start, end)) {
claimed.push({ start, end, fg: rule.fg, bg: rule.bg });
made++;
budgets[ri]--;
if (budgets[ri] <= 0) break;
}
if (rule.regex.lastIndex === start) rule.regex.lastIndex++;
}
}
if (claimed.length === 0) return text;
claimed.sort((a, b) => a.start - b.start);
let out = "";
let pos = 0;
for (const c of claimed) {
if (c.start > pos) out += text.slice(pos, c.start);
out += wrap(text.slice(c.start, c.end), c.fg, c.bg);
pos = c.end;
}
if (pos < text.length) out += text.slice(pos);
return out;
}
function applyHighlights(chunk, rules2) {
if (!chunk || rules2.length === 0) return chunk;
if (chunk.length > MAX_CHUNK) return chunk;
let budgets = rules2.map(() => MAX_PER_RULE);
let usedBudget = false;
let out = "";
for (const token of tokenize(chunk)) {
if (token.kind === "seq") {
out += token.value;
continue;
}
if (token.value.includes("\x1B")) {
out += token.value;
continue;
}
const before = budgets.reduce((a, b) => a + b, 0);
out += applyRun(token.value, rules2, budgets);
const after = budgets.reduce((a, b) => a + b, 0);
if (after < before) usedBudget = true;
}
return usedBudget ? out : chunk;
}
function isIncompleteEscape(seg) {
if (seg === "\x1B") return true;
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true;
if (seg.startsWith("\x1B]") && !seg.includes("\x07") && !seg.includes("\x1B\\")) return true;
return false;
}
function lastEscapeEnd(input) {
let end = -1;
let m;
ESCAPE_RE.lastIndex = 0;
while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length;
return end;
}
function findIncompleteIndex(input) {
const idx = input.lastIndexOf("\x1B");
if (idx < 0) return -1;
return isIncompleteEscape(input.slice(idx)) ? idx : -1;
}
var MAX_CARRY = 1024;
var HighlightStream = class {
rules;
buffer = "";
constructor(rules2) {
this.rules = rules2;
}
/** Swap the active rules without touching any buffered content. */
setRules(rules2) {
this.rules = rules2;
}
/** True while content is held back (a pending flush will release it). */
hasPending() {
return this.buffer.length > 0;
}
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
push(chunk) {
this.buffer += chunk;
const incompleteAt = findIncompleteIndex(this.buffer);
if (incompleteAt >= 0) {
const head2 = this.buffer.slice(0, incompleteAt);
const tail = this.buffer.slice(incompleteAt);
if (tail.length > MAX_CARRY) {
this.buffer = "";
return applyHighlights(head2, this.rules) + tail;
}
this.buffer = tail;
return applyHighlights(head2, this.rules);
}
const lastEnd = lastEscapeEnd(this.buffer);
if (lastEnd < 0) {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer;
this.buffer = "";
return applyHighlights(raw, this.rules);
}
return "";
}
const head = this.buffer.slice(0, lastEnd);
this.buffer = this.buffer.slice(lastEnd);
return applyHighlights(head, this.rules);
}
/** Flush any held content (session end) — highlighted as a final run. */
flush() {
const b = this.buffer;
this.buffer = "";
return applyHighlights(b, this.rules);
}
};
// tests/hl-split-smoke.mjs
var rules = compileRules([
{ id: "okstate", pattern: "\\b(SUCCESS|PASS|OK|DONE)\\b", enabled: true, priority: 5, color: { fg: "#3fb950" } },
{ id: "badstate", pattern: "\\b(ERROR|FAILED)\\b", enabled: true, priority: 6, color: { fg: "#f85149" } },
{ id: "numbers", pattern: "\\b\\d+(?:\\.\\d+)?(?:%)?\\b", enabled: true, priority: 25, color: { fg: "#f2cc60" } },
{ id: "url", pattern: "https?://[^\\s]+", enabled: true, priority: 30, color: { fg: "#58a6ff" } }
]);
var banner = "\x1B]0;kimi \u2014 session\x07\x1B[38;2;79;168;255m\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E\x1B[0m\r\n\x1B[38;2;136;136;136mWelcome to Kimi Code!\x1B[0m \x1B[38;2;79;168;255m\u2502\x1B[0m send /help\r\n\x1B[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1B[0m\r\n";
var reference = applyHighlights(banner, rules);
var failed = 0;
var splitAt = (s, i) => [s.slice(0, i), s.slice(i)];
for (let i = 1; i < banner.length - 1; i++) {
const [a, b] = splitAt(banner, i);
const stream = new HighlightStream(rules);
const out = stream.push(a) + stream.push(b) + stream.flush();
if (out !== reference) {
failed++;
if (failed <= 3) {
console.log(`MISMATCH at split ${i}:`);
console.log(" got: " + JSON.stringify(out.slice(0, 120)));
console.log(" expected: " + JSON.stringify(reference.slice(0, 120)));
}
}
}
{
const i = Math.floor(banner.length / 3);
const j = Math.floor(banner.length * 2 / 3);
const stream = new HighlightStream(rules);
const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush();
if (out !== reference) failed++;
}
{
const stream = new HighlightStream(rules);
let out = "";
for (const ch of banner) out += stream.push(ch);
out += stream.flush();
if (out !== reference) failed++;
}
if (failed === 0) {
console.log("SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference");
process.exit(0);
} else {
console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`);
process.exit(1);
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -72,6 +72,7 @@ ok(store.listHistory().length === 0, 'clearHistory empties history')
// ---- 4. Library CRUD + ordering ------------------------------------------------
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
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()
+257
View File
@@ -0,0 +1,257 @@
/**
* ZMODEM engine e2e (M6). In-process, no SSH server and no real credentials:
* our engine (src/main/zmodem.ts) is attached to one end of an in-process wire,
* and a SEPARATE zmodem.js Sentry plays the remote rz/sz on the other end. Two
* full duplex directions are exercised over the real ZMODEM framing:
*
* 场景 1 (download): remote `sz` sends a file -> our engine receives it into
* a temp dir; assert content matches + done(ok) + progress.
* 场景 2 (upload): our engine uploads a local temp file -> remote `rz`
* receives it; assert remote content matches + done(ok).
*
* Pre-req (the bundle is built automatically on first run):
* npx esbuild src/main/zmodem.ts --bundle --platform=node --format=cjs \
* --external:ssh2 --alias:electron=./tests/electron-stub.cjs \
* --outfile=tests/.zmodem-e2e.cjs
* zmodem.js stays bundled (NOT external).
*
* Run: node tests/zmodem-e2e.mjs (must exit 0)
*/
import { createRequire } from 'module'
import {
mkdtempSync,
writeFileSync,
readFileSync,
mkdirSync,
rmSync,
existsSync
} from 'fs'
import { join, dirname, resolve, basename } from 'path'
import { fileURLToPath } from 'url'
import { execFileSync } from 'child_process'
import { randomBytes } from 'crypto'
const __dirname = dirname(fileURLToPath(import.meta.url))
const require_ = createRequire(import.meta.url)
const fail = (msg) => {
console.error(`FAIL: ${msg}`)
process.exit(1)
}
// ---- 0. ensure bundle ---------------------------------------------------------
const bundlePath = join(__dirname, '.zmodem-e2e.cjs')
if (!existsSync(bundlePath)) {
console.log('[zmodem-e2e] building bundle ...')
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
execFileSync(
cmd,
[
'esbuild', resolve(__dirname, '../src/main/zmodem.ts'),
'--bundle', '--platform=node', '--format=cjs',
'--external:ssh2',
'--alias:electron=./tests/electron-stub.cjs',
`--outfile=${bundlePath}`
],
{ stdio: 'inherit', shell: process.platform === 'win32' }
)
}
const zmodem = require_('zmodem.js')
const engine = require_(bundlePath)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
/**
* Drive a zmodem.js SEND session with a real file (the fs-port of the browser
* send_files helper, used by the remote half of the download scenario).
*/
async function driveRemoteSend(session, filePath) {
const data = readFileSync(filePath)
const name = basename(filePath)
const xfer = await session.send_offer({ name, size: data.length, mtime: new Date() })
if (!xfer) throw new Error('offer was skipped')
const CHUNK = 8192
let off = 0
if (data.length === 0) {
await xfer.end(new Uint8Array(0))
} else {
while (off < data.length) {
const end = Math.min(off + CHUNK, data.length)
if (end < data.length) {
xfer.send(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
} else {
await xfer.end(new Uint8Array(data.buffer, data.byteOffset + off, end - off))
}
off = end
}
}
await session.close()
}
/** Read all bytes a remote receive side has drained so far. */
function collectRemote(self) {
return Buffer.concat(self._incoming)
}
/**
* Build a remote (peer) Sentry. `remoteOnDetect(confirm)` wires the remote's
* on_detect: it should confirm + drive its side. `routeOut` is called with the
* octets the remote sent so we can feed them into our engine.
*/
function makeRemoteSentry(remoteOnDetect, routeOut) {
const self = { _incoming: [] }
const remoteSentry = new zmodem.Sentry({
to_terminal: () => {},
on_retract: () => {},
on_detect: (detection) => remoteOnDetect(detection, self),
sender: (octets) => routeOut(Buffer.from(octets))
})
self.sentry = remoteSentry
self.consume = (buf) => {
self._incoming.push(Buffer.from(buf))
}
return self
}
// ---- SCENARIO 1: download (remote `sz` -> our engine receives) ----------------
async function scenarioDownload() {
console.log('\n=== 场景 1: 远端 sz + 本地接收 (download) ===')
const sessionId = `sess-${randomBytes(3).toString('hex')}`
const srcDir = mkdtempSync(join(__dirname, '.zm-src-dl-'))
const srcFile = join(srcDir, 'transferred.bin')
const payload = randomBytes(200_000)
writeFileSync(srcFile, payload)
const outDir = mkdtempSync(join(__dirname, '.zm-out-dl-'))
const events = []
const remote = makeRemoteSentry((detection) => {
const s = detection.confirm()
if (s.type === 'send') {
// remote plays sz: send the source file, then finish
driveRemoteSend(s, srcFile).catch((e) => fail(`remote send error: ${e}`))
}
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
engine.attachZmodem(sessionId, {
broadcast: (channel, payload) => events.push({ channel, payload }),
toTerminal: () => {},
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
})
// remote sz initiates with ZRQINIT
engine.feedZmodem(sessionId, Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
// wait for the receive offer
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
if (!offer) fail('download: no ZMODEM_OFFER received')
if (offer.mode !== 'receive') fail(`download: expected receive offer, got ${offer.mode}`)
console.log(`[download] offer received (mode=receive) id=${offer.id}`)
engine.respondZmodem({ id: offer.id, cancelled: false, dir: outDir })
// wait for done
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
if (!done) fail('download: no ZMODEM_DONE received')
if (!done.ok) fail(`download: expected ok, got ${JSON.stringify(done)}`)
// assert progress events flowed
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
if (prog.length === 0) fail('download: no TRANSFER_PROGRESS emitted')
if (prog[0].kind !== 'zmodem-download') fail(`download: expected zmodem-download kind, got ${prog[0].kind}`)
const finalProg = prog[prog.length - 1]
if (finalProg.state !== 'done') fail(`download: last progress not done (${finalProg.state})`)
// assert content
const got = readFileSync(join(outDir, 'transferred.bin'))
if (got.length !== payload.length || !got.equals(payload)) {
fail(`download: content mismatch (len ${got.length} vs ${payload.length})`)
}
console.log(`[download] file received: ${got.length} bytes, content matches`)
console.log(`[download] progress events: ${prog.length} (state->done), done(ok)=true`)
rmSync(srcDir, { recursive: true, force: true })
rmSync(outDir, { recursive: true, force: true })
}
// ---- SCENARIO 2: upload (our engine -> remote `rz`) ----------------------------
async function scenarioUpload() {
console.log('\n=== 场景 2: 本地上传 + 远端 rz (upload) ===')
const sessionId = `sess-${randomBytes(3).toString('hex')}`
const srcDir = mkdtempSync(join(__dirname, '.zm-src-up-'))
const srcFile = join(srcDir, 'sendme.bin')
const payload = randomBytes(150_000)
writeFileSync(srcFile, payload)
const outDir = mkdtempSync(join(__dirname, '.zm-out-up-'))
const events = []
let remoteGot = null
const remote = makeRemoteSentry((detection, self) => {
const s = detection.confirm()
if (s.type === 'receive') {
// remote plays rz: accept the offered file, spool to disk.
s.on('offer', (offer) => {
const name = basename(offer.get_details().name)
const chunks = []
offer.on('input', (p) => chunks.push(Buffer.from(p)))
offer
.accept()
.then(async () => {
const data = Buffer.concat(chunks)
writeFileSync(join(outDir, name), data)
remoteGot = data
})
.catch((e) => fail(`remote receive error: ${e}`))
})
// start() once; zmodem.js re-sends ZRINIT itself after each accepted file.
s.start().catch((e) => fail(`remote receive start error: ${e}`))
}
}, (buf) => setTimeout(() => engine.feedZmodem(sessionId, buf), 0))
engine.attachZmodem(sessionId, {
broadcast: (channel, payload) => events.push({ channel, payload }),
toTerminal: () => {},
writeStream: (id, data) => setTimeout(() => remote.sentry.consume(data), 0)
})
// remote rz receiver initiates: we feed it ZRQINIT so it detects "receive",
// confirms and start()s, which sends ZRINIT toward our engine.
remote.sentry.consume(Buffer.from(zmodem.Header.build('ZRQINIT').to_hex()))
// engine should detect send and offer
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:offer'); i++) await sleep(10)
const offer = events.find((e) => e.channel === 'zmodem:offer')?.payload
if (!offer) fail('upload: no ZMODEM_OFFER received')
if (offer.mode !== 'send') fail(`upload: expected send offer, got ${offer.mode}`)
console.log(`[upload] offer received (mode=send) id=${offer.id}`)
engine.respondZmodem({ id: offer.id, cancelled: false, paths: [srcFile] })
for (let i = 0; i < 200 && !events.some((e) => e.channel === 'zmodem:done'); i++) await sleep(10)
const done = events.find((e) => e.channel === 'zmodem:done')?.payload
if (!done) fail('upload: no ZMODEM_DONE received')
if (!done.ok) fail(`upload: expected ok, got ${JSON.stringify(done)}`)
const prog = events.filter((e) => e.channel === 'transfer:progress').map((e) => e.payload)
if (prog.length === 0) fail('upload: no TRANSFER_PROGRESS emitted')
if (prog[0].kind !== 'zmodem-upload') fail(`upload: expected zmodem-upload kind, got ${prog[0].kind}`)
if (!remoteGot) fail('upload: remote never received the file data')
if (remoteGot.length !== payload.length || !payload.equals(remoteGot)) {
fail(`upload: content mismatch (remote ${remoteGot.length} vs src ${payload.length})`)
}
console.log(`[upload] remote received ${remoteGot.length} bytes, content matches`)
console.log(`[upload] progress events: ${prog.length}, done(ok)=true`)
rmSync(srcDir, { recursive: true, force: true })
rmSync(outDir, { recursive: true, force: true })
}
// ---- run ----------------------------------------------------------------------
const timer = setTimeout(() => fail('zmodem-e2e timed out'), 30000)
await scenarioDownload()
await scenarioUpload()
clearTimeout(timer)
console.log('\n[zmodem-e2e] ALL CHECKS PASSED')
process.exit(0)