Compare commits

...
15 Commits
Author SHA1 Message Date
Bill c1744184a3 chore: bump version to 1.0.12 2026-09-17 09:26:20 +08:00
Bill b3744b4705 fix: luminance-aware chrome theming for light themes; serialized settings writes; buffered log flush
- Derive tab-bar/chrome palette by background luminance: light themes keep
  a near-background bar with black-tinted tab overlays instead of a muddy
  gray strip; dark themes unchanged
- Theme dockview tabs via the group-scoped --dv-*-tab-* vars its own rules
  consume (they outspecify our .dv-tab rules and leaked abyss navy onto
  light tabs); bump inactive-tab hover specificity to match
- Convert settings dialog + highlight editor hardcoded white text/border
  tints to color-mix over --chrome-fg so panes stay readable on light themes
- commands.ts: per-file log write buffer with a single drain loop per file
  (burst output coalesces into one appendFile per IO tick, chain no longer
  grows per logWrite); stop-tail rides the same buffer
- settingsStore: serialize all writers through mutateSettings() queue that
  re-reads latest state per mutation (tray close-action vs settings UI full
  saves no longer clobber each other)
- tests: burst ordering + stop-tail case for the log buffer
2026-09-17 09:11:05 +08:00
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
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
24 changed files with 791 additions and 552 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
@@ -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 待用户复测)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "0.8.0",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "0.8.0",
"version": "1.0.12",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.7",
"version": "1.0.12",
"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)
}
}
+48 -14
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
@@ -72,11 +73,17 @@ export class CommandsStore {
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
/**
* Pending append per log file. appendFile is async, so two rapid logWrite
* calls would otherwise race and land out of order. Chaining each new append
* onto the previous one keeps the file append-only in call order.
* Buffered plain text per log file, flushed by a single drain loop per file.
* Writes land in the buffer synchronously (so order is preserved) and the
* drain merges everything that accumulated during the previous appendFile
* round trip into one append call — burst output costs one syscall per IO
* tick instead of one Promise chain link per logWrite.
*/
private readonly pendingAppends = new Map<string, Promise<void>>()
private readonly logBuffers = new Map<string, string>()
/** In-flight drain loop per log file; absent when the file is settled. */
private readonly logDrains = 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 +276,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,25 +288,51 @@ export class CommandsStore {
// not logging (or already stopped) -> ignore, prevents stop/append race
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(
() => undefined,
() => {
// 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
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
this.drainLog(meta.file)
}
/**
* Flush a log file's buffer, keeping at most one drain (and therefore one
* in-flight appendFile) per file. New writes that arrive mid-flush re-enter
* the buffer and are picked up by the next loop iteration, merged.
*/
private drainLog(file: string): Promise<void> {
const existing = this.logDrains.get(file)
if (existing) return existing
const run = (async (): Promise<void> => {
for (;;) {
const chunk = this.logBuffers.get(file)
if (chunk === undefined) break
this.logBuffers.delete(file)
try {
await appendFile(file, chunk, 'utf8')
} catch {
// file may have been removed after stop -> ignore
}
)
this.pendingAppends.set(meta.file, next)
// Avoid unbounded growth of the chain map once the file settles.
next.finally(() => {
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
}
})()
run.finally(() => {
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
})
this.logDrains.set(file, run)
return run
}
/** Stop logging a session, stamping endedAt into the registry + index. */
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.
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
this.drainLog(meta.file)
}
this.finishLog(meta)
}
+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)
+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
}
}
+24 -9
View File
@@ -159,25 +159,40 @@ export function loadSettings(): AppSettings {
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules,
system: next.system ?? DEFAULT_SYSTEM
}).settings
let settingsQueue: Promise<unknown> = Promise.resolve()
/**
* Serialize a settings mutation against every other writer. Each queued step
* re-reads the file so the mutation applies on top of the latest state — a
* full-snapshot save can no longer silently revert a change written between
* its read and its write (e.g. tray close-action vs settings UI save).
*/
export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): Promise<AppSettings> {
const run = settingsQueue.then((): AppSettings => {
const merged = deepMerge(mutate(loadSettings())).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
})
// Keep the queue alive when a mutation throws; the caller still sees it.
settingsQueue = run.then(
() => undefined,
() => undefined
)
return run
}
/** Replace the persisted settings with `next` (serialized). */
export function saveSettings(next: AppSettings): Promise<AppSettings> {
return mutateSettings((current) => ({ ...current, ...next }))
}
export function registerSettingsIpc(): void {
+7 -4
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import { loadSettings, mutateSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
@@ -15,9 +15,12 @@ export function markQuitting(): void {
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
// Serialized read-modify-write on the latest state: must not stomp (or be
// stomped by) a concurrent full-snapshot save from the settings UI.
void mutateSettings((current) => {
current.system.closeAction = action
return current
})
}
function showMainWindow(showOrCreate: () => void): void {
+8
View File
@@ -5,6 +5,14 @@
--chrome-border: #2d2d2d;
--chrome-accent: #3b8eea;
--chrome-hover: #2a2a2a;
--chrome-bg-deep: #101418;
/* Tab-bar overlays; overwritten per theme by applyChromeTheme(). */
--chrome-tab-bg: rgba(255, 255, 255, 0.04);
--chrome-tab-bg-hover: rgba(255, 255, 255, 0.07);
--chrome-tab-ring: rgba(255, 255, 255, 0.1);
--chrome-tab-ring-hover: rgba(255, 255, 255, 0.2);
--chrome-tab-fg: #98a2ab;
--chrome-tab-fg-hover: #d0d0d0;
}
html,
+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}
+16 -13
View File
@@ -1,5 +1,8 @@
/* ============================================================
Highlight settings tab + rule editor (settings/highlight.css)
Text/border tints derive from --chrome-fg via color-mix so the
panel follows the active terminal theme (white tints on dark
themes, black tints on light themes).
============================================================ */
.hl-tab {
@@ -21,15 +24,15 @@
}
.hl-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.04);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
border-bottom: 1px solid var(--chrome-border);
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 85%, transparent);
font-size: 12px;
}
.hl-table .ant-table-tbody > tr > td {
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-bottom: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
}
.hl-table .ant-table-tbody > tr:hover > td {
@@ -48,7 +51,7 @@
.hl-pattern-text {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -71,17 +74,17 @@
.hl-cell-priority {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
}
.hl-cell-note {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
}
.hl-cell-empty {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
}
.hl-preview-text {
@@ -114,7 +117,7 @@
.hl-editor-label {
flex: 0 0 56px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
padding-top: 5px;
}
@@ -129,7 +132,7 @@
.hl-editor-hint {
align-self: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.hl-editor-error {
@@ -149,7 +152,7 @@
.hl-editor-sub-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
}
.hl-editor-inline {
@@ -170,7 +173,7 @@
width: 40px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
border-radius: 4px;
background: transparent;
cursor: pointer;
@@ -197,7 +200,7 @@
.hl-palette-tag {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-right: 2px;
}
@@ -205,7 +208,7 @@
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
cursor: pointer;
display: inline-block;
transition: transform 0.12s ease, box-shadow 0.12s ease;
+91 -19
View File
@@ -1,5 +1,8 @@
/* ============================================================
Settings dialog + theme editor styles (settings/**, theme/**)
Text/border tints derive from --chrome-fg via color-mix so the
dialog follows the active terminal theme (white tints on dark
themes, black tints on light themes).
============================================================ */
/* ---------- settings dialog scaffolding ---------- */
@@ -14,6 +17,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;
@@ -37,7 +109,7 @@
}
.settings-row:hover {
background: rgba(255, 255, 255, 0.03);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 3%, transparent);
}
.settings-row + .settings-row {
@@ -46,7 +118,7 @@
.settings-row-label {
flex: 0 0 46%;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
font-size: 13px;
line-height: 1.4;
}
@@ -56,7 +128,7 @@
margin-top: 2px;
font-size: 12px;
font-weight: 400;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.settings-row-control {
@@ -87,7 +159,7 @@
.settings-block-hint {
font-weight: 400;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.settings-radio-group {
@@ -123,7 +195,7 @@
.settings-preview-title {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 8px;
}
@@ -178,14 +250,14 @@
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.theme-card:hover {
border-color: var(--chrome-accent);
background: rgba(255, 255, 255, 0.07);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 7%, transparent);
}
.theme-card-selected {
@@ -211,7 +283,7 @@
.theme-card-name {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -224,7 +296,7 @@
padding: 2px 5px;
border-radius: 4px;
background: rgba(59, 142, 234, 0.2);
color: #6fb3ff;
color: color-mix(in srgb, var(--chrome-accent, #3b8eea) 55%, var(--chrome-fg, #cccccc));
}
.theme-card-dots {
@@ -237,7 +309,7 @@
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.25);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 25%, transparent);
display: inline-block;
}
@@ -267,12 +339,12 @@
.theme-card-new-icon {
font-size: 26px;
line-height: 1;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
}
.theme-card-new-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
}
/* ---------- theme editor ---------- */
@@ -290,7 +362,7 @@
.theme-editor-label {
flex: 0 0 56px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
}
.theme-editor-colors {
@@ -310,7 +382,7 @@
.theme-editor-color-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
@@ -322,7 +394,7 @@
width: 40px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
border-radius: 4px;
background: transparent;
cursor: pointer;
@@ -339,7 +411,7 @@
.theme-editor-divider {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 10%, transparent);
margin: 2px 0;
}
@@ -350,7 +422,7 @@
.theme-editor-preview-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 6px;
}
@@ -426,7 +498,7 @@
}
.about-changelog {
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 8%, transparent);
padding-top: 12px;
}
+145 -46
View File
@@ -1,18 +1,20 @@
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'
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'
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'
@@ -31,12 +33,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 +323,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 +474,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 +520,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
@@ -759,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
@@ -871,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()
@@ -1103,17 +1186,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) ---- */
+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
}
+42 -3
View File
@@ -7,6 +7,17 @@ function parseHex(color: string): [number, number, number] | null {
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */
function luminance(color: string): number | null {
const c = parseHex(color)
if (!c) return null
const lin = c.map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
@@ -19,6 +30,10 @@ export function mix(a: string, b: string, t: number): string | null {
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
}
/** Whether the active terminal theme has a light background. Tab bars derived
* from it flip their overlay direction (white-on-dark vs black-on-light). */
let chromeIsLight = false
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
* terminal theme so the whole window follows theme switches instead of only
* the terminal canvas. Values land on :root CSS variables; anything the theme
@@ -30,13 +45,36 @@ export function applyChromeTheme(colors: ThemeColors): void {
}
set('--chrome-bg', colors.background)
set('--chrome-fg', colors.foreground)
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
if (chromeIsLight) {
// Light chrome: keep the bar close to the background (a big mix toward
// black turns pale themes into a muddy gray strip) and overlay tabs with
// translucent black instead of the translucent white used on dark themes.
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.08))
set('--chrome-hover', mix(colors.background, '#000000', 0.06))
set('--chrome-border', mix(colors.background, '#000000', 0.2))
set('--chrome-tab-bg', 'rgba(0, 0, 0, 0.045)')
set('--chrome-tab-bg-hover', 'rgba(0, 0, 0, 0.09)')
set('--chrome-tab-ring', 'rgba(0, 0, 0, 0.18)')
set('--chrome-tab-ring-hover', 'rgba(0, 0, 0, 0.32)')
set('--chrome-tab-fg', mix(colors.background, '#000000', 0.68))
set('--chrome-tab-fg-hover', mix(colors.background, '#000000', 0.88))
} else {
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
set('--chrome-tab-bg', 'rgba(255, 255, 255, 0.04)')
set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)')
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
set('--chrome-tab-fg', '#98a2ab')
set('--chrome-tab-fg-hover', '#d0d0d0')
}
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
/** Tab accent: base color + a foreground variant that stays readable on the
* bar (lightened on dark chrome, darkened on light chrome). Consumed via
* var(--tab-accent*) in workspace.css. */
export function applyTabAccent(color: string): void {
const style = document.documentElement.style
// Settings hydrated from an older main process may lack the key; clear the
@@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void {
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
const fg = chromeIsLight ? mix(color, '#000000', 0.35) : mix(color, '#ffffff', 0.18)
style.setProperty('--tab-accent-fg', fg ?? color)
}
@@ -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.
+33 -160
View File
@@ -35,14 +35,14 @@
border: none;
border-radius: 5px;
background: transparent;
color: #9a9a9a;
color: var(--chrome-tab-fg, #9a9a9a);
font-size: 15px;
cursor: pointer;
}
.workspace-rail-btn:hover {
background: var(--chrome-hover);
color: #ffffff;
color: var(--chrome-fg, #ffffff);
}
.workspace-rail-divider {
@@ -54,7 +54,7 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
color: var(--tab-accent-fg, #e8e8e8);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
}
@@ -179,6 +179,18 @@
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
/* Tab surfaces. Dockview paints tabs through group-scoped rules that
outspecify our .dv-tab rules, so theme the tabs via the vars those rules
consume — otherwise the abyss defaults (dark navy) leak onto light themes. */
--dv-activegroup-visiblepanel-tab-background-color: color-mix(
in srgb, var(--tab-accent, #3fb950) 10%, transparent);
--dv-activegroup-visiblepanel-tab-color: var(--tab-accent-fg, #3fd06a);
--dv-activegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-activegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
--dv-inactivegroup-visiblepanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-inactivegroup-visiblepanel-tab-color: var(--chrome-tab-fg, #98a2ab);
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-inactivegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
}
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
@@ -191,16 +203,18 @@
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
color: #98a2ab;
background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1));
color: var(--chrome-tab-fg, #98a2ab);
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
color: #d0d0d0;
/* Group-scoped chain matches dockview's own specificity so the hover tint
wins over its group-level background rules. */
.workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover {
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.2));
color: var(--chrome-tab-fg-hover, #d0d0d0);
}
/* XTerminal-style active tab: green outline + tinted bg + green title. */
@@ -229,7 +243,7 @@
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.16));
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
@@ -432,9 +446,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
background: transparent;
color: #98a2ab;
color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -446,7 +460,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;
@@ -464,18 +478,18 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
background: transparent;
color: #98a2ab;
color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-btn2:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
color: #ffffff;
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24));
color: var(--chrome-tab-fg-hover, #ffffff);
}
.workspace-tabbar-btn2.is-active {
@@ -574,147 +588,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;
+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 = {
+18
View File
@@ -198,5 +198,23 @@ ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log final
store.openLogsDir()
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
// ---- 6. Burst ordering + stop tail (per-file buffer + single drain) -------------
const sid2 = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const startB = store.logStart(sid2)
// Burst 1: 300 writes in one tick must coalesce into ordered appends.
for (let i = 0; i < 300; i++) store.logWrite(sid2, `a${String(i).padStart(3, '0')}\n`)
await wait(10)
// Burst 2 after a gap: may land while burst 1's drain is still in flight.
for (let i = 0; i < 100; i++) store.logWrite(sid2, `b${String(i).padStart(3, '0')}\n`)
// Trailing partial line must ride the same buffer via logStop.
store.logWrite(sid2, 'partial-tail')
store.logStop(sid2)
await wait(200)
const expected =
Array.from({ length: 300 }, (_, i) => `a${String(i).padStart(3, '0')}\n`).join('') +
Array.from({ length: 100 }, (_, i) => `b${String(i).padStart(3, '0')}\n`).join('') +
'partial-tail'
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
console.log('\n[commands] ALL CHECKS PASSED')
process.exit(0)