Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e453e272c | ||
|
|
237265e6ba | ||
|
|
3b2fe06f71 | ||
|
|
97fc7171cc | ||
|
|
d3d029fe4f | ||
|
|
77fd241b43 |
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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"productName": "OpenTerminal",
|
"productName": "OpenTerminal",
|
||||||
"version": "1.0.9",
|
"version": "1.0.11",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -14,6 +14,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;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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'
|
||||||
@@ -14,6 +14,7 @@ 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'
|
||||||
@@ -32,10 +33,18 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -511,19 +520,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
|
|
||||||
const beginPaste = useCallback(
|
const beginPaste = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
if (
|
// Read the setting at call time: this runs from a long-lived keydown
|
||||||
!deadRef.current &&
|
// listener, so a captured value would go stale after a settings change.
|
||||||
settings.terminal.pasteRiskConfirm &&
|
const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm
|
||||||
!pasteConfirmedForSession &&
|
if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) {
|
||||||
isRiskyPaste(text)
|
|
||||||
) {
|
|
||||||
nativePasteRef.current = text
|
nativePasteRef.current = text
|
||||||
setPaste({ noPrompt: false, disableDetection: false })
|
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. */
|
/** Confirm the risky-paste dialog: apply its choices, then paste. */
|
||||||
@@ -761,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
|
||||||
@@ -873,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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in new issue
Block a user