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:
1 parent
0953ac4f41
commit
0663e5affa
30 files changed
+9028
-37
No files matched your search
Generated
+3472
-2
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Vendored
+125
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@
|
||||
margin: -8px 0 16px;
|
||||
}
|
||||
|
||||
.settings-shortcut-hint {
|
||||
margin: 2px 8px 0;
|
||||
}
|
||||
|
||||
/* ---------- font tab: live terminal preview ---------- */
|
||||
|
||||
.settings-preview {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user