Compare commits

..
11 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
21 changed files with 622 additions and 227 deletions

No files matched your search

+6 -5
View File
@@ -5,6 +5,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令 ## 常用命令
- 开发:`npm run dev`(主进程改动不热重建,需重启) - 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json` - 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap) - 打包:`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` - 国内网络需镜像:`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) 1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
2. `npm run dist` 构建 2. `npm run dist` 构建
3. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.2` 3. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.11 --skip-github`
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release - **只发国内**(2026-09-15 起的产品决定):Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)
- 可用 `--skip-github` / `--skip-gitea` 跳过某步 - 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理 - 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` 应返回新版本号 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)
## 更新机制 ## 更新机制
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"version": "0.8.0", "version": "1.0.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "0.8.0", "version": "1.0.12",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15", "@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.8", "version": "1.0.12",
"description": "Open-source terminal with SSH, split panes, themes and fonts", "description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "Bill", "author": "Bill",
+13 -9
View File
@@ -48,14 +48,16 @@ if (ymlVersion !== V) {
process.exit(1) 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 const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) { const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined
const { ProxyAgent, setGlobalDispatcher } = require('undici') if (proxy) console.log('proxy for GitHub only:', proxy)
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
async function upload(url, file, token, authScheme) { async function upload(url, file, token, authScheme, viaProxy = false) {
const name = path.basename(file) const name = path.basename(file)
const stat = fs.statSync(file) const stat = fs.statSync(file)
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, { 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) 'Content-Length': String(stat.size)
}, },
body: fs.createReadStream(file), body: fs.createReadStream(file),
duplex: 'half' duplex: 'half',
...(viaProxy && proxyAgent ? { dispatcher: proxyAgent } : {})
}) })
console.log(` upload ${name}: HTTP ${resp.status}`) console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) { if (!resp.ok) {
@@ -136,14 +139,15 @@ async function github() {
'User-Agent': 'OpenTerminal', 'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json' 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)}`) } if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json() const rel = await resp.json()
console.log('release created, id =', rel.id) console.log('release created, id =', rel.id)
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets` 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')]) { 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)
} }
} }
+36 -20
View File
@@ -73,11 +73,15 @@ export class CommandsStore {
/** Logs currently accumulating output, keyed by sessionId. */ /** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>() private readonly activeBySession = new Map<string, SessionLogMeta>()
/** /**
* Pending append per log file. appendFile is async, so two rapid logWrite * Buffered plain text per log file, flushed by a single drain loop per file.
* calls would otherwise race and land out of order. Chaining each new append * Writes land in the buffer synchronously (so order is preserved) and the
* onto the previous one keeps the file append-only in call order. * 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). */ /** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>() private readonly sanitizers = new Map<string, LogSanitizer>()
@@ -287,19 +291,35 @@ export class CommandsStore {
// Raw PTY output is escape-sequence soup in a text file; log plain text. // Raw PTY output is escape-sequence soup in a text file; log plain text.
const clean = this.sanitizers.get(sessionId)?.push(data) ?? '' const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
if (!clean) return if (!clean) return
// Chain onto any in-flight append so rapid writes land in call order. this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve() this.drainLog(meta.file)
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then( }
() => undefined,
() => { /**
* 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 // 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. run.finally(() => {
next.finally(() => { if (this.logDrains.get(file) === run) this.logDrains.delete(file)
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
}) })
this.logDrains.set(file, run)
return run
} }
/** Stop logging a session, stamping endedAt into the registry + index. */ /** Stop logging a session, stamping endedAt into the registry + index. */
@@ -310,12 +330,8 @@ export class CommandsStore {
this.sanitizers.delete(sessionId) this.sanitizers.delete(sessionId)
if (tail) { if (tail) {
// Best effort: the trailing partial line belongs in the file too. // Best effort: the trailing partial line belongs in the file too.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve() this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then( this.drainLog(meta.file)
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
} }
this.finishLog(meta) 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 // Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process. // it out of the tray if hidden there) instead of starting another process.
const gotSingleInstanceLock = app.requestSingleInstanceLock() const gotSingleInstanceLock = app.requestSingleInstanceLock()
@@ -64,6 +73,15 @@ function createWindow(): void {
win.on('ready-to-show', () => win.show()) 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). // Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => { win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate) void onMainWindowClose(win, e, showOrCreate)
+24 -9
View File
@@ -159,25 +159,40 @@ export function loadSettings(): AppSettings {
} }
} }
export function saveSettings(next: AppSettings): AppSettings { let settingsQueue: Promise<unknown> = Promise.resolve()
const merged = deepMerge({
terminal: next.terminal, /**
customThemes: next.customThemes, * Serialize a settings mutation against every other writer. Each queued step
highlightRules: next.highlightRules, * re-reads the file so the mutation applies on top of the latest state — a
system: next.system ?? DEFAULT_SYSTEM * full-snapshot save can no longer silently revert a change written between
}).settings * 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) applySystemSettings(merged.system)
applyWindowChrome(merged) applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true }) mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath() const path = settingsPath()
const tmp = `${path}.tmp` const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8') writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
renameSync(tmp, path) renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged) broadcast(Ipc.SETTINGS_CHANGED, merged)
return 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 { export function registerSettingsIpc(): void {
+7 -4
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron' import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings' import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore' import { loadSettings, mutateSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset' import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']> export type CloseAction = NonNullable<SystemSettings['closeAction']>
@@ -15,9 +15,12 @@ export function markQuitting(): void {
} }
function persistCloseAction(action: CloseAction): void { function persistCloseAction(action: CloseAction): void {
const next = loadSettings() // Serialized read-modify-write on the latest state: must not stomp (or be
next.system.closeAction = action // stomped by) a concurrent full-snapshot save from the settings UI.
saveSettings(next) void mutateSettings((current) => {
current.system.closeAction = action
return current
})
} }
function showMainWindow(showOrCreate: () => void): void { function showMainWindow(showOrCreate: () => void): void {
+8
View File
@@ -5,6 +5,14 @@
--chrome-border: #2d2d2d; --chrome-border: #2d2d2d;
--chrome-accent: #3b8eea; --chrome-accent: #3b8eea;
--chrome-hover: #2a2a2a; --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, 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 { Button, Modal, Tabs } from 'antd'
import type { TabsProps } from 'antd' import type { TabsProps } from 'antd'
import { DEFAULT_SETTINGS } from '@shared/settings' 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 }>({ const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({
open: false 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'] = [ const tabItems: TabsProps['items'] = [
{ key: 'font', label: '字体', children: <FontSettingsTab /> }, { key: 'font', label: '字体', children: <FontSettingsTab /> },
@@ -50,7 +97,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
<Modal <Modal
open={open} open={open}
onCancel={onClose} onCancel={onClose}
width={760} centered
width={dialogSize.w}
title="设置" title="设置"
footer={ footer={
<div className="settings-footer"> <div className="settings-footer">
@@ -61,7 +109,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
</div> </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 <ThemeEditor
open={editorState.open} open={editorState.open}
onClose={closeEditor} onClose={closeEditor}
+1 -1
View File
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
/> />
<SettingRow <SettingRow
label="粘贴风险确认" label="粘贴风险确认"
desc="粘贴前弹出风险确认提示" desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
control={ control={
<Switch <Switch
checked={settings.terminal.pasteRiskConfirm} checked={settings.terminal.pasteRiskConfirm}
+16 -13
View File
@@ -1,5 +1,8 @@
/* ============================================================ /* ============================================================
Highlight settings tab + rule editor (settings/highlight.css) 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 { .hl-tab {
@@ -21,15 +24,15 @@
} }
.hl-table .ant-table-thead > tr > th { .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); 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; font-size: 12px;
} }
.hl-table .ant-table-tbody > tr > td { .hl-table .ant-table-tbody > tr > td {
background: transparent; 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 { .hl-table .ant-table-tbody > tr:hover > td {
@@ -48,7 +51,7 @@
.hl-pattern-text { .hl-pattern-text {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.88); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -71,17 +74,17 @@
.hl-cell-priority { .hl-cell-priority {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.88); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
} }
.hl-cell-note { .hl-cell-note {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.7); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
} }
.hl-cell-empty { .hl-cell-empty {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.3); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
} }
.hl-preview-text { .hl-preview-text {
@@ -114,7 +117,7 @@
.hl-editor-label { .hl-editor-label {
flex: 0 0 56px; 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; padding-top: 5px;
} }
@@ -129,7 +132,7 @@
.hl-editor-hint { .hl-editor-hint {
align-self: center; align-self: center;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.45); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
} }
.hl-editor-error { .hl-editor-error {
@@ -149,7 +152,7 @@
.hl-editor-sub-label { .hl-editor-sub-label {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.6); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
} }
.hl-editor-inline { .hl-editor-inline {
@@ -170,7 +173,7 @@
width: 40px; width: 40px;
height: 26px; height: 26px;
padding: 0; 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; border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
@@ -197,7 +200,7 @@
.hl-palette-tag { .hl-palette-tag {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.5); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-right: 2px; margin-right: 2px;
} }
@@ -205,7 +208,7 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
border-radius: 3px; 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; cursor: pointer;
display: inline-block; display: inline-block;
transition: transform 0.12s ease, box-shadow 0.12s ease; 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/**) 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 ---------- */ /* ---------- settings dialog scaffolding ---------- */
@@ -14,6 +17,75 @@
border-bottom: 1px solid var(--chrome-border); 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 { .settings-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -37,7 +109,7 @@
} }
.settings-row:hover { .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 { .settings-row + .settings-row {
@@ -46,7 +118,7 @@
.settings-row-label { .settings-row-label {
flex: 0 0 46%; 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; font-size: 13px;
line-height: 1.4; line-height: 1.4;
} }
@@ -56,7 +128,7 @@
margin-top: 2px; margin-top: 2px;
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
color: rgba(255, 255, 255, 0.45); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
} }
.settings-row-control { .settings-row-control {
@@ -87,7 +159,7 @@
.settings-block-hint { .settings-block-hint {
font-weight: 400; font-weight: 400;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.45); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
} }
.settings-radio-group { .settings-radio-group {
@@ -123,7 +195,7 @@
.settings-preview-title { .settings-preview-title {
display: block; display: block;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.5); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -178,14 +250,14 @@
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.12); border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent);
background: rgba(255, 255, 255, 0.04); background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
} }
.theme-card:hover { .theme-card:hover {
border-color: var(--chrome-accent); 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 { .theme-card-selected {
@@ -211,7 +283,7 @@
.theme-card-name { .theme-card-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.88); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -224,7 +296,7 @@
padding: 2px 5px; padding: 2px 5px;
border-radius: 4px; border-radius: 4px;
background: rgba(59, 142, 234, 0.2); 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 { .theme-card-dots {
@@ -237,7 +309,7 @@
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 3px; 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; display: inline-block;
} }
@@ -267,12 +339,12 @@
.theme-card-new-icon { .theme-card-new-icon {
font-size: 26px; font-size: 26px;
line-height: 1; 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 { .theme-card-new-text {
font-size: 13px; font-size: 13px;
color: rgba(255, 255, 255, 0.6); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
} }
/* ---------- theme editor ---------- */ /* ---------- theme editor ---------- */
@@ -290,7 +362,7 @@
.theme-editor-label { .theme-editor-label {
flex: 0 0 56px; 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 { .theme-editor-colors {
@@ -310,7 +382,7 @@
.theme-editor-color-label { .theme-editor-color-label {
font-size: 12px; 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; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -322,7 +394,7 @@
width: 40px; width: 40px;
height: 26px; height: 26px;
padding: 0; 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; border-radius: 4px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
@@ -339,7 +411,7 @@
.theme-editor-divider { .theme-editor-divider {
grid-column: 1 / -1; 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; margin: 2px 0;
} }
@@ -350,7 +422,7 @@
.theme-editor-preview-label { .theme-editor-preview-label {
display: block; display: block;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.5); color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -426,7 +498,7 @@
} }
.about-changelog { .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; padding-top: 12px;
} }
+145 -46
View File
@@ -1,18 +1,20 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ForwardRefExoticComponent, Ref } from 'react' import type { ForwardRefExoticComponent, Ref } from 'react'
import { Terminal } from '@xterm/xterm' 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 { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl' import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search' import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { getThemeById } from '@shared/theme' import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings' import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store' import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore' import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine' import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { findUrls } from './urlLinks'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher' import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker' import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore' import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -31,12 +33,27 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
return n return n
} }
/** Paste-risk heuristic: multi-line or unreasonably long text. */ /**
function isRiskyPaste(text: string): boolean { * Paste-risk heuristic, split by shape rather than by raw length:
if (text.includes('\n') || text.includes('\r')) return true * - two or more lines always confirm — a stray newline executes a command
return text.length > 100 * 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']> = { const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
matchBackground: '#3b8eea', matchBackground: '#3b8eea',
matchBorder: '#2d2d2d', matchBorder: '#2d2d2d',
@@ -306,7 +323,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// time (decides if 复制 is enabled). // time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false) 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 [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0) const [exitCode, setExitCode] = useState(0)
const [recording, setRecording] = useState(false) const [recording, setRecording] = useState(false)
@@ -457,40 +474,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[sessionId] [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( const confirmPaste = useCallback(
(text: string) => { (text: string) => {
const data = nativePasteRef.current ?? text const data = nativePasteRef.current ?? text
nativePasteRef.current = null nativePasteRef.current = null
setPaste(null) setPaste(null)
if (!deadRef.current) { // Route through xterm's own paste: when the shell asked for bracketed
writeBroadcast(sessionId, data) // paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
trackPastedText(data) // 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() termRef.current?.focus()
}, },
[sessionId, trackPastedText] []
) )
const cancelPaste = useCallback(() => { const cancelPaste = useCallback(() => {
@@ -523,16 +520,28 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback( const beginPaste = useCallback(
(text: string) => { (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 nativePasteRef.current = text
setPaste(text) setPaste({ noPrompt: false, disableDetection: false })
return return
} }
confirmPaste(text) 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 copySelection = useCallback(async () => {
const term = termRef.current const term = termRef.current
if (!term) return if (!term) return
@@ -759,8 +768,55 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
if (out) term.write(out) if (out) term.write(out)
scheduleCarryFlush() 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 }[] = [ const disposables: { dispose(): void }[] = [
...oscDisposables, ...oscDisposables,
linkProvider,
term.onData((data) => { term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data) if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it // M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
@@ -871,9 +927,36 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
observerRef.current = observer 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 () => { return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer) if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current) if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
host?.removeEventListener('keydown', onPasteKey, true)
for (const unsubscribe of unsubscribes) unsubscribe() for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
observer?.disconnect() observer?.disconnect()
@@ -1103,17 +1186,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined /> <FolderOpenOutlined />
</button> </button>
</div> </div>
{paste !== null && !dead && ( <Modal
<div className="term-pastebar"> open={paste !== null}
<span className="term-pastebar-text">检测到多行/长文本粘贴</span> title="确认粘贴"
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}> okText="粘贴"
粘贴 cancelText="取消"
</button> width={420}
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}> maskClosable={false}
取消 onOk={acceptPaste}
</button> 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> </div>
)} </Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion {/* Relative-positioned wrapper around the xterm surface: the suggestion
popup anchors to this box, so its coordinates start at the terminal popup anchors to this box, so its coordinates start at the terminal
content rows (the search bar above never offsets the top). */} content rows (the search bar above never offsets the top). */}
+12 -43
View File
@@ -328,51 +328,20 @@
} }
/* ---- paste risk confirm bar ---- */ /* ---- 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; display: flex;
align-items: center; margin-bottom: 8px;
gap: 8px; }
flex: none;
padding: 4px 8px; .term-paste-confirm-hint {
background: #3a2e1a; margin: 12px 0 0 !important;
border-bottom: 1px solid #5c4a26;
font-size: 12px; font-size: 12px;
color: #e8d9a8; opacity: 0.65;
}
.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);
} }
/* ---- terminal docked area (flex:1) ---- */ /* ---- 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] 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 { function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0') 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)}` 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 /** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
* terminal theme so the whole window follows theme switches instead of only * terminal theme so the whole window follows theme switches instead of only
* the terminal canvas. Values land on :root CSS variables; anything the theme * 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-bg', colors.background)
set('--chrome-fg', colors.foreground) 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-bg-deep', mix(colors.background, '#000000', 0.35))
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08)) set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
set('--chrome-border', mix(colors.background, colors.foreground, 0.16)) 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 /** Tab accent: base color + a foreground variant that stays readable on the
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */ * bar (lightened on dark chrome, darkened on light chrome). Consumed via
* var(--tab-accent*) in workspace.css. */
export function applyTabAccent(color: string): void { export function applyTabAccent(color: string): void {
const style = document.documentElement.style const style = document.documentElement.style
// Settings hydrated from an older main process may lack the key; clear the // Settings hydrated from an older main process may lack the key; clear the
@@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void {
return return
} }
style.setProperty('--tab-accent', color) 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)
} }
+22 -30
View File
@@ -61,26 +61,20 @@ const TERMINAL_COMPONENT = 'terminal'
const TERMINAL_TAB_COMPONENT = 'terminal-tab' const TERMINAL_TAB_COMPONENT = 'terminal-tab'
/** /**
* Per-workspace monotonic title counter for "终端 N". Lives on `window` so * "终端 N" titles fill the lowest free number among live panels: closing a
* dev hot-reloads (which reset module state) don't restart the numbering and * pane frees its number for the next new terminal, and a session restore can
* produce duplicate tab titles. * 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(existing: Iterable<string | undefined>): string {
function nextTerminalTitle(): string { const used = new Set<number>()
titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1 for (const title of existing) {
return `终端 ${titleSeqHolder.__otTitleSeq}`
}
/**
* Lift the counter past every "终端 N" currently on screen. The counter lives
* only in memory, so after a session restore / template apply it is behind the
* restored titles and the next new terminal duplicates one (`终端 2` twice).
*/
function syncTitleSeq(titles: Iterable<string | undefined>): void {
for (const title of titles) {
const n = title?.match(/^终端 (\d+)$/)?.[1] const n = title?.match(/^终端 (\d+)$/)?.[1]
if (n) titleSeqHolder.__otTitleSeq = Math.max(titleSeqHolder.__otTitleSeq ?? 0, Number(n)) if (n) used.add(Number(n))
} }
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
} }
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -200,7 +194,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(containerApi.panels.map((p) => p.title)),
params: { sessionId: result.id, sessionKind: 'local' }, params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' } position: { referenceGroup: group.id, direction: 'within' }
}) })
@@ -630,7 +624,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(current.panels.map((p) => p.title)),
params: { sessionId, sessionKind: 'local' }, params: { sessionId, sessionKind: 'local' },
position: position:
reference && direction reference && direction
@@ -873,17 +867,18 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames]) const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
await rebindSessionPanels(terminal, snapshot, true, renames) await rebindSessionPanels(terminal, snapshot, true, renames)
await rebindSessionPanels(ssh, snapshot, true, renames) await rebindSessionPanels(ssh, snapshot, true, renames)
const allPanels = [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])] // Retitle duplicates left over from the old counter bug (the snapshot it
syncTitleSeq(allPanels.map((p) => p.title)) // wrote keeps colliding titles forever otherwise): the first panel keeps
// Retitle duplicates left over from the counter bug (the snapshot the bug // the title, later ones get the lowest free number.
// wrote keeps colliding titles forever otherwise): first panel keeps the
// title, later ones get a fresh number past the restored maximum.
const seenTitles = new Set<string>() const seenTitles = new Set<string>()
for (const p of allPanels) { for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
const t = p.title ?? '' const t = p.title ?? ''
if (!t) continue if (!t) continue
if (seenTitles.has(t)) p.setTitle(nextTerminalTitle()) if (seenTitles.has(t)) {
else seenTitles.add(t) const fresh = nextTerminalTitle(seenTitles)
p.setTitle(fresh)
seenTitles.add(fresh)
} else seenTitles.add(t)
} }
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
useWorkspaceModeStore.getState().setMode(snapshot.mode) useWorkspaceModeStore.getState().setMode(snapshot.mode)
@@ -932,9 +927,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// Fresh sessions for every restored terminal panel in both workspaces. // Fresh sessions for every restored terminal panel in both workspaces.
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current) if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
syncTitleSeq(
[...(terminalApiRef.current?.panels ?? []), ...(sshApiRef.current?.panels ?? [])].map((p) => p.title)
)
// Old sessions are unreachable after the layout swap — kill them. // Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid) for (const sid of previousSessions) killSession(sid)
+32 -18
View File
@@ -35,14 +35,14 @@
border: none; border: none;
border-radius: 5px; border-radius: 5px;
background: transparent; background: transparent;
color: #9a9a9a; color: var(--chrome-tab-fg, #9a9a9a);
font-size: 15px; font-size: 15px;
cursor: pointer; cursor: pointer;
} }
.workspace-rail-btn:hover { .workspace-rail-btn:hover {
background: var(--chrome-hover); background: var(--chrome-hover);
color: #ffffff; color: var(--chrome-fg, #ffffff);
} }
.workspace-rail-divider { .workspace-rail-divider {
@@ -54,7 +54,7 @@
/* ---- M6.1 rail active state (mode entries) ---- */ /* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active { .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); box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent); 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. */ /* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28); --dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75); --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 ---- */ /* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
@@ -191,16 +203,18 @@
border-radius: 3px; border-radius: 3px;
padding: 0 8px; padding: 0 8px;
margin-right: 4px; margin-right: 4px;
background: rgba(255, 255, 255, 0.04); background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1));
color: #98a2ab; color: var(--chrome-tab-fg, #98a2ab);
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
} }
.workspace-dockview .dv-tab.dv-inactive-tab:hover { /* Group-scoped chain matches dockview's own specificity so the hover tint
background: rgba(255, 255, 255, 0.07); wins over its group-level background rules. */
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20); .workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover {
color: #d0d0d0; 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. */ /* XTerminal-style active tab: green outline + tinted bg + green title. */
@@ -229,7 +243,7 @@
width: 15px; width: 15px;
height: 15px; height: 15px;
border-radius: 2px; 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; opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; 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; padding: 0;
border: none; border: none;
border-radius: 6px; 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; background: transparent;
color: #98a2ab; color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -464,18 +478,18 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 0; padding: 0;
border: none; border: none;
border-radius: 6px; 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; background: transparent;
color: #98a2ab; color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
} }
.workspace-tabbar-btn2:hover { .workspace-tabbar-btn2:hover {
background: rgba(255, 255, 255, 0.07); background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24); box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24));
color: #ffffff; color: var(--chrome-tab-fg-hover, #ffffff);
} }
.workspace-tabbar-btn2.is-active { .workspace-tabbar-btn2.is-active {
+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('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('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('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 = { 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() store.openLogsDir()
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir') 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') console.log('\n[commands] ALL CHECKS PASSED')
process.exit(0) process.exit(0)