Compare commits

...
6 Commits
Author SHA1 Message Date
Bill 97fc7171cc chore: bump version to 1.0.10 2026-09-15 13:17:28 +08:00
Bill d3d029fe4f fix: wire plain Ctrl/Cmd+V to paste and split the check by line count
Plain Ctrl+V was never the terminal's: it went to the pty as a literal ^V
(0x16) — the command history even recorded 'cd \u0016' — so nothing pasted
and the confirm dialog never saw it. It is now handled on the terminal host
in the capture phase, with preventDefault, feeding the same path as
Ctrl+Shift+V.

The paste check is now shape-based rather than length-based: two or more
lines confirm (a stray newline executes an unreviewed command), a single
line pastes straight through unless it is unusually long.
2026-09-15 11:45:17 +08:00
Bill 77fd241b43 chore(dev): give dev builds their own userData and single-instance lock
A dev instance shared the installed build's userData directory and lock, so
starting it demanded killing the real app and it wrote test settings and
session snapshots into the live profile. Dev now uses OpenTerminal-dev and
tags its window title '(dev)'; both instances run side by side.
2026-09-15 01:02:10 +08:00
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
8 changed files with 164 additions and 129 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令
- 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.8",
"version": "1.0.10",
"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)
}
}
+18
View File
@@ -20,6 +20,15 @@ function showOrCreate(): void {
}
}
// Dev runs get their own userData directory (settings, session snapshot,
// command history, logs) *and* their own single-instance lock — both are keyed
// on that path. Without this a dev instance fights the installed build: it
// takes the lock, so the other side is refused / must be killed, and it writes
// test data straight into the real profile. Must run before the lock below.
if (!app.isPackaged) {
app.setPath('userData', join(app.getPath('appData'), 'OpenTerminal-dev'))
}
// Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process.
const gotSingleInstanceLock = app.requestSingleInstanceLock()
@@ -64,6 +73,15 @@ function createWindow(): void {
win.on('ready-to-show', () => win.show())
// Dev window wears a "(dev)" tag so it is never confused with the installed
// build sitting next to it (they no longer share userData — see above).
if (!app.isPackaged) {
win.on('page-title-updated', (e) => {
e.preventDefault()
win.setTitle('OpenTerminal (dev)')
})
}
// Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate)
+1 -1
View File
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
<SettingRow
label="粘贴风险确认"
desc="粘贴前弹出风险确认提示"
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
control={
<Switch
checked={settings.terminal.pasteRiskConfirm}
+96 -45
View File
@@ -7,6 +7,7 @@ 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 { TerminalSettings } from '@shared/settings'
@@ -31,12 +32,27 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
return n
}
/** Paste-risk heuristic: multi-line or unreasonably long text. */
function isRiskyPaste(text: string): boolean {
if (text.includes('\n') || text.includes('\r')) return true
return text.length > 100
/**
* Paste-risk heuristic, split by shape rather than by raw length:
* - two or more lines always confirm — a stray newline executes a command
* the user never reviewed;
* - a single line pastes straight through, unless it is unusually long
* (those are pasted scripts rather than something typed by hand).
*/
const SINGLE_LINE_CONFIRM_LENGTH = 1000
function needsPasteConfirm(text: string): boolean {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
if (normalized.includes('\n')) return true
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
}
/**
* "本次会话不再提示" 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',
@@ -306,7 +322,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)
@@ -457,40 +473,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[sessionId]
)
/**
* Pasted text is written straight to the pty (confirmPaste → writeBroadcast),
* bypassing xterm's onData, so the line buffer / history / cwd tracker would
* never see it — a pasted `cd X` used to be recorded as the bare prefix typed
* before it. Mirror the shell: each completed line is a submitted command,
* the trailing partial line stays pending in the buffer.
*/
const trackPastedText = useCallback(
(data: string): void => {
if (termRef.current?.buffer.active.type === 'alternate') return
const parts = data.replace(/\r\n/g, '\n').split('\n')
const tail = parts.pop() ?? ''
for (const line of parts) {
const t = line.trim()
lineBufRef.current = ''
if (t) trackSubmittedLine(t)
}
if (tail) lineBufRef.current += tail
},
[trackSubmittedLine]
)
const confirmPaste = useCallback(
(text: string) => {
const data = nativePasteRef.current ?? text
nativePasteRef.current = null
setPaste(null)
if (!deadRef.current) {
writeBroadcast(sessionId, data)
trackPastedText(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, trackPastedText]
[]
)
const cancelPaste = useCallback(() => {
@@ -523,16 +519,28 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback(
(text: string) => {
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) {
// Read the setting at call time: this runs from a long-lived keydown
// listener, so a captured value would go stale after a settings change.
const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm
if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) {
nativePasteRef.current = text
setPaste(text)
setPaste({ noPrompt: false, disableDetection: false })
return
}
confirmPaste(text)
},
[settings.terminal.pasteRiskConfirm, confirmPaste]
[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
@@ -871,9 +879,36 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
observerRef.current = observer
}
// Plain Ctrl/Cmd+V: not a chord xterm handles, so it used to reach the pty
// as a literal ^V (0x16) — nothing pasted. Handled here, on the terminal
// host in the capture phase, with preventDefault so the pty never sees it
// and Chromium's own paste cannot double up. Text follows the same
// single-line / multi-line rule as the Ctrl+Shift+V path.
const host = hostRef.current
const onPasteKey = (e: KeyboardEvent): void => {
if (e.type !== 'keydown' || e.altKey) return
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if (!mod || e.shiftKey || e.key.toLowerCase() !== 'v') 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 // ordinary inputs keep the browser's native paste
}
e.preventDefault()
e.stopPropagation()
void pasteFromClipboard()
}
host?.addEventListener('keydown', onPasteKey, true)
return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
host?.removeEventListener('keydown', onPasteKey, true)
for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose()
observer?.disconnect()
@@ -1103,17 +1138,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). */}
+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) ---- */
+22 -30
View File
@@ -61,26 +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}`
}
/**
* Lift the counter past every "终端 N" currently on screen. The counter lives
* only in memory, so after a session restore / template apply it is behind the
* restored titles and the next new terminal duplicates one (`终端 2` twice).
*/
function syncTitleSeq(titles: Iterable<string | undefined>): void {
for (const title of titles) {
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) titleSeqHolder.__otTitleSeq = Math.max(titleSeqHolder.__otTitleSeq ?? 0, Number(n))
if (n) used.add(Number(n))
}
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
}
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -200,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' }
})
@@ -630,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
@@ -873,17 +867,18 @@ 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)
const allPanels = [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]
syncTitleSeq(allPanels.map((p) => p.title))
// Retitle duplicates left over from the counter bug (the snapshot the bug
// wrote keeps colliding titles forever otherwise): first panel keeps the
// title, later ones get a fresh number past the restored maximum.
// 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 allPanels) {
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
const t = p.title ?? ''
if (!t) continue
if (seenTitles.has(t)) p.setTitle(nextTerminalTitle())
else seenTitles.add(t)
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)
@@ -932,9 +927,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// Fresh sessions for every restored terminal panel in both workspaces.
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
syncTitleSeq(
[...(terminalApiRef.current?.panels ?? []), ...(sshApiRef.current?.panels ?? [])].map((p) => p.title)
)
// Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid)