Compare commits

..
5 Commits
Author SHA1 Message Date
Bill c1744184a3 chore: bump version to 1.0.12 2026-09-17 09:26:20 +08:00
Bill b3744b4705 fix: luminance-aware chrome theming for light themes; serialized settings writes; buffered log flush
- Derive tab-bar/chrome palette by background luminance: light themes keep
  a near-background bar with black-tinted tab overlays instead of a muddy
  gray strip; dark themes unchanged
- Theme dockview tabs via the group-scoped --dv-*-tab-* vars its own rules
  consume (they outspecify our .dv-tab rules and leaked abyss navy onto
  light tabs); bump inactive-tab hover specificity to match
- Convert settings dialog + highlight editor hardcoded white text/border
  tints to color-mix over --chrome-fg so panes stay readable on light themes
- commands.ts: per-file log write buffer with a single drain loop per file
  (burst output coalesces into one appendFile per IO tick, chain no longer
  grows per logWrite); stop-tail rides the same buffer
- settingsStore: serialize all writers through mutateSettings() queue that
  re-reads latest state per mutation (tray close-action vs settings UI full
  saves no longer clobber each other)
- tests: burst ordering + stop-tail case for the log buffer
2026-09-17 09:11:05 +08:00
Bill 9e453e272c chore: bump version to 1.0.11 2026-09-15 15:08:13 +08:00
Bill 237265e6ba feat: clickable URLs, left-rail settings nav, proportional resizable dialog
URLs in terminal output (http/https/ftp with ports, www. hosts, bare
localhost:port dev-server forms) are detected via a link provider: hover
underlines and shows the pointer, Ctrl/Cmd+Click opens the system browser.
Wrapped URLs spanning buffer rows resolve as one link. The 网址链接 highlight
preset grows to cover ftp:// and www. forms.

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

No files matched your search

+5 -5
View File
@@ -25,12 +25,12 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
2. `npm run dist` 构建
3. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.2`
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release
- 可用 `--skip-github` / `--skip-gitea` 跳过某步
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理
3. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.11 --skip-github`
- **只发国内**(2026-09-15 起的产品决定):Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
5. `git tag v<版本号>` 并推送两个远程
5. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
## 更新机制
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "0.8.0",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "0.8.0",
"version": "1.0.12",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.10",
"version": "1.0.12",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+37 -21
View File
@@ -73,11 +73,15 @@ export class CommandsStore {
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
/**
* Pending append per log file. appendFile is async, so two rapid logWrite
* calls would otherwise race and land out of order. Chaining each new append
* onto the previous one keeps the file append-only in call order.
* Buffered plain text per log file, flushed by a single drain loop per file.
* Writes land in the buffer synchronously (so order is preserved) and the
* drain merges everything that accumulated during the previous appendFile
* round trip into one append call — burst output costs one syscall per IO
* tick instead of one Promise chain link per logWrite.
*/
private readonly pendingAppends = new Map<string, Promise<void>>()
private readonly logBuffers = new Map<string, string>()
/** In-flight drain loop per log file; absent when the file is settled. */
private readonly logDrains = new Map<string, Promise<void>>()
/** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>()
@@ -287,19 +291,35 @@ export class CommandsStore {
// Raw PTY output is escape-sequence soup in a text file; log plain text.
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
if (!clean) return
// Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
() => undefined,
() => {
// file may have been removed after stop -> ignore
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
this.drainLog(meta.file)
}
/**
* Flush a log file's buffer, keeping at most one drain (and therefore one
* in-flight appendFile) per file. New writes that arrive mid-flush re-enter
* the buffer and are picked up by the next loop iteration, merged.
*/
private drainLog(file: string): Promise<void> {
const existing = this.logDrains.get(file)
if (existing) return existing
const run = (async (): Promise<void> => {
for (;;) {
const chunk = this.logBuffers.get(file)
if (chunk === undefined) break
this.logBuffers.delete(file)
try {
await appendFile(file, chunk, 'utf8')
} catch {
// file may have been removed after stop -> ignore
}
}
)
this.pendingAppends.set(meta.file, next)
// Avoid unbounded growth of the chain map once the file settles.
next.finally(() => {
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
})()
run.finally(() => {
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
})
this.logDrains.set(file, run)
return run
}
/** Stop logging a session, stamping endedAt into the registry + index. */
@@ -310,12 +330,8 @@ export class CommandsStore {
this.sanitizers.delete(sessionId)
if (tail) {
// Best effort: the trailing partial line belongs in the file too.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
this.drainLog(meta.file)
}
this.finishLog(meta)
}
+32 -17
View File
@@ -159,25 +159,40 @@ export function loadSettings(): AppSettings {
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules,
system: next.system ?? DEFAULT_SYSTEM
}).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
let settingsQueue: Promise<unknown> = Promise.resolve()
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
renameSync(tmp, path)
/**
* Serialize a settings mutation against every other writer. Each queued step
* re-reads the file so the mutation applies on top of the latest state — a
* full-snapshot save can no longer silently revert a change written between
* its read and its write (e.g. tray close-action vs settings UI save).
*/
export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): Promise<AppSettings> {
const run = settingsQueue.then((): AppSettings => {
const merged = deepMerge(mutate(loadSettings())).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
mkdirSync(app.getPath('userData'), { recursive: true })
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 {
+7 -4
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import { loadSettings, mutateSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
@@ -15,9 +15,12 @@ export function markQuitting(): void {
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
// Serialized read-modify-write on the latest state: must not stomp (or be
// stomped by) a concurrent full-snapshot save from the settings UI.
void mutateSettings((current) => {
current.system.closeAction = action
return current
})
}
function showMainWindow(showOrCreate: () => void): void {
+8
View File
@@ -5,6 +5,14 @@
--chrome-border: #2d2d2d;
--chrome-accent: #3b8eea;
--chrome-hover: #2a2a2a;
--chrome-bg-deep: #101418;
/* Tab-bar overlays; overwritten per theme by applyChromeTheme(). */
--chrome-tab-bg: rgba(255, 255, 255, 0.04);
--chrome-tab-bg-hover: rgba(255, 255, 255, 0.07);
--chrome-tab-ring: rgba(255, 255, 255, 0.1);
--chrome-tab-ring-hover: rgba(255, 255, 255, 0.2);
--chrome-tab-fg: #98a2ab;
--chrome-tab-fg-hover: #d0d0d0;
}
html,
+60 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button, Modal, Tabs } from 'antd'
import type { TabsProps } from 'antd'
import { DEFAULT_SETTINGS } from '@shared/settings'
@@ -20,6 +20,53 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({
open: false
})
// Dialog size: proportional to the main window by default, user-draggable
// afterwards. `null` means "use the proportional default". While the dialog
// is open, a main-window resize re-derives that default in real time.
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
const [winSize, setWinSize] = useState({ w: window.innerWidth, h: window.innerHeight })
useEffect(() => {
if (open) setSize(null)
}, [open])
useEffect(() => {
if (!open) return
const onResize = (): void => setWinSize({ w: window.innerWidth, h: window.innerHeight })
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [open])
const clampToWindow = (w: number, h: number): { w: number; h: number } => ({
w: Math.max(720, Math.min(w, winSize.w - 96)),
h: Math.max(440, Math.min(h, winSize.h - 140))
})
const dialogSize = size
? clampToWindow(size.w, size.h)
: clampToWindow(Math.round(winSize.w * 0.7), Math.round(winSize.h * 0.7))
// The modal chrome (header + footer + paddings) is ~132px; the body takes the rest.
const bodyHeight = Math.max(360, dialogSize.h - 132)
const startResize = (e: React.MouseEvent<HTMLSpanElement>): void => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const base = dialogSize
const onMove = (ev: MouseEvent): void => {
const w = Math.max(720, Math.min(window.innerWidth - 96, base.w + (ev.clientX - startX)))
const h = Math.max(440, Math.min(window.innerHeight - 140, base.h + (ev.clientY - startY)))
setSize({ w, h })
}
const onUp = (): void => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'nwse-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const tabItems: TabsProps['items'] = [
{ key: 'font', label: '字体', children: <FontSettingsTab /> },
@@ -50,7 +97,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
<Modal
open={open}
onCancel={onClose}
width={760}
centered
width={dialogSize.w}
title="设置"
footer={
<div className="settings-footer">
@@ -61,7 +109,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
</div>
}
>
<Tabs defaultActiveKey="font" items={tabItems} />
<div className="settings-body" style={{ height: bodyHeight }}>
<Tabs tabPosition="left" defaultActiveKey="font" items={tabItems} />
</div>
{/* Drag corner: resizes the dialog until it is closed. */}
<span
className="settings-resize"
title="拖拽调整大小"
aria-label="拖拽调整大小"
onMouseDown={startResize}
/>
<ThemeEditor
open={editorState.open}
onClose={closeEditor}
+17 -14
View File
@@ -1,5 +1,8 @@
/* ============================================================
Highlight settings tab + rule editor (settings/highlight.css)
Text/border tints derive from --chrome-fg via color-mix so the
panel follows the active terminal theme (white tints on dark
themes, black tints on light themes).
============================================================ */
.hl-tab {
@@ -21,15 +24,15 @@
}
.hl-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.04);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
border-bottom: 1px solid var(--chrome-border);
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 85%, transparent);
font-size: 12px;
}
.hl-table .ant-table-tbody > tr > td {
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-bottom: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
}
.hl-table .ant-table-tbody > tr:hover > td {
@@ -48,7 +51,7 @@
.hl-pattern-text {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -71,17 +74,17 @@
.hl-cell-priority {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
}
.hl-cell-note {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
}
.hl-cell-empty {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
}
.hl-preview-text {
@@ -114,7 +117,7 @@
.hl-editor-label {
flex: 0 0 56px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
padding-top: 5px;
}
@@ -129,7 +132,7 @@
.hl-editor-hint {
align-self: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.hl-editor-error {
@@ -149,7 +152,7 @@
.hl-editor-sub-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
}
.hl-editor-inline {
@@ -170,7 +173,7 @@
width: 40px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
border-radius: 4px;
background: transparent;
cursor: pointer;
@@ -197,7 +200,7 @@
.hl-palette-tag {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-right: 2px;
}
@@ -205,7 +208,7 @@
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
cursor: pointer;
display: inline-block;
transition: transform 0.12s ease, box-shadow 0.12s ease;
@@ -218,4 +221,4 @@
.hl-swatch-selected {
box-shadow: 0 0 0 2px var(--chrome-accent) inset;
border-color: var(--chrome-accent);
}
}
+91 -19
View File
@@ -1,5 +1,8 @@
/* ============================================================
Settings dialog + theme editor styles (settings/**, theme/**)
Text/border tints derive from --chrome-fg via color-mix so the
dialog follows the active terminal theme (white tints on dark
themes, black tints on light themes).
============================================================ */
/* ---------- settings dialog scaffolding ---------- */
@@ -14,6 +17,75 @@
border-bottom: 1px solid var(--chrome-border);
}
/* ---------- left-rail navigation (tabPosition="left") ----------
The tab list doubles as the dialog's sidebar: fixed narrow column, roomy
click targets, active item tinted with the tab accent. The content column
scrolls on its own so long panes (字体/高亮) never grow the dialog. */
.settings-body .ant-tabs-left > .ant-tabs-nav {
margin: 0;
border-bottom: none;
border-right: 1px solid var(--chrome-border);
padding-right: 6px;
min-width: 148px;
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab {
margin: 2px 0;
padding: 8px 14px;
border-radius: 6px;
justify-content: flex-start;
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab:hover {
background: var(--chrome-hover);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab-active {
background: color-mix(in srgb, var(--tab-accent, #3fb950) 16%, transparent);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
background: var(--tab-accent, #3fb950);
}
.settings-body {
display: flex;
overflow: hidden;
}
.settings-body > .ant-tabs {
flex: 1;
min-height: 0;
}
.settings-body .ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
}
/* Corner grip for resizing the dialog (wired in SettingsDialog). */
.ant-modal .ant-modal-body {
position: relative;
}
.settings-resize {
position: absolute;
right: 3px;
bottom: 3px;
width: 16px;
height: 16px;
cursor: nwse-resize;
border-radius: 3px;
background:
linear-gradient(135deg, transparent 52%, var(--chrome-border) 52%, var(--chrome-border) 58%, transparent 58%),
linear-gradient(135deg, transparent 64%, var(--chrome-border) 64%, var(--chrome-border) 70%, transparent 70%),
linear-gradient(135deg, transparent 76%, var(--chrome-border) 76%, var(--chrome-border) 82%, transparent 82%);
}
.settings-resize:hover {
filter: brightness(1.6);
}
.settings-footer {
display: flex;
align-items: center;
@@ -37,7 +109,7 @@
}
.settings-row:hover {
background: rgba(255, 255, 255, 0.03);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 3%, transparent);
}
.settings-row + .settings-row {
@@ -46,7 +118,7 @@
.settings-row-label {
flex: 0 0 46%;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
font-size: 13px;
line-height: 1.4;
}
@@ -56,7 +128,7 @@
margin-top: 2px;
font-size: 12px;
font-weight: 400;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.settings-row-control {
@@ -87,7 +159,7 @@
.settings-block-hint {
font-weight: 400;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
}
.settings-radio-group {
@@ -123,7 +195,7 @@
.settings-preview-title {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 8px;
}
@@ -178,14 +250,14 @@
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.theme-card:hover {
border-color: var(--chrome-accent);
background: rgba(255, 255, 255, 0.07);
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 7%, transparent);
}
.theme-card-selected {
@@ -211,7 +283,7 @@
.theme-card-name {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -224,7 +296,7 @@
padding: 2px 5px;
border-radius: 4px;
background: rgba(59, 142, 234, 0.2);
color: #6fb3ff;
color: color-mix(in srgb, var(--chrome-accent, #3b8eea) 55%, var(--chrome-fg, #cccccc));
}
.theme-card-dots {
@@ -237,7 +309,7 @@
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.25);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 25%, transparent);
display: inline-block;
}
@@ -267,12 +339,12 @@
.theme-card-new-icon {
font-size: 26px;
line-height: 1;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
}
.theme-card-new-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
}
/* ---------- theme editor ---------- */
@@ -290,7 +362,7 @@
.theme-editor-label {
flex: 0 0 56px;
color: rgba(255, 255, 255, 0.88);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
}
.theme-editor-colors {
@@ -310,7 +382,7 @@
.theme-editor-color-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
@@ -322,7 +394,7 @@
width: 40px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
border-radius: 4px;
background: transparent;
cursor: pointer;
@@ -339,7 +411,7 @@
.theme-editor-divider {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 10%, transparent);
margin: 2px 0;
}
@@ -350,7 +422,7 @@
.theme-editor-preview-label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
margin-bottom: 6px;
}
@@ -426,7 +498,7 @@
}
.about-changelog {
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 8%, transparent);
padding-top: 12px;
}
+49 -1
View File
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ForwardRefExoticComponent, Ref } from 'react'
import { Terminal } from '@xterm/xterm'
import type { ITheme } from '@xterm/xterm'
import type { ILink, ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
@@ -14,6 +14,7 @@ import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { findUrls } from './urlLinks'
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
@@ -767,8 +768,55 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
if (out) term.write(out)
scheduleCarryFlush()
}
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
// the pointer cursor + underline advertise it on hover. A plain click stays
// with the terminal (cursor placement / selection), so a link can never
// open by accident while selecting text.
const linkProvider = term.registerLinkProvider({
provideLinks: (bufferLineNumber, callback) => {
const buffer = term.buffer.active
// provideLinks speaks 1-based line numbers, IBuffer.getLine 0-based.
let first = bufferLineNumber - 1
if (first < 0) {
callback(undefined)
return
}
while (first > 0 && buffer.getLine(first)?.isWrapped) first--
const rows: { index: number; text: string }[] = []
for (let row = first; row < buffer.length; row++) {
const line = buffer.getLine(row)
if (!line || (row > first && !line.isWrapped)) break
rows.push({ index: row, text: line.translateToString(false) })
}
const text = rows.map((r) => r.text).join('')
const cols = term.cols > 0 ? term.cols : 1
const base = rows[0]?.index ?? first
const links: ILink[] = findUrls(text).map((found) => {
const startRow = Math.floor(found.start / cols)
const endRow = Math.floor((found.end - 1) / cols)
return {
range: {
start: { x: (found.start % cols) + 1, y: base + startRow + 1 },
end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 }
},
text: found.url,
decorations: { pointerCursor: true, underline: true },
activate: (event: MouseEvent): void => {
const mod = navigator.platform.toLowerCase().includes('mac')
? event.metaKey
: event.ctrlKey
if (!mod) return
// The main process routes window.open through shell.openExternal.
window.open(found.url, '_blank', 'noopener')
}
}
})
callback(links.length > 0 ? links : undefined)
}
})
const disposables: { dispose(): void }[] = [
...oscDisposables,
linkProvider,
term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
+67
View File
@@ -0,0 +1,67 @@
/**
* Clickable URLs in terminal output.
*
* xterm has no link detection of its own for plain text (OSC 8 hyperlinks are
* a separate, opt-in protocol the CLI must emit), so a link provider scans the
* buffer line and hands back ranges. Two shapes are recognised:
* 1. fully qualified `http(s)://host[:port][/path][?query]`;
* 2. the scheme-less dev-server forms `localhost:5000`, `127.0.0.1:8080`,
* `0.0.0.0:3000` — opened as http.
*
* Because PTY output wraps long URLs across buffer rows, the provider rebuilds
* the whole *logical* line (walking back through `isWrapped` rows and forward
* to the last wrapped one) before matching, so a link split by the terminal
* width still resolves as one range.
*/
/** Characters that end a URL: whitespace, quotes, brackets, CJK punctuation. */
const URL_BODY = `[^\\s"'\`<>()\\[\\]{},。;:、]+`
const SCHEME_URL = new RegExp(`\\bhttps?://${URL_BODY}`, 'gi')
const FTP_URL = new RegExp(`\\bftp://${URL_BODY}`, 'gi')
const WWW_HOST = new RegExp(`\\bwww\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d{1,5})?(?:/${URL_BODY})?`, 'gi')
const BARE_HOST = new RegExp(`\\b(?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(?::\\d{1,5})(?:/${URL_BODY})?`, 'gi')
/** Trailing punctuation that belongs to the sentence, not the URL. */
const TRAILING = /[.,;:!?)\]}>"',。;:、]+$/
export interface FoundLink {
/** absolute offset of the first character, within the logical line */
start: number
/** absolute offset one past the last character */
end: number
/** the clickable target, scheme guaranteed */
url: string
}
/** Match every URL in one logical line of text. */
export function findUrls(text: string): FoundLink[] {
const out: FoundLink[] = []
const scan = (re: RegExp, withScheme: boolean): void => {
re.lastIndex = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const trimmed = m[0].replace(TRAILING, '')
if (!trimmed) continue
out.push({
start: m.index,
end: m.index + trimmed.length,
url: withScheme ? trimmed : `http://${trimmed}`
})
}
}
scan(SCHEME_URL, true)
scan(FTP_URL, true)
// `www.…` (and the bare host forms) get an http:// target when opened.
scan(WWW_HOST, false)
scan(BARE_HOST, false)
// A bare host inside a scheme URL (`http://localhost:5000`) matches both
// patterns; keep the longer, scheme-carrying one.
out.sort((a, b) => a.start - b.start || b.end - a.end)
const kept: FoundLink[] = []
for (const link of out) {
if (kept.some((k) => link.start < k.end && link.end > k.start)) continue
kept.push(link)
}
return kept
}
+45 -6
View File
@@ -7,6 +7,17 @@ function parseHex(color: string): [number, number, number] | null {
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */
function luminance(color: string): number | null {
const c = parseHex(color)
if (!c) return null
const lin = c.map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
@@ -19,6 +30,10 @@ export function mix(a: string, b: string, t: number): string | null {
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
}
/** Whether the active terminal theme has a light background. Tab bars derived
* from it flip their overlay direction (white-on-dark vs black-on-light). */
let chromeIsLight = false
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
* terminal theme so the whole window follows theme switches instead of only
* the terminal canvas. Values land on :root CSS variables; anything the theme
@@ -30,13 +45,36 @@ export function applyChromeTheme(colors: ThemeColors): void {
}
set('--chrome-bg', colors.background)
set('--chrome-fg', colors.foreground)
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))
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
if (chromeIsLight) {
// Light chrome: keep the bar close to the background (a big mix toward
// black turns pale themes into a muddy gray strip) and overlay tabs with
// translucent black instead of the translucent white used on dark themes.
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.08))
set('--chrome-hover', mix(colors.background, '#000000', 0.06))
set('--chrome-border', mix(colors.background, '#000000', 0.2))
set('--chrome-tab-bg', 'rgba(0, 0, 0, 0.045)')
set('--chrome-tab-bg-hover', 'rgba(0, 0, 0, 0.09)')
set('--chrome-tab-ring', 'rgba(0, 0, 0, 0.18)')
set('--chrome-tab-ring-hover', 'rgba(0, 0, 0, 0.32)')
set('--chrome-tab-fg', mix(colors.background, '#000000', 0.68))
set('--chrome-tab-fg-hover', mix(colors.background, '#000000', 0.88))
} else {
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
set('--chrome-tab-bg', 'rgba(255, 255, 255, 0.04)')
set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)')
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
set('--chrome-tab-fg', '#98a2ab')
set('--chrome-tab-fg-hover', '#d0d0d0')
}
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
/** Tab accent: base color + a foreground variant that stays readable on the
* bar (lightened on dark chrome, darkened on light chrome). Consumed via
* var(--tab-accent*) in workspace.css. */
export function applyTabAccent(color: string): void {
const style = document.documentElement.style
// Settings hydrated from an older main process may lack the key; clear the
@@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void {
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
const fg = chromeIsLight ? mix(color, '#000000', 0.35) : mix(color, '#ffffff', 0.18)
style.setProperty('--tab-accent-fg', fg ?? color)
}
+32 -18
View File
@@ -35,14 +35,14 @@
border: none;
border-radius: 5px;
background: transparent;
color: #9a9a9a;
color: var(--chrome-tab-fg, #9a9a9a);
font-size: 15px;
cursor: pointer;
}
.workspace-rail-btn:hover {
background: var(--chrome-hover);
color: #ffffff;
color: var(--chrome-fg, #ffffff);
}
.workspace-rail-divider {
@@ -54,7 +54,7 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
color: var(--tab-accent-fg, #e8e8e8);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
}
@@ -179,6 +179,18 @@
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
/* Tab surfaces. Dockview paints tabs through group-scoped rules that
outspecify our .dv-tab rules, so theme the tabs via the vars those rules
consume — otherwise the abyss defaults (dark navy) leak onto light themes. */
--dv-activegroup-visiblepanel-tab-background-color: color-mix(
in srgb, var(--tab-accent, #3fb950) 10%, transparent);
--dv-activegroup-visiblepanel-tab-color: var(--tab-accent-fg, #3fd06a);
--dv-activegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-activegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
--dv-inactivegroup-visiblepanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-inactivegroup-visiblepanel-tab-color: var(--chrome-tab-fg, #98a2ab);
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
--dv-inactivegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
}
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
@@ -191,16 +203,18 @@
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
color: #98a2ab;
background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1));
color: var(--chrome-tab-fg, #98a2ab);
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
color: #d0d0d0;
/* Group-scoped chain matches dockview's own specificity so the hover tint
wins over its group-level background rules. */
.workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover {
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.2));
color: var(--chrome-tab-fg-hover, #d0d0d0);
}
/* XTerminal-style active tab: green outline + tinted bg + green title. */
@@ -229,7 +243,7 @@
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.16));
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
@@ -432,9 +446,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
background: transparent;
color: #98a2ab;
color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -464,18 +478,18 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
background: transparent;
color: #98a2ab;
color: var(--chrome-tab-fg, #98a2ab);
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-btn2:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
color: #ffffff;
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24));
color: var(--chrome-tab-fg-hover, #ffffff);
}
.workspace-tabbar-btn2.is-active {
+1 -1
View File
@@ -97,7 +97,7 @@ export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'),
RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'),
RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'),
RULE('url', 'https?://[^\\s]+', 30, '#58a6ff', '网址链接')
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接')
]
export const DEFAULT_SETTINGS: AppSettings = {
+18
View File
@@ -198,5 +198,23 @@ ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log final
store.openLogsDir()
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
// ---- 6. Burst ordering + stop tail (per-file buffer + single drain) -------------
const sid2 = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const startB = store.logStart(sid2)
// Burst 1: 300 writes in one tick must coalesce into ordered appends.
for (let i = 0; i < 300; i++) store.logWrite(sid2, `a${String(i).padStart(3, '0')}\n`)
await wait(10)
// Burst 2 after a gap: may land while burst 1's drain is still in flight.
for (let i = 0; i < 100; i++) store.logWrite(sid2, `b${String(i).padStart(3, '0')}\n`)
// Trailing partial line must ride the same buffer via logStop.
store.logWrite(sid2, 'partial-tail')
store.logStop(sid2)
await wait(200)
const expected =
Array.from({ length: 300 }, (_, i) => `a${String(i).padStart(3, '0')}\n`).join('') +
Array.from({ length: 100 }, (_, i) => `b${String(i).padStart(3, '0')}\n`).join('') +
'partial-tail'
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
console.log('\n[commands] ALL CHECKS PASSED')
process.exit(0)