Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1744184a3 | ||
|
|
b3744b4705 | ||
|
|
9e453e272c | ||
|
|
237265e6ba | ||
|
|
3b2fe06f71 | ||
|
|
97fc7171cc | ||
|
|
d3d029fe4f | ||
|
|
77fd241b43 | ||
|
|
4b336360f8 | ||
|
|
d7abbaad9c | ||
|
|
cbf4c224fb | ||
|
|
03aa79a691 | ||
|
|
458dd9c88b | ||
|
|
28d8af6982 | ||
|
|
f09bed06b5 |
No files matched your search
@@ -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)
|
||||||
|
|
||||||
## 更新机制
|
## 更新机制
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@
|
|||||||
## M6 — 多终端协同 ✅
|
## M6 — 多终端协同 ✅
|
||||||
|
|
||||||
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
|
- [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] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
|
||||||
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
||||||
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"productName": "OpenTerminal",
|
"productName": "OpenTerminal",
|
||||||
"version": "1.0.7",
|
"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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-15
@@ -26,6 +26,7 @@ import { join } from 'path'
|
|||||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||||
import { loadSettings } from './settingsStore'
|
import { loadSettings } from './settingsStore'
|
||||||
|
import { LogSanitizer } from './logSanitizer'
|
||||||
|
|
||||||
/** Upper bound on recorded history entries when no limit is configured. */
|
/** Upper bound on recorded history entries when no limit is configured. */
|
||||||
const HISTORY_CAP = 500
|
const HISTORY_CAP = 500
|
||||||
@@ -72,11 +73,17 @@ 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). */
|
||||||
|
private readonly sanitizers = new Map<string, LogSanitizer>()
|
||||||
|
|
||||||
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
||||||
const ud = userData ?? app.getPath('userData')
|
const ud = userData ?? app.getPath('userData')
|
||||||
@@ -269,6 +276,7 @@ export class CommandsStore {
|
|||||||
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
|
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
|
||||||
this.metasByFile.set(file, meta)
|
this.metasByFile.set(file, meta)
|
||||||
this.activeBySession.set(sessionId, meta)
|
this.activeBySession.set(sessionId, meta)
|
||||||
|
this.sanitizers.set(sessionId, new LogSanitizer())
|
||||||
this.persistIndex()
|
this.persistIndex()
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
@@ -280,25 +288,51 @@ export class CommandsStore {
|
|||||||
// not logging (or already stopped) -> ignore, prevents stop/append race
|
// not logging (or already stopped) -> ignore, prevents stop/append race
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Chain onto any in-flight append so rapid writes land in call order.
|
// Raw PTY output is escape-sequence soup in a text file; log plain text.
|
||||||
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
|
||||||
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then(
|
if (!clean) return
|
||||||
() => undefined,
|
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
|
||||||
() => {
|
this.drainLog(meta.file)
|
||||||
// file may have been removed after stop -> ignore
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
run.finally(() => {
|
||||||
// Avoid unbounded growth of the chain map once the file settles.
|
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
|
||||||
next.finally(() => {
|
|
||||||
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. */
|
||||||
logStop(sessionId: string): void {
|
logStop(sessionId: string): void {
|
||||||
const meta = this.activeBySession.get(sessionId)
|
const meta = this.activeBySession.get(sessionId)
|
||||||
if (!meta) return
|
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)
|
this.finishLog(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-17
@@ -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,
|
|
||||||
highlightRules: next.highlightRules,
|
|
||||||
system: next.system ?? DEFAULT_SYSTEM
|
|
||||||
}).settings
|
|
||||||
applySystemSettings(merged.system)
|
|
||||||
applyWindowChrome(merged)
|
|
||||||
const pretty = JSON.stringify(merged, null, 2)
|
|
||||||
|
|
||||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
/**
|
||||||
const path = settingsPath()
|
* Serialize a settings mutation against every other writer. Each queued step
|
||||||
const tmp = `${path}.tmp`
|
* re-reads the file so the mutation applies on top of the latest state — a
|
||||||
writeFileSync(tmp, pretty, 'utf8')
|
* full-snapshot save can no longer silently revert a change written between
|
||||||
renameSync(tmp, path)
|
* 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)
|
||||||
|
|
||||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||||
return merged
|
const path = settingsPath()
|
||||||
|
const tmp = `${path}.tmp`
|
||||||
|
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 {
|
export function registerSettingsIpc(): void {
|
||||||
|
|||||||
+7
-4
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -218,4 +221,4 @@
|
|||||||
.hl-swatch-selected {
|
.hl-swatch-selected {
|
||||||
box-shadow: 0 0 0 2px var(--chrome-accent) inset;
|
box-shadow: 0 0 0 2px var(--chrome-accent) inset;
|
||||||
border-color: var(--chrome-accent);
|
border-color: var(--chrome-accent);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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). */}
|
||||||
|
|||||||
@@ -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) ---- */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
|
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
|
||||||
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
|
if (chromeIsLight) {
|
||||||
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
|
// 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
|
/** 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)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined
|
||||||
ThunderboltOutlined
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { DockviewReact } from 'dockview-react'
|
import { DockviewReact } from 'dockview-react'
|
||||||
import type {
|
import type {
|
||||||
@@ -33,7 +32,6 @@ import { HostKeyModal } from './HostKeyModal'
|
|||||||
import { TerminalPanel } from './TerminalPanel'
|
import { TerminalPanel } from './TerminalPanel'
|
||||||
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
||||||
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
||||||
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
|
|
||||||
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
||||||
import { useSettingsStore } from '@renderer/settings/store'
|
import { useSettingsStore } from '@renderer/settings/store'
|
||||||
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
||||||
@@ -63,14 +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}`
|
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 {
|
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
||||||
@@ -190,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' }
|
||||||
})
|
})
|
||||||
@@ -201,7 +205,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
|||||||
<div className="workspace-tabbar-actions">
|
<div className="workspace-tabbar-actions">
|
||||||
<AddTerminalGroupButton onClick={handleAdd} />
|
<AddTerminalGroupButton onClick={handleAdd} />
|
||||||
<BroadcastToggleButton />
|
<BroadcastToggleButton />
|
||||||
<QuickInputToggleButton />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -240,7 +243,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
/** Per-workspace active panel used as the split anchor. */
|
/** Per-workspace active panel used as the split anchor. */
|
||||||
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||||
const activeSshPanelRef = 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)
|
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||||
|
|
||||||
/** Sessions we already asked to kill — kill once, never twice. */
|
/** Sessions we already asked to kill — kill once, never twice. */
|
||||||
@@ -621,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
|
||||||
@@ -864,6 +867,19 @@ 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)
|
||||||
|
// 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
|
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
||||||
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
||||||
recountAll()
|
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 (
|
return (
|
||||||
<div className="workspace-root">
|
<div className="workspace-root">
|
||||||
<IconRail
|
<IconRail
|
||||||
@@ -1056,11 +1065,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
</div>
|
</div>
|
||||||
</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). */}
|
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
|
||||||
{requestedConn != null && (
|
{requestedConn != null && (
|
||||||
<ConnectFlow
|
<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.
|
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
||||||
* Top: workspace actions; bottom: settings.
|
* Top: workspace actions; bottom: settings.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -446,7 +460,7 @@ body.ssh-resizing .ssh-bottom-divider {
|
|||||||
color: var(--tab-accent-fg, #3fd06a);
|
color: var(--tab-accent-fg, #3fd06a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
|
/* ---- M6 tab-bar workspace actions (broadcast) ---- */
|
||||||
.workspace-tabbar-actions {
|
.workspace-tabbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -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 {
|
||||||
@@ -574,147 +588,6 @@ body.ssh-resizing .ssh-bottom-divider {
|
|||||||
user-select: none;
|
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) ---- */
|
/* ---- custom tab (title + close + context menu) ---- */
|
||||||
.workspace-terminal-tab {
|
.workspace-terminal-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user