Compare commits

...
6 Commits
Author SHA1 Message Date
Bill 9e453e272c chore: bump version to 1.0.11 2026-09-15 15:08:13 +08:00
Bill 237265e6ba feat: clickable URLs, left-rail settings nav, proportional resizable dialog
URLs in terminal output (http/https/ftp with ports, www. hosts, bare
localhost:port dev-server forms) are detected via a link provider: hover
underlines and shows the pointer, Ctrl/Cmd+Click opens the system browser.
Wrapped URLs spanning buffer rows resolve as one link. The 网址链接 highlight
preset grows to cover ftp:// and www. forms.

The settings dialog moves to a left navigation rail with the content column
scrolling on its own, sizes itself at ~70% of the main window and follows
main-window resizes in real time (until the user drags the corner grip),
stays centred while resizing, and keeps a stable height.
2026-09-15 15:07:47 +08:00
Bill 3b2fe06f71 docs: publish domestically only
From the 2026-09-15 decision, releases go to the Gitea release + the
domestic update channel with --skip-github; the GitHub release assets are no
longer synced per version (the code/tag mirror stays).
2026-09-15 13:37:29 +08:00
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
9 changed files with 316 additions and 23 deletions

No files matched your search

+6 -5
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`
@@ -24,12 +25,12 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
2. `npm run dist` 构建
3. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.2`
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release
- 可用 `--skip-github` / `--skip-gitea` 跳过某步
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理
3. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.11 --skip-github`
- **只发国内**(2026-09-15 起的产品决定):Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
5. `git tag v<版本号>` 并推送两个远程
5. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
## 更新机制
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.9",
"version": "1.0.11",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+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)
+60 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button, Modal, Tabs } from 'antd'
import type { TabsProps } from 'antd'
import { DEFAULT_SETTINGS } from '@shared/settings'
@@ -20,6 +20,53 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({
open: false
})
// Dialog size: proportional to the main window by default, user-draggable
// afterwards. `null` means "use the proportional default". While the dialog
// is open, a main-window resize re-derives that default in real time.
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
const [winSize, setWinSize] = useState({ w: window.innerWidth, h: window.innerHeight })
useEffect(() => {
if (open) setSize(null)
}, [open])
useEffect(() => {
if (!open) return
const onResize = (): void => setWinSize({ w: window.innerWidth, h: window.innerHeight })
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [open])
const clampToWindow = (w: number, h: number): { w: number; h: number } => ({
w: Math.max(720, Math.min(w, winSize.w - 96)),
h: Math.max(440, Math.min(h, winSize.h - 140))
})
const dialogSize = size
? clampToWindow(size.w, size.h)
: clampToWindow(Math.round(winSize.w * 0.7), Math.round(winSize.h * 0.7))
// The modal chrome (header + footer + paddings) is ~132px; the body takes the rest.
const bodyHeight = Math.max(360, dialogSize.h - 132)
const startResize = (e: React.MouseEvent<HTMLSpanElement>): void => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const base = dialogSize
const onMove = (ev: MouseEvent): void => {
const w = Math.max(720, Math.min(window.innerWidth - 96, base.w + (ev.clientX - startX)))
const h = Math.max(440, Math.min(window.innerHeight - 140, base.h + (ev.clientY - startY)))
setSize({ w, h })
}
const onUp = (): void => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'nwse-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const tabItems: TabsProps['items'] = [
{ key: 'font', label: '字体', children: <FontSettingsTab /> },
@@ -50,7 +97,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
<Modal
open={open}
onCancel={onClose}
width={760}
centered
width={dialogSize.w}
title="设置"
footer={
<div className="settings-footer">
@@ -61,7 +109,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
</div>
}
>
<Tabs defaultActiveKey="font" items={tabItems} />
<div className="settings-body" style={{ height: bodyHeight }}>
<Tabs tabPosition="left" defaultActiveKey="font" items={tabItems} />
</div>
{/* Drag corner: resizes the dialog until it is closed. */}
<span
className="settings-resize"
title="拖拽调整大小"
aria-label="拖拽调整大小"
onMouseDown={startResize}
/>
<ThemeEditor
open={editorState.open}
onClose={closeEditor}
+1 -1
View File
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
<SettingRow
label="粘贴风险确认"
desc="粘贴前弹出风险确认提示"
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
control={
<Switch
checked={settings.terminal.pasteRiskConfirm}
+69
View File
@@ -14,6 +14,75 @@
border-bottom: 1px solid var(--chrome-border);
}
/* ---------- left-rail navigation (tabPosition="left") ----------
The tab list doubles as the dialog's sidebar: fixed narrow column, roomy
click targets, active item tinted with the tab accent. The content column
scrolls on its own so long panes (字体/高亮) never grow the dialog. */
.settings-body .ant-tabs-left > .ant-tabs-nav {
margin: 0;
border-bottom: none;
border-right: 1px solid var(--chrome-border);
padding-right: 6px;
min-width: 148px;
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab {
margin: 2px 0;
padding: 8px 14px;
border-radius: 6px;
justify-content: flex-start;
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab:hover {
background: var(--chrome-hover);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab-active {
background: color-mix(in srgb, var(--tab-accent, #3fb950) 16%, transparent);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
background: var(--tab-accent, #3fb950);
}
.settings-body {
display: flex;
overflow: hidden;
}
.settings-body > .ant-tabs {
flex: 1;
min-height: 0;
}
.settings-body .ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
}
/* Corner grip for resizing the dialog (wired in SettingsDialog). */
.ant-modal .ant-modal-body {
position: relative;
}
.settings-resize {
position: absolute;
right: 3px;
bottom: 3px;
width: 16px;
height: 16px;
cursor: nwse-resize;
border-radius: 3px;
background:
linear-gradient(135deg, transparent 52%, var(--chrome-border) 52%, var(--chrome-border) 58%, transparent 58%),
linear-gradient(135deg, transparent 64%, var(--chrome-border) 64%, var(--chrome-border) 70%, transparent 70%),
linear-gradient(135deg, transparent 76%, var(--chrome-border) 76%, var(--chrome-border) 82%, transparent 82%);
}
.settings-resize:hover {
filter: brightness(1.6);
}
.settings-footer {
display: flex;
align-items: center;
+93 -12
View File
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ForwardRefExoticComponent, Ref } from 'react'
import { Terminal } from '@xterm/xterm'
import type { ITheme } from '@xterm/xterm'
import type { ILink, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
@@ -14,6 +14,7 @@ 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 { findUrls } from './urlLinks'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -32,10 +33,18 @@ 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
}
/**
@@ -511,19 +520,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback(
(text: string) => {
if (
!deadRef.current &&
settings.terminal.pasteRiskConfirm &&
!pasteConfirmedForSession &&
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({ noPrompt: false, disableDetection: false })
return
}
confirmPaste(text)
},
[settings.terminal.pasteRiskConfirm, confirmPaste]
[confirmPaste]
)
/** Confirm the risky-paste dialog: apply its choices, then paste. */
@@ -761,8 +768,55 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
if (out) term.write(out)
scheduleCarryFlush()
}
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
// the pointer cursor + underline advertise it on hover. A plain click stays
// with the terminal (cursor placement / selection), so a link can never
// open by accident while selecting text.
const linkProvider = term.registerLinkProvider({
provideLinks: (bufferLineNumber, callback) => {
const buffer = term.buffer.active
// provideLinks speaks 1-based line numbers, IBuffer.getLine 0-based.
let first = bufferLineNumber - 1
if (first < 0) {
callback(undefined)
return
}
while (first > 0 && buffer.getLine(first)?.isWrapped) first--
const rows: { index: number; text: string }[] = []
for (let row = first; row < buffer.length; row++) {
const line = buffer.getLine(row)
if (!line || (row > first && !line.isWrapped)) break
rows.push({ index: row, text: line.translateToString(false) })
}
const text = rows.map((r) => r.text).join('')
const cols = term.cols > 0 ? term.cols : 1
const base = rows[0]?.index ?? first
const links: ILink[] = findUrls(text).map((found) => {
const startRow = Math.floor(found.start / cols)
const endRow = Math.floor((found.end - 1) / cols)
return {
range: {
start: { x: (found.start % cols) + 1, y: base + startRow + 1 },
end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 }
},
text: found.url,
decorations: { pointerCursor: true, underline: true },
activate: (event: MouseEvent): void => {
const mod = navigator.platform.toLowerCase().includes('mac')
? event.metaKey
: event.ctrlKey
if (!mod) return
// The main process routes window.open through shell.openExternal.
window.open(found.url, '_blank', 'noopener')
}
}
})
callback(links.length > 0 ? links : undefined)
}
})
const disposables: { dispose(): void }[] = [
...oscDisposables,
linkProvider,
term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
@@ -873,9 +927,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()
+67
View File
@@ -0,0 +1,67 @@
/**
* Clickable URLs in terminal output.
*
* xterm has no link detection of its own for plain text (OSC 8 hyperlinks are
* a separate, opt-in protocol the CLI must emit), so a link provider scans the
* buffer line and hands back ranges. Two shapes are recognised:
* 1. fully qualified `http(s)://host[:port][/path][?query]`;
* 2. the scheme-less dev-server forms `localhost:5000`, `127.0.0.1:8080`,
* `0.0.0.0:3000` — opened as http.
*
* Because PTY output wraps long URLs across buffer rows, the provider rebuilds
* the whole *logical* line (walking back through `isWrapped` rows and forward
* to the last wrapped one) before matching, so a link split by the terminal
* width still resolves as one range.
*/
/** Characters that end a URL: whitespace, quotes, brackets, CJK punctuation. */
const URL_BODY = `[^\\s"'\`<>()\\[\\]{},。;:、]+`
const SCHEME_URL = new RegExp(`\\bhttps?://${URL_BODY}`, 'gi')
const FTP_URL = new RegExp(`\\bftp://${URL_BODY}`, 'gi')
const WWW_HOST = new RegExp(`\\bwww\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d{1,5})?(?:/${URL_BODY})?`, 'gi')
const BARE_HOST = new RegExp(`\\b(?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(?::\\d{1,5})(?:/${URL_BODY})?`, 'gi')
/** Trailing punctuation that belongs to the sentence, not the URL. */
const TRAILING = /[.,;:!?)\]}>"',。;:、]+$/
export interface FoundLink {
/** absolute offset of the first character, within the logical line */
start: number
/** absolute offset one past the last character */
end: number
/** the clickable target, scheme guaranteed */
url: string
}
/** Match every URL in one logical line of text. */
export function findUrls(text: string): FoundLink[] {
const out: FoundLink[] = []
const scan = (re: RegExp, withScheme: boolean): void => {
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const trimmed = m[0].replace(TRAILING, '')
if (!trimmed) continue
out.push({
start: m.index,
end: m.index + trimmed.length,
url: withScheme ? trimmed : `http://${trimmed}`
})
}
}
scan(SCHEME_URL, true)
scan(FTP_URL, true)
// `www.…` (and the bare host forms) get an http:// target when opened.
scan(WWW_HOST, false)
scan(BARE_HOST, false)
// A bare host inside a scheme URL (`http://localhost:5000`) matches both
// patterns; keep the longer, scheme-carrying one.
out.sort((a, b) => a.start - b.start || b.end - a.end)
const kept: FoundLink[] = []
for (const link of out) {
if (kept.some((k) => link.start < k.end && link.end > k.start)) continue
kept.push(link)
}
return kept
}
+1 -1
View File
@@ -97,7 +97,7 @@ export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'),
RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'),
RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'),
RULE('url', 'https?://[^\\s]+', 30, '#58a6ff', '网址链接')
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接')
]
export const DEFAULT_SETTINGS: AppSettings = {