Compare commits

...
15 Commits
Author SHA1 Message Date
Bill 4b336360f8 fix(release): scope the proxy to GitHub so the domestic channel stays direct
setGlobalDispatcher routed every request — including the Gitea release and
channel PUTs — through the local proxy, which reset mid-upload once and left
the channel half-written. The proxy agent is now passed explicitly on the
GitHub requests only.
2026-09-15 00:34:30 +08:00
Bill d7abbaad9c fix: paste through xterm so large multi-line blocks insert instead of executing
confirmPaste wrote the raw text to the pty, so a large paste bypassed
bracketed paste and PowerShell ran it line by line; it also skipped
onData, which is why history/cwd tracking needed a separate mirror. Now the
text goes through term.paste(): xterm adds the \x1b[200~ markers when the
shell enabled bracketed paste (one edit, no execution) and the normal
onData path restores tracking for free.

The risky-paste bar becomes a proper dialog (确认粘贴 / 本次会话不再提示 /
关闭后续粘贴检测), matching the behaviour users expect from other
terminals.
2026-09-15 00:27:19 +08:00
Bill cbf4c224fb fix: allocate terminal titles by lowest free number instead of a counter
Closing 终端 6/7 then opening a new one produced 终端 8 rather than
refilling the gap. nextTerminalTitle now scans live panel titles and takes
the smallest unused N, which also subsumes the restore-time counter
seeding (syncTitleSeq is gone) — duplicate retitling after a restore fills
the lowest free number too.
2026-09-14 22:24:36 +08:00
Bill 03aa79a691 chore: bump version to 1.0.8 2026-09-14 21:41:46 +08:00
Bill 458dd9c88b fix: write session logs as plain text instead of raw PTY soup
Recorded logs carried the raw stream: SGR colors, cursor moves,
synchronized-output markers, and every TUI redraw frame — unreadable in an
editor and far larger than the visible output (a short kimi session logged
21.5KB of which 4.4KB is text). A per-session sanitizer now strips ANSI
statefully across chunk boundaries, collapses carriage-return overwrites
(progress bars keep only their final text), and suppresses alternate-screen
frames with a marker line. Ink-style inline TUIs redraw in the normal
buffer and cannot be frame-collapsed without screen emulation; their
committed lines are preserved.
2026-09-14 21:17:45 +08:00
Bill 28d8af6982 fix: keep terminal titles unique across session restore
The 终端 N counter lives only in memory, so after a restore it restarted at
zero and the next new terminal duplicated a restored title. Broadcast keys
targets by session id so duplicate titles never broke the fan-out itself,
but the target list became indistinguishable. Seed the counter past the
restored maximum on session restore / template apply, and retitle
duplicates already baked into existing snapshots.
2026-09-14 20:56:02 +08:00
Bill f09bed06b5 remove: drop the quick-input panel
The toggle state was a global singleton while the bolt button renders in
every pane's tab bar, so all panes showed the same open/close state and
the single floating panel (window bottom-right, sends to the *active*
session) never corresponded to the pane whose button was clicked. A true
per-pane rework is a medium refactor for a feature that overlaps with the
inline completion and the sidebar commands panel — removing instead.
2026-09-14 20:47:21 +08:00
Bill fcb2781694 chore: bump version to 1.0.7 2026-09-14 20:12:04 +08:00
Bill 0b4a87f1e7 fix: feed pasted text into the line buffer so history and cwd memory see it
App-driven paste (context menu / Ctrl+Shift+V) writes straight to the pty,
bypassing xterm's onData, and xterm-native paste arrives wrapped in
bracketed-paste markers that the buffer guards dropped. Either way a pasted
'cd D:\path' was recorded as the bare 'cd' typed before it, so the pane's
directory memory silently stayed home. Track submitted lines from pasted
text and strip bracketed-paste markers in the line buffer.
2026-09-14 20:01:23 +08:00
Bill 01827bf70b fix: resolve drive-relative cd (cd d:) in cwd memory
path.isAbsolute('d:') is false on Windows, so a drive-relative cd resolved
against the current base, produced a nonexistent path, and the pane kept
its creation directory in the snapshot. Map a bare drive-letter argument
to the drive root (the fresh-shell answer; we cannot know the drive's
remembered directory).
2026-09-14 19:52:32 +08:00
Bill 9a330499de fix: track bare drive switches (d:) for cwd memory
PowerShell/cmd users hop drives with a bare 'd:' — no cd keyword — so the
typed-command tracker never saw it, the pane's cwd stayed at its creation
directory, and the session snapshot restored it to home. Treat a bare
drive-letter line as a cd to the drive root; the main-process existence
check drops it on platforms without drive letters.
2026-09-14 19:27:20 +08:00
Bill 3c193a48a7 chore: bump version to 1.0.6 2026-09-14 17:31:00 +08:00
Bill 9467b78560 chore: name installed NSIS shortcuts OTerminal 2026-09-14 17:22:15 +08:00
Bill be7332db17 perf: route PTY stream through a single shared IPC listener
Every pane registered its own global onPtyData/onPtyExit listener, so each
output chunk woke N listeners and N-1 discarded it after an id compare.
With many terminals under heavy output that fan-out is pure overhead.
Now one IPC listener dispatches through a Map keyed by sessionId: one
lookup per chunk, only the owning pane runs.
2026-09-14 16:59:57 +08:00
Bill bbee1d642d polish: about-page logo now uses the app icon instead of an OT text mark 2026-09-14 15:06:19 +08:00
16 changed files with 400 additions and 463 deletions

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@
## M6 — 多终端协同 ✅
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
- [x] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 自动执行(\r) + 快捷命令 + 最近命令;广播感知发送
- [x] ~~快捷输入面板(Quick Input)~~(已移除:全局单例开关与面板不对一一对应,功能与行内补全/侧边栏命令面板重叠)
- [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
+3
View File
@@ -37,6 +37,9 @@ nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
# Installed shortcuts read "OTerminal"; productName stays OpenTerminal so the
# userData dir and window title are untouched.
shortcutName: OTerminal
createDesktopShortcut: true
createStartMenuShortcut: true
artifactName: ${productName}-${version}-setup.${ext}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.5",
"version": "1.0.9",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+13 -9
View File
@@ -48,14 +48,16 @@ if (ymlVersion !== V) {
process.exit(1)
}
// Proxy applies to GitHub only. The domestic Gitea host is fastest — and most
// reliable — over a direct connection; routing it through a local proxy is
// what once broke a channel upload (ECONNRESET mid-PUT), and a proxy that is
// down would take the domestic channel with it. So no global dispatcher here:
// the agent is passed explicitly on GitHub requests.
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) {
const { ProxyAgent, setGlobalDispatcher } = require('undici')
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined
if (proxy) console.log('proxy for GitHub only:', proxy)
async function upload(url, file, token, authScheme) {
async function upload(url, file, token, authScheme, viaProxy = false) {
const name = path.basename(file)
const stat = fs.statSync(file)
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, {
@@ -66,7 +68,8 @@ async function upload(url, file, token, authScheme) {
'Content-Length': String(stat.size)
},
body: fs.createReadStream(file),
duplex: 'half'
duplex: 'half',
...(viaProxy && proxyAgent ? { dispatcher: proxyAgent } : {})
})
console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) {
@@ -136,14 +139,15 @@ async function github() {
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
},
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
...(proxyAgent ? { dispatcher: proxyAgent } : {})
})
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json()
console.log('release created, id =', rel.id)
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer')
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
}
}
+19 -1
View File
@@ -26,6 +26,7 @@ import { join } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands'
import { DEFAULT_SETTINGS } from '../shared/settings'
import { loadSettings } from './settingsStore'
import { LogSanitizer } from './logSanitizer'
/** Upper bound on recorded history entries when no limit is configured. */
const HISTORY_CAP = 500
@@ -77,6 +78,8 @@ export class CommandsStore {
* onto the previous one keeps the file append-only in call order.
*/
private readonly pendingAppends = new Map<string, Promise<void>>()
/** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>()
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
const ud = userData ?? app.getPath('userData')
@@ -269,6 +272,7 @@ export class CommandsStore {
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
this.metasByFile.set(file, meta)
this.activeBySession.set(sessionId, meta)
this.sanitizers.set(sessionId, new LogSanitizer())
this.persistIndex()
return meta
}
@@ -280,9 +284,12 @@ export class CommandsStore {
// not logging (or already stopped) -> ignore, prevents stop/append race
return
}
// Raw PTY output is escape-sequence soup in a text file; log plain text.
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
if (!clean) return
// Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then(
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
() => undefined,
() => {
// file may have been removed after stop -> ignore
@@ -299,6 +306,17 @@ export class CommandsStore {
logStop(sessionId: string): void {
const meta = this.activeBySession.get(sessionId)
if (!meta) return
const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
this.sanitizers.delete(sessionId)
if (tail) {
// Best effort: the trailing partial line belongs in the file too.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
}
this.finishLog(meta)
}
+7
View File
@@ -37,6 +37,13 @@ export function resolveCwd(current: string | undefined, rawArg: string): string
}
// `cd -` means the shell's previous directory, which we cannot know here.
if (arg === '-') return null
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
// current directory, which the shell never tells us — path.isAbsolute('d:')
// is false, so without this branch it resolves against the base and dies on
// a nonexistent `<base>\d:`. The drive root is the fresh-shell answer.
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(arg)) {
return existingDir(`${arg.toUpperCase()}\\`)
}
const base = current && current.trim() ? current : homedir()
// An absolute argument wins; otherwise resolve against the base. Both forms
+147
View File
@@ -0,0 +1,147 @@
/**
* Plain-text sanitizer for session logs.
*
* Raw PTY output is unreadable in a text file: SGR colors, cursor moves,
* synchronized-output markers — and for full-screen TUIs (vim, htop) every
* redrawn frame. For each logged session this filter:
* 1. strips ANSI escape sequences (CSI / OSC / single-char), statefully
* across chunk boundaries (a sequence may split between two PTY chunks);
* 2. collapses carriage-return overwrites (progress bars, spinner lines)
* keeping only the final text of the line — a `\r` directly followed by
* `\n` is a line ending, not an overwrite;
* 3. drops output produced while the alternate screen is active (TUI
* redraw frames), emitting one marker line per suppressed span.
*
* Note: inline TUIs that redraw in the *normal* buffer (Ink-based CLIs such
* as Claude Code / kimi) cannot be frame-collapsed without full screen
* emulation; their committed lines are preserved as-is.
*/
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
/** Alt-screen toggles: CSI ? 1049|1047|47 h / l */
const ALT_ON = /^\?(?:1049|1047|47)h$/
const ALT_OFF = /^\?(?:1049|1047|47)l$/
export class LogSanitizer {
private mode: Mode = 'text'
/** raw bytes of the escape sequence in flight (bounded: OSC can be long) */
private seq = ''
private alt = false
/** some output was suppressed since the last marker */
private altDirty = false
/** current line, not yet newline-terminated */
private line = ''
/** previous text char was \r — the next char decides ending vs overwrite */
private cr = false
/** Transform one raw chunk; returns the plain text to append ('' for none). */
push(data: string): string {
let out = ''
for (let i = 0; i < data.length; i++) {
const c = data[i]
// A pending \r: `\r\n` completes the line; anything else overwrites it.
if (this.cr) {
this.cr = false
if (c === '\n') {
out += this.emitLine()
continue
}
this.line = ''
// fall through and process c normally
}
switch (this.mode) {
case 'text':
if (c === '\x1b') {
this.mode = 'esc'
this.seq = ''
} else if (c === '\r') {
this.cr = true
} else if (c === '\n') {
out += this.emitLine()
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
// printable + tab; DEL and C0 controls (bell etc.) are dropped
if (this.alt) this.altDirty = true
else this.line += c
}
break
case 'esc':
if (c === '[') this.mode = 'csi'
else if (c === ']') this.mode = 'osc'
else if (c === '(' || c === ')' || c === '#' || c === '%') this.mode = 'esc-skip1'
else this.mode = 'text' // single-character escape, consumed
break
case 'esc-skip1':
this.mode = 'text'
break
case 'csi':
if (c >= '@' && c <= '~') {
// the final byte is not part of `seq` — test the full sequence
const full = this.seq + c
if (ALT_ON.test(full)) {
this.alt = true
} else if (ALT_OFF.test(full)) {
this.alt = false
if (this.altDirty) {
this.altDirty = false
out += MARKER
}
}
this.seq = ''
this.mode = 'text'
} else if (this.seq.length < 1024) {
this.seq += c
}
break
case 'osc':
if (c === '\x07') this.mode = 'text'
else if (c === '\x1b') this.mode = 'osc-esc'
break
case 'osc-esc':
// ST is ESC \; a new ESC [ / ESC ] starts the next sequence
if (c === '\\') this.mode = 'text'
else if (c === '[') {
this.mode = 'csi'
this.seq = ''
} else if (c === ']') this.mode = 'osc'
else this.mode = 'text'
break
}
}
return out
}
/** Flush the pending partial line at log stop. */
flush(): string {
let out = ''
if (this.cr) {
this.cr = false
// A trailing \r at stop: treat as line ending rather than overwrite.
out += this.emitLine()
} else if (this.line && !this.alt) {
out += this.line
this.line = ''
}
if (this.altDirty) {
this.altDirty = false
out += MARKER
}
return out
}
/** Complete the current line: hand it out with a newline and reset. */
private emitLine(): string {
if (this.alt) {
if (this.line) this.altDirty = true
this.line = ''
return ''
}
const out = this.line ? this.line + '\n' : '\n'
this.line = ''
return out
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Button, Progress, Select, Switch, Tag } from 'antd'
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
import { useSettingsStore } from './store'
import appIconUrl from '../../../../build/icon.png'
const STATUS_TEXT: Record<UpdateStatus, string> = {
idle: '点击检查更新',
@@ -34,7 +35,7 @@ export function AboutTab(): React.JSX.Element {
return (
<div className="about-tab">
<div className="about-header">
<div className="about-logo">OT</div>
<img className="about-logo" src={appIconUrl} alt="OpenTerminal" draggable={false} />
<div>
<div className="about-name">OpenTerminal</div>
<div className="about-version">
+2 -7
View File
@@ -380,13 +380,8 @@
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
object-fit: cover;
display: block;
}
.about-name {
+96 -35
View File
@@ -7,13 +7,14 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
import './terminal.css'
@@ -37,6 +38,13 @@ function isRiskyPaste(text: string): boolean {
return text.length > 100
}
/**
* "本次会话不再提示" from the paste confirmation dialog. Deliberately in memory
* only: it lasts until the app exits and never becomes a stored preference
* (that is what 关闭后续粘贴检测 in the dialog is for).
*/
let pasteConfirmedForSession = false
const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
matchBackground: '#3b8eea',
matchBorder: '#2d2d2d',
@@ -152,6 +160,15 @@ function stepLineBuffer(
outBuf.current = ''
return { line: '' }
}
// xterm-native paste (plain Ctrl+V): with the shell's bracketed-paste mode
// on, the pasted block arrives wrapped in \x1b[200~ / \x1b[201~ and the chunk
// starts with ESC, which the guards below would drop wholesale. Strip the
// markers and keep the pasted text as ordinary input.
if (data.includes('\x1b[200~') || data.includes('\x1b[201~')) {
const text = data.replace(/\x1b\[20[01]~/g, '')
if (text) outBuf.current = b + text
return { line: outBuf.current }
}
// A chunk that begins with escape/control sequences (e.g. the terminal's
// clear-line) only removes buffer content; never appends.
const first = data[0]
@@ -297,7 +314,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null)
const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null)
const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0)
const [recording, setRecording] = useState(false)
@@ -429,15 +446,39 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
setResultInfo(found ? '已找到' : '未找到')
}, [])
/** Record a submitted command line; cd-style lines also update cwd memory. */
const trackSubmittedLine = useCallback(
(submit: string): void => {
void window.api.recordCommand(submit).catch(() => undefined)
// Remember the directory for the next launch: a cd-style line is
// resolved in the main process (the only side with node's `path`).
const cdArg = cdArgument(submit)
if (cdArg !== null) {
void window.api
.resolveCwd(getSessionCwd(sessionId), cdArg)
.then((next: string | null) => {
if (next) setSessionCwd(sessionId, next)
})
.catch(() => undefined)
}
},
[sessionId]
)
const confirmPaste = useCallback(
(text: string) => {
const data = nativePasteRef.current ?? text
nativePasteRef.current = null
setPaste(null)
if (!deadRef.current) writeBroadcast(sessionId, data)
// Route through xterm's own paste: when the shell asked for bracketed
// paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
// and inserted as ONE edit instead of executing line by line — which is
// what a large multi-line paste needs. It also feeds onData, so the line
// buffer, command history and cwd memory see the pasted text.
if (!deadRef.current) termRef.current?.paste(data)
termRef.current?.focus()
},
[sessionId]
[]
)
const cancelPaste = useCallback(() => {
@@ -470,9 +511,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback(
(text: string) => {
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) {
if (
!deadRef.current &&
settings.terminal.pasteRiskConfirm &&
!pasteConfirmedForSession &&
isRiskyPaste(text)
) {
nativePasteRef.current = text
setPaste(text)
setPaste({ noPrompt: false, disableDetection: false })
return
}
confirmPaste(text)
@@ -480,6 +526,15 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[settings.terminal.pasteRiskConfirm, confirmPaste]
)
/** Confirm the risky-paste dialog: apply its choices, then paste. */
const acceptPaste = useCallback((): void => {
if (paste?.noPrompt) pasteConfirmedForSession = true
if (paste?.disableDetection) {
void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false })
}
confirmPaste(nativePasteRef.current ?? '')
}, [paste, confirmPaste])
const copySelection = useCallback(async () => {
const term = termRef.current
if (!term) return
@@ -732,18 +787,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// M5: line capture → recordCommand + inline completion overlay.
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined)
// Remember the directory for the next launch: a cd-style line is
// resolved in the main process (the only side with node's `path`).
const cdArg = cdArgument(step.submit)
if (cdArg !== null) {
void window.api
.resolveCwd(getSessionCwd(sessionId), cdArg)
.then((next: string | null) => {
if (next) setSessionCwd(sessionId, next)
})
.catch(() => undefined)
}
trackSubmittedLine(step.submit)
}
if (deadRef.current) {
suggestionsRef.current = []
@@ -792,18 +836,19 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
let replayDone = false
const pending: string[] = []
const unsubscribes: (() => void)[] = [
window.api.onPtyData((e: PtyDataEvent) => {
if (e.id !== sessionId || deadRef.current) return
// Routed via the shared dispatcher (one IPC listener for all panes)
// instead of a per-pane global listener.
subscribePtyData(sessionId, (data: string) => {
if (deadRef.current) return
if (!replayDone) {
pending.push(e.data)
pending.push(data)
return
}
writeHighlighted(e.data)
writeHighlighted(data)
}),
window.api.onPtyExit((e: PtyExitEvent) => {
if (e.id !== sessionId) return
subscribePtyExit(sessionId, (code: number) => {
deadRef.current = true
setExitCode(e.exitCode)
setExitCode(code)
setDead(true)
})
]
@@ -1060,17 +1105,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined />
</button>
</div>
{paste !== null && !dead && (
<div className="term-pastebar">
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}>
粘贴
</button>
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}>
取消
</button>
<Modal
open={paste !== null}
title="确认粘贴"
okText="粘贴"
cancelText="取消"
width={420}
maskClosable={false}
onOk={acceptPaste}
onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
本次会话不再提示
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
关闭后续粘贴检测
</Checkbox>
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
</div>
)}
</Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion
popup anchors to this box, so its coordinates start at the terminal
content rows (the search bar above never offsets the top). */}
+18 -5
View File
@@ -4,8 +4,9 @@
* Two signals feed the same store:
* 1. the shell itself, over OSC 7 (`file://host/path`) or ConEmu's OSC 9;9 —
* exact, but only present when shell integration is on;
* 2. the command the user typed, matched against the `cd` family — the
* fallback that works with no shell cooperation at all.
* 2. the command the user typed, matched against the `cd` family plus the
* bare drive switch (`d:`) of cmd.exe / PowerShell — the fallback that
* works with no shell cooperation at all.
*
* Both are platform-neutral: path resolution and existence checks happen in the
* main process (`src/main/cwd.ts`) where node's `path` lives, so nothing here
@@ -21,14 +22,26 @@
*/
const CD_COMMAND = /^\s*(?:cd|chdir|sl|set-location|pushd|push-location)(?:\s+([\s\S]*))?$/i
/**
* Bare drive switch (`d:`, `D:`) — cmd.exe and PowerShell change the current
* drive with no `cd` keyword at all, and it is the common way to land on
* another drive on Windows. Treated as a cd to the drive root; the main
* process's existence check quietly drops it on platforms where drive
* letters mean nothing (POSIX resolves it to a nonexistent relative path).
*/
const DRIVE_SWITCH = /^\s*([a-zA-Z]):\s*$/
/**
* Return the argument of a cd-style command line, or null when the line is not
* one. An empty string means "bare cd" (the shell's home directory).
* one. An empty string means "bare cd" (the shell's home directory). A bare
* drive switch yields the drive root (`D:\`).
*/
export function cdArgument(line: string): string | null {
const match = line.match(CD_COMMAND)
if (!match) return null
return (match[1] ?? '').trim()
if (match) return (match[1] ?? '').trim()
const drive = line.match(DRIVE_SWITCH)
if (drive) return `${drive[1].toUpperCase()}:\\`
return null
}
/** Payload of an OSC 9 sequence that follows the ConEmu `9;<path>` convention. */
@@ -0,0 +1,48 @@
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
/**
* Single shared PTY stream listener. Every pane used to register its own
* window.api.onPtyData/onPtyExit listener, so each output chunk woke N
* listeners (N = pane count) and N-1 of them discarded it after an id
* compare. With many terminals under heavy output that per-chunk fan-out is
* pure overhead. Here one IPC listener dispatches through a Map keyed by
* sessionId: one lookup per chunk, and only the owning pane runs.
*/
type DataHandler = (data: string) => void
type ExitHandler = (exitCode: number) => void
const dataHandlers = new Map<string, DataHandler>()
const exitHandlers = new Map<string, ExitHandler>()
let bound = false
function ensureBound(): void {
if (bound) return
bound = true
window.api.onPtyData((e: PtyDataEvent) => {
dataHandlers.get(e.id)?.(e.data)
})
window.api.onPtyExit((e: PtyExitEvent) => {
exitHandlers.get(e.id)?.(e.exitCode)
})
}
/** Subscribe to one session's output. Returns the unsubscribe function. */
export function subscribePtyData(id: string, cb: DataHandler): () => void {
ensureBound()
dataHandlers.set(id, cb)
return () => {
// A rebind (template apply) may have replaced the handler already; only
// delete when this subscription is still the registered one.
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
}
}
/** Subscribe to one session's exit. Returns the unsubscribe function. */
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
ensureBound()
exitHandlers.set(id, cb)
return () => {
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
}
}
+12 -43
View File
@@ -328,51 +328,20 @@
}
/* ---- paste risk confirm bar ---- */
.term-pastebar {
/* ---- risky-paste confirmation dialog body ---- */
.term-paste-confirm p {
margin: 0 0 12px;
}
.term-paste-confirm .ant-checkbox-wrapper {
display: flex;
align-items: center;
gap: 8px;
flex: none;
padding: 4px 8px;
background: #3a2e1a;
border-bottom: 1px solid #5c4a26;
margin-bottom: 8px;
}
.term-paste-confirm-hint {
margin: 12px 0 0 !important;
font-size: 12px;
color: #e8d9a8;
}
.term-pastebar-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.term-pastebar-btn {
flex: none;
padding: 3px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.term-pastebar-btn-ok {
background: var(--chrome-accent);
color: #fff;
}
.term-pastebar-btn-ok:hover {
filter: brightness(1.1);
}
.term-pastebar-btn-cancel {
background: transparent;
color: var(--chrome-fg);
}
.term-pastebar-btn-cancel:hover {
background: rgba(255, 255, 255, 0.1);
opacity: 0.65;
}
/* ---- terminal docked area (flex:1) ---- */
@@ -1,173 +0,0 @@
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>
)
}
+30 -45
View File
@@ -10,8 +10,7 @@ import {
MenuUnfoldOutlined,
PlusOutlined,
SettingOutlined,
ShareAltOutlined,
ThunderboltOutlined
ShareAltOutlined
} from '@ant-design/icons'
import { DockviewReact } from 'dockview-react'
import type {
@@ -33,7 +32,6 @@ 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 { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
import { useSettingsStore } from '@renderer/settings/store'
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
@@ -63,14 +61,20 @@ const TERMINAL_COMPONENT = 'terminal'
const TERMINAL_TAB_COMPONENT = 'terminal-tab'
/**
* Per-workspace monotonic title counter for "终端 N". Lives on `window` so
* dev hot-reloads (which reset module state) don't restart the numbering and
* produce duplicate tab titles.
* "终端 N" titles fill the lowest free number among live panels: closing a
* pane frees its number for the next new terminal, and a session restore can
* no longer leave the numbering behind (the old in-memory counter restarted
* at zero on launch and produced duplicate titles).
*/
const titleSeqHolder = window as unknown as { __otTitleSeq?: number }
function nextTerminalTitle(): string {
titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1
return `终端 ${titleSeqHolder.__otTitleSeq}`
function nextTerminalTitle(existing: Iterable<string | undefined>): string {
const used = new Set<number>()
for (const title of existing) {
const n = title?.match(/^终端 (\d+)$/)?.[1]
if (n) used.add(Number(n))
}
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
}
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -190,7 +194,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
id: panelId(),
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
title: nextTerminalTitle(containerApi.panels.map((p) => p.title)),
params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' }
})
@@ -201,7 +205,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<QuickInputToggleButton />
</div>
)
}
@@ -240,7 +243,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** Per-workspace active panel used as the split anchor. */
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** The currently-visible mode's active panel (broadcast / quick-input target). */
/** The currently-visible mode's active panel (broadcast target). */
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** Sessions we already asked to kill — kill once, never twice. */
@@ -621,7 +624,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
id: panelId(),
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
title: nextTerminalTitle(current.panels.map((p) => p.title)),
params: { sessionId, sessionKind: 'local' },
position:
reference && direction
@@ -864,6 +867,19 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
await rebindSessionPanels(terminal, snapshot, true, renames)
await rebindSessionPanels(ssh, snapshot, true, renames)
// Retitle duplicates left over from the old counter bug (the snapshot it
// wrote keeps colliding titles forever otherwise): the first panel keeps
// the title, later ones get the lowest free number.
const seenTitles = new Set<string>()
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
const t = p.title ?? ''
if (!t) continue
if (seenTitles.has(t)) {
const fresh = nextTerminalTitle(seenTitles)
p.setTitle(fresh)
seenTitles.add(fresh)
} else seenTitles.add(t)
}
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
useWorkspaceModeStore.getState().setMode(snapshot.mode)
recountAll()
@@ -961,13 +977,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}
}
/** Session id of the currently active, visible terminal panel ('' when none). */
const resolveActiveSessionId = useCallback((): string => {
const panel = activePanelRef.current
if (!panel) return ''
return sessionIdOf(panel) ?? ''
}, [])
return (
<div className="workspace-root">
<IconRail
@@ -1056,11 +1065,6 @@ 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
@@ -1206,25 +1210,6 @@ function WorkspaceBroadcastPopover({
)
}
/** 快捷输入 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.
+1 -142
View File
@@ -446,7 +446,7 @@ body.ssh-resizing .ssh-bottom-divider {
color: var(--tab-accent-fg, #3fd06a);
}
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
/* ---- M6 tab-bar workspace actions (broadcast) ---- */
.workspace-tabbar-actions {
display: flex;
align-items: center;
@@ -574,147 +574,6 @@ body.ssh-resizing .ssh-bottom-divider {
user-select: none;
}
/* ---- 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;