Compare commits

...
8 Commits
Author SHA1 Message Date
Bill 70c33a5572 fix: register update IPC handlers (dead check/download buttons since 1.0.1); bump 1.0.4 2026-09-08 21:40:25 +08:00
Bill 7f2e4c1110 feat: terminal right-click context menu (copy/paste/find/select-all/clear) 2026-09-08 20:50:59 +08:00
Bill 7d3057ee5d fix: drop TS annotation in release.cjs 2026-09-08 17:12:52 +08:00
Bill 2b3fefafd0 feat: close-to-tray default + system settings entry; bump 1.0.3 2026-09-08 17:00:09 +08:00
Bill 8f686f56ca docs: updater proxy strategy + channel changelog in AGENTS.md 2026-09-08 09:45:29 +08:00
Bill c3baac3f05 fix: updater proxy strategy (Gitea direct, GitHub system proxy) + changelog via channel release-notes.md 2026-09-08 09:44:34 +08:00
Bill dc3d54fa07 chore: release script in repo + AGENTS.md dev/release docs 2026-09-08 01:15:02 +08:00
Bill 8cb193fa6a feat: theme-driven chrome, customizable tab accent, UI polish
- Sidebar/tab bars/dividers/settings dialog follow the terminal theme
- Tab accent color customizable in theme settings (color picker)
- Title bar shows the app icon; Material Dark is the default theme
- Slimmer rectangular tabs (28px), slate scrollbars, visible pane dividers
- Fix pure-black xterm viewport gaps after pane resize
2026-09-08 00:57:56 +08:00
20 changed files with 650 additions and 82 deletions

No files matched your search

+3
View File
@@ -32,3 +32,6 @@ release/
# stray local test artifacts
.min-test.bin
.min2-test.bin
# release notes draft (consumed by scripts/release.cjs)
RELEASE_NOTES.md
+43
View File
@@ -0,0 +1,43 @@
# OpenTerminal 开发备忘
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令
- 开发:`npm run dev`(主进程改动不热重建,需重启)
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
## 仓库与远程
- `origin` = Gitea `https://git.codingplan.site/admin/OpenTerminal.git`(主仓库 + 更新通道)
- `github` = `https://github.com/billowliu2/OpenTerminal.git`(镜像 + 备用更新源)
- ⚠️ Gitea 的 upload-pack 有故障,`git fetch origin` 会报 `bad pack header`;查询远端状态用 API 或 `git ls-remote`。推送正常。
- 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。
## 凭据
- `.env`(已 gitignore):`GIT_TOKEN`(Gitea)、`GH_TOKEN`(GitHub)等。git 推送通过 credential helper 使用该令牌。
## 发布新版本
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` 再走代理
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
5. `git tag v<版本号>` 并推送两个远程
## 更新机制
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
## 主题机制
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.1",
"version": "1.0.4",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+124
View File
@@ -0,0 +1,124 @@
// OpenTerminal release publisher
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea]
// node scripts/release.cjs 1.0.2
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
// (needed for api.github.com / uploads.github.com in some networks).
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.join(__dirname, '..')
const V = process.argv[2]
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea]')
process.exit(1)
}
const SKIP_GH = process.argv.includes('--skip-github')
const SKIP_GITEA = process.argv.includes('--skip-gitea')
const env = Object.fromEntries(
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
.filter((l) => l.includes('=') && !l.startsWith('#'))
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
)
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
const R = path.join(ROOT, 'release')
const files = [
path.join(R, `OpenTerminal-${V}-setup.msi`),
path.join(R, `OpenTerminal-${V}-setup.exe`)
]
for (const f of files) {
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
}
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) {
const { ProxyAgent, setGlobalDispatcher } = require('undici')
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
async function upload(url, file, token, authScheme) {
const name = path.basename(file)
const stat = fs.statSync(file)
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, {
method: 'POST',
headers: {
Authorization: `${authScheme} ${token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': String(stat.size)
},
body: fs.createReadStream(file),
duplex: 'half'
})
console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) console.log(' ', (await resp.text()).slice(0, 300))
}
async function gitea() {
console.log('=== Gitea release ===')
const base = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
const resp = await fetch(`${base}/releases`, {
method: 'POST',
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
const rel = await resp.json()
console.log('release created, id =', rel.id)
for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token')
}
async function giteaChannel() {
console.log('=== Gitea update channel ===')
const base = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable'
const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
console.log(' delete old stable:', del.status)
const channelFiles = [
[path.join(R, 'latest.yml'), 'latest.yml'],
[path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`],
[path.join(R, `OpenTerminal-${V}-setup.exe`), `OpenTerminal-${V}-setup.exe`],
// Changelog served from the channel: the Gitea repo is private (anonymous
// releases API 404s), but the generic package is publicly readable.
[path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md']
]
for (const [f, name] of channelFiles) {
const stat = fs.statSync(f)
const resp = await fetch(`${base}/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) },
body: fs.createReadStream(f),
duplex: 'half'
})
console.log(` put ${name}: HTTP ${resp.status}`)
}
}
async function github() {
console.log('=== GitHub release ===')
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
},
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
const rel = await resp.json()
console.log('release created, id =', rel.id)
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer')
}
}
async function main() {
if (!SKIP_GITEA) { await gitea(); await giteaChannel() }
if (!SKIP_GH) await github()
console.log('done')
}
main().catch((e) => { console.error(e); process.exit(1) })
+1
View File
@@ -98,6 +98,7 @@ process.on('uncaughtException', (err) => {
app.whenReady().then(() => {
registerIpc()
registerUpdateIpc()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
+2 -2
View File
@@ -34,7 +34,7 @@ function showMainWindow(showOrCreate: () => void): void {
/** Rebuild the context menu so the close-action radio items reflect settings. */
function refreshContextMenu(showOrCreate: () => void): void {
if (!tray) return
const action = loadSettings().system.closeAction ?? 'ask'
const action = loadSettings().system.closeAction ?? 'tray'
const setAction = (value: CloseAction) => (): void => {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
@@ -86,7 +86,7 @@ function hideToTray(win: BrowserWindow): void {
*/
export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise<void> {
if (quitting) return
const action = loadSettings().system.closeAction ?? 'ask'
const action = loadSettings().system.closeAction ?? 'tray'
if (action === 'tray') {
e.preventDefault()
hideToTray(win)
+40 -3
View File
@@ -1,4 +1,4 @@
import { app, ipcMain, net } from 'electron'
import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
import { broadcast } from './broadcast'
@@ -30,6 +30,8 @@ function setState(patch: Partial<UpdateState>): void {
function useFeed(feed: 'gitea' | 'github'): void {
activeFeed = feed
if (feed === 'gitea') {
// Domestic feed: always direct — a system proxy only breaks it.
void autoUpdater.netSession.setProxy({ mode: 'direct' })
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
@@ -37,6 +39,8 @@ function useFeed(feed: 'gitea' | 'github'): void {
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
} else {
// GitHub usually needs the system proxy (when one is enabled).
void autoUpdater.netSession.setProxy({ mode: 'system' })
autoUpdater.setFeedURL({ provider: 'github', ...GITHUB_REPO })
}
}
@@ -67,8 +71,14 @@ async function checkWithFallback(): Promise<void> {
} catch (err) {
if (activeFeed !== 'gitea') throw err
console.warn('[updater] gitea feed failed, falling back to github:', err)
const giteaErr = err instanceof Error ? err.message : String(err)
useFeed('github')
await autoUpdater.checkForUpdates()
try {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`)
}
}
}
@@ -96,8 +106,35 @@ async function handleDownload(): Promise<void> {
}
}
/** Gitea releases first, GitHub releases as fallback; both are public reads. */
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
async function directFetch(url: string): Promise<Response> {
const s = session.fromPartition('openterminal-update-direct', { cache: false })
await s.setProxy({ mode: 'direct' })
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
}
/**
* Changelog sources, in order:
* 1. update channel's release-notes.md — the Gitea repo itself is private
* (anonymous API reads 404), but the generic package is publicly readable.
* 2. Gitea / GitHub releases APIs (full history when reachable).
*/
async function fetchChangelog(): Promise<ReleaseNote[]> {
try {
const [notesResp, ymlResp] = await Promise.all([
directFetch(`${GITEA_FEED}release-notes.md`),
directFetch(`${GITEA_FEED}latest.yml`)
])
if (notesResp.ok) {
const body = await notesResp.text()
const yml = ymlResp.ok ? await ymlResp.text() : ''
const version = yml.match(/^version:\s*(\S+)/m)?.[1] ?? ''
const date = yml.match(/^releaseDate:\s*'?(\S+?)'?$/m)?.[1]?.slice(0, 10) ?? ''
if (body.trim()) return [{ version: version ? `v${version}` : 'latest', date, body }]
}
} catch {
// fall through to releases APIs
}
for (const url of [GITEA_RELEASES_API, GITHUB_RELEASES_API]) {
try {
const resp = await net.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
+16
View File
@@ -4,6 +4,8 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
/** Custom window title bar: draggable strip themed with the active terminal
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
@@ -15,6 +17,7 @@ function TitleBar(): React.JSX.Element {
className="app-titlebar"
style={{ background: theme.colors.background, color: theme.colors.foreground }}
>
<img className="app-titlebar-icon" src={appIconUrl} alt="" draggable={false} />
<span className="app-titlebar-title">OpenTerminal</span>
</div>
)
@@ -22,12 +25,25 @@ function TitleBar(): React.JSX.Element {
export default function App(): React.JSX.Element {
const hydrate = useSettingsStore((s) => s.hydrate)
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
// Sidebar / tab bars / dividers follow the active terminal theme.
useEffect(() => {
applyChromeTheme(getThemeById(themeId, customThemes).colors)
}, [themeId, customThemes])
// Active-tab accent color (rail indicator, tab outline, SSH badges).
useEffect(() => {
applyTabAccent(tabAccentColor)
}, [tabAccentColor])
return (
<div className="app-root">
<TitleBar />
+1 -1
View File
@@ -346,7 +346,7 @@
}
.connections-local-item.is-active {
background: rgba(63, 185, 80, 0.12);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
}
.connections-local-name {
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import type { AppApi } from '../shared/api'
declare global {
+20 -7
View File
@@ -20,16 +20,16 @@ body,
font-size: 13px;
}
/* App-wide slim dark scrollbars: the native Windows style renders light
/* App-wide slim slate scrollbars: the native Windows style renders light
tracks + arrow buttons that look broken on the dark surface. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
@@ -37,12 +37,17 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-button,
@@ -80,6 +85,14 @@ button {
letter-spacing: 0.2px;
}
.app-titlebar-icon {
width: 16px;
height: 16px;
margin-right: 7px;
border-radius: 4px;
pointer-events: none;
}
.app-titlebar-title {
opacity: 0.75;
font-weight: 500;
+30 -8
View File
@@ -1,12 +1,14 @@
import ReactDOM from 'react-dom/client'
import { useEffect } from 'react'
import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import { mix } from './theme/chrome'
import './global.css'
// Dev convenience: allow opening the renderer in a plain browser (vite page)
@@ -148,13 +150,33 @@ function FontHotkeyListener(): null {
// antd <App> provides the context that App.useApp() consumers (workspace
// message/notification) rely on; without it they get empty stubs and crash.
// ErrorBoundary keeps any render crash from black-screening the whole window.
/** antd surfaces (settings dialog, menus, popovers) follow the terminal theme:
* base/container/elevated backgrounds derive from the theme's background. */
function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX.Element {
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const colors = getThemeById(themeId, customThemes).colors
return (
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: {
colorBgBase: colors.background,
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? '#1f1f1f',
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? '#252525',
colorTextBase: colors.foreground,
borderRadius: 6
}
}}
>
{children}
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: { colorBgContainer: '#1f1f1f', colorBgElevated: '#252525', borderRadius: 6 }
}}
>
<ThemedConfigProvider>
<AntdApp>
<ErrorBoundary>
<App />
@@ -162,5 +184,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ConfigProvider>
</ThemedConfigProvider>
)
@@ -317,6 +317,22 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="关闭按钮行为"
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
control={
<Select
className="settings-select"
value={settings.system.closeAction ?? 'tray'}
onChange={(value) => void updateSystem({ closeAction: value })}
options={[
{ value: 'tray', label: '最小化到托盘' },
{ value: 'exit', label: '直接退出' },
{ value: 'ask', label: '每次询问' }
]}
/>
}
/>
<SettingRow
label="全局唤起快捷键"
desc="Control+Shift+Alt+T 等,留空禁用"
+22 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { Button, Popconfirm } from 'antd'
import { Button, ColorPicker, Popconfirm } from 'antd'
import type { TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
@@ -46,7 +46,26 @@ export function ThemeSettingsTab({
}
return (
<div className="theme-gallery">
<div className="theme-pane">
<div className="theme-accent-row">
<div>
<div className="settings-block-label">标签强调色</div>
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
</div>
<div className="theme-accent-controls">
<ColorPicker
value={settings.terminal.tabAccentColor}
onChange={(color) => void updateTerminal({ tabAccentColor: color.toHexString() })}
/>
<Button
size="small"
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
>
恢复默认
</Button>
</div>
</div>
<div className="theme-gallery">
{themeCards.map((t) => {
const selected = t.id === activeTheme.id
const isCustom = t.builtin === false
@@ -116,6 +135,7 @@ export function ThemeSettingsTab({
<div className="theme-card-new-icon">+</div>
<div className="theme-card-new-text">新建主题</div>
</div>
</div>
</div>
)
}
+29 -5
View File
@@ -5,13 +5,13 @@
/* ---------- settings dialog scaffolding ---------- */
.ant-modal-header {
border-bottom: 1px solid #2d2d2d;
border-bottom: 1px solid var(--chrome-border);
padding-bottom: 14px;
}
.ant-modal .ant-tabs-nav {
margin-bottom: 8px;
border-bottom: 1px solid #2d2d2d;
border-bottom: 1px solid var(--chrome-border);
}
.settings-footer {
@@ -116,7 +116,7 @@
margin-top: 14px;
border-radius: 6px;
padding: 12px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
overflow: hidden;
}
@@ -140,6 +140,30 @@
/* ---------- theme gallery ---------- */
/* ---- theme tab: accent picker + gallery ---- */
.theme-pane {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.theme-accent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
}
.theme-accent-controls {
display: flex;
align-items: center;
gap: 8px;
}
.theme-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -333,7 +357,7 @@
.theme-editor-preview-box {
border-radius: 6px;
padding: 10px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
}
@@ -356,7 +380,7 @@
width: 48px;
height: 48px;
border-radius: 12px;
background: #1e2531;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
+116 -7
View File
@@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
@@ -44,6 +44,13 @@ const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNe
activeMatchColorOverviewRuler: '#c77800'
}
// xterm's default overviewRulerBorder (#7f7f7f) renders as a light vertical line
// on the dark surface once scrollback exists. Force it transparent; search-match
// marks in the ruler keep their own colors.
function withChromeColors(colors: ITheme): ITheme {
return { ...colors, overviewRulerBorder: '#00000000' }
}
export interface TerminalHandle {
focus(): void
clear(): void
@@ -273,6 +280,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
// Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null)
const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0)
@@ -332,6 +343,40 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
})
}, [sessionId])
const openSearch = useCallback(() => {
searchOpenRef.current = true
setSearchOpen(true)
}, [])
const openContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setMenuHasSel(Boolean(termRef.current?.getSelection()))
// Clamp so the menu never overflows the window edge.
const x = Math.min(e.clientX, window.innerWidth - 216)
const y = Math.min(e.clientY, window.innerHeight - 200)
setMenuPos({ x: Math.max(x, 4), y: Math.max(y, 4) })
}, [])
// Global dismiss: Escape / scroll / window blur close the context menu.
useEffect(() => {
if (!menuPos) return
const close = (): void => setMenuPos(null)
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
window.addEventListener('keydown', onKey, true)
window.addEventListener('wheel', close, true)
window.addEventListener('blur', close)
return () => {
window.removeEventListener('keydown', onKey, true)
window.removeEventListener('wheel', close, true)
window.removeEventListener('blur', close)
}
}, [menuPos])
const closeSearch = useCallback(() => {
searchOpenRef.current = false
searchRef.current?.clearDecorations()
@@ -463,8 +508,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) {
searchOpenRef.current = true
setSearchOpen(true)
openSearch()
return false
}
if (isModShiftChord(e, 'c')) {
@@ -514,7 +558,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
return true
},
[closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
[closeSearch, openSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
)
// ---- terminal instance: built once per session ----
@@ -530,7 +574,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
cursorBlink: tSettings.cursorBlink,
cursorStyle: tSettings.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle,
theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme),
allowProposedApi: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
drawBoldTextInBrightColors: true,
@@ -737,7 +781,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.options.cursorBlink = tSettings.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) }
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme)
scheduleFit()
}, [tSettings, settings.customThemes, scheduleFit])
@@ -827,8 +871,73 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
searchTermRef.current = value
}, [])
const runMenuAction = useCallback(
(action: () => void) => {
setMenuPos(null)
action()
termRef.current?.focus()
},
[]
)
return (
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()}>
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()} onContextMenu={openContextMenu}>
{menuPos && (
<>
<div
className="term-menu-overlay"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
termRef.current?.focus()
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
}}
/>
<div className="term-menu" style={{ left: menuPos.x, top: menuPos.y }}>
<button
type="button"
className="term-menu-item"
disabled={!menuHasSel}
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">复制</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
type="button"
className="term-menu-item"
disabled={dead}
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">粘贴</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">查找</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">全选</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">清屏</span>
<kbd />
</button>
</div>
</>
)}
{searchOpen && (
<SearchBar
value={searchTerm}
+88 -7
View File
@@ -13,15 +13,24 @@
height: 100%;
}
/* Dark slim scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. */
/* xterm.css defaults .xterm-viewport to #000; when the pane height isn't a
multiple of the cell height the leftover bottom strip shows that black.
Pin it to the (theme-derived) chrome background so the gap is invisible. */
.terminal-view .xterm .xterm-viewport {
background-color: var(--chrome-bg);
}
/* Slim slate scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. The thumb
sits inset inside a wider grab lane (transparent border + padding-box clip)
so it reads as a soft slate bar, never a white line. */
.terminal-view .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
.terminal-view .xterm-viewport::-webkit-scrollbar {
width: 10px;
width: 12px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-track {
@@ -29,12 +38,17 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-button,
@@ -104,6 +118,73 @@
white-space: nowrap;
}
/* ---- right-click context menu ---- */
.term-menu-overlay {
position: fixed;
inset: 0;
z-index: 40;
}
.term-menu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 4px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
background: var(--chrome-bg-deep);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
}
.term-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--chrome-fg);
font: inherit;
cursor: pointer;
text-align: left;
}
.term-menu-item:hover:not(:disabled),
.term-menu-item:focus-visible {
background: var(--chrome-hover);
color: var(--chrome-accent);
}
.term-menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.term-menu-label {
flex: 1;
text-align: left;
}
.term-menu-item > kbd {
border: none;
background: transparent;
color: var(--chrome-fg);
opacity: 0.55;
font: inherit;
font-size: 11px;
}
.term-menu-sep {
height: 1px;
margin: 4px 8px;
background: var(--chrome-border);
}
/* ---- session log record / open-dir buttons (floating, top-left) ---- */
.term-recbar {
position: absolute;
+51
View File
@@ -0,0 +1,51 @@
import type { ThemeColors } from '@shared/theme'
function parseHex(color: string): [number, number, number] | null {
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
/** t=0 → a, t=1 → b */
export function mix(a: string, b: string, t: number): string | null {
const ca = parseHex(a)
const cb = parseHex(b)
if (!ca || !cb) return 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)}`
}
/** 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
* can't provide keeps the static fallback in global.css. */
export function applyChromeTheme(colors: ThemeColors): void {
const style = document.documentElement.style
const set = (key: string, value: string | null): void => {
if (value) style.setProperty(key, value)
}
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))
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. 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
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
if (!color) {
style.removeProperty('--tab-accent')
style.removeProperty('--tab-accent-fg')
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
}
+39 -36
View File
@@ -55,8 +55,8 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
box-shadow: inset 2px 0 0 #3fb950;
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
}
.workspace-rail-spacer {
@@ -173,19 +173,22 @@
/* Align terminal chrome with the app chrome. */
.dockview-theme-abyss {
--dv-group-view-background-color: var(--chrome-bg);
--dv-tabs-and-actions-container-background-color: #10141a;
--dv-tabs-and-actions-container-height: 32px;
--dv-tabs-and-actions-container-background-color: var(--chrome-bg-deep, #10141a);
--dv-tabs-and-actions-container-height: 28px;
--dv-tab-divider-color: transparent;
/* 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 polish: rounded pills, quiet inactive, green active ---- */
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
.workspace-dockview .dv-tabs-and-actions-container {
padding: 4px 5px 0;
box-sizing: border-box;
}
.workspace-dockview .dv-tab {
border-radius: 9px;
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
@@ -202,14 +205,14 @@
/* XTerminal-style active tab: green outline + tinted bg + green title. */
.workspace-dockview .dv-tab.dv-active-tab {
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.75);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 75%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
.workspace-dockview .dv-tab.dv-active-tab:hover {
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.95);
color: #4ad476;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 95%, transparent);
color: var(--tab-accent-fg, #4ad476);
}
.workspace-dockview .dv-tab .workspace-terminal-tab {
@@ -217,15 +220,15 @@
}
.workspace-dockview .dv-tab .workspace-terminal-tab-title {
font-size: 12.5px;
font-size: 12px;
line-height: 1;
}
/* Close glyph as its own mini outlined pill, mirroring the tab border. */
.workspace-dockview .dv-tab .workspace-terminal-tab-close {
width: 17px;
height: 17px;
border-radius: 4px;
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -233,9 +236,9 @@
.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover {
opacity: 1;
color: #3fd06a;
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
}
/* ---- empty state overlay ---- */
@@ -377,11 +380,11 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-terminal-tab-icon {
flex: 0 0 auto;
font-size: 12px;
color: #3fb950;
color: var(--tab-accent, #3fb950);
}
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950;
border-left: 2px solid var(--tab-accent, #3fb950);
padding-left: 5px !important;
gap: 5px;
}
@@ -397,9 +400,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 1px 6px 2px;
box-sizing: border-box;
border-radius: 9px;
border: 1px solid rgba(63, 185, 80, 0.38);
background: rgba(63, 185, 80, 0.08);
color: #3fb950;
border: 1px solid color-mix(in srgb, var(--tab-accent, #3fb950) 38%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
color: var(--tab-accent, #3fb950);
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
font-size: 10.5px;
line-height: 1;
@@ -407,9 +410,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge {
border-color: rgba(63, 185, 80, 0.6);
background: rgba(63, 185, 80, 0.14);
color: #46c96a;
border-color: color-mix(in srgb, var(--tab-accent, #3fb950) 60%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 14%, transparent);
color: var(--tab-accent-fg, #46c96a);
}
/* ---- per-group "+" right after the last tab ----
@@ -438,9 +441,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-add:hover {
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
@@ -476,15 +479,15 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-btn2.is-active {
color: #3fd06a;
background: rgba(63, 185, 80, 0.12);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.55);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 55%, transparent);
}
.workspace-tabbar-bcast-count {
font-size: 10px;
font-weight: 600;
color: #3fb950;
color: var(--tab-accent, #3fb950);
line-height: 1;
}
@@ -565,9 +568,9 @@ body.ssh-resizing .ssh-bottom-divider {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35);
color: var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 35%, transparent);
user-select: none;
}
+6 -3
View File
@@ -36,6 +36,8 @@ export interface TerminalSettings {
pasteRiskConfirm: boolean
/** inline command suggestion popup while typing (history + library) */
suggestEnabled: boolean
/** accent color for the active tab outline, rail indicator and SSH badges */
tabAccentColor: string
}
export interface SystemSettings {
@@ -95,14 +97,15 @@ export const DEFAULT_SETTINGS: AppSettings = {
cursorBlink: true,
cursorStyle: 'block',
cursorInactiveStyle: 'outline',
themeId: 'default-dark',
themeId: 'material-dark',
rendererMode: 'auto',
autoWrap: true,
copyOnSelect: false,
pasteRiskConfirm: true,
suggestEnabled: true
suggestEnabled: true,
tabAccentColor: '#3fb950'
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'ask', autoCheckUpdate: true }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true }
}