Compare commits

..
3 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
5 changed files with 106 additions and 123 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.8",
"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)
}
}
+58 -40
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'
@@ -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',
@@ -306,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)
@@ -457,40 +465,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,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)
@@ -533,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
@@ -1103,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). */}
+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)