diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx index b0affaa..8c1882a 100644 --- a/src/renderer/src/settings/SettingsDialog.tsx +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -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): 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: }, @@ -50,7 +97,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS @@ -61,7 +109,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS } > - +
+ +
+ {/* Drag corner: resizes the dialog until it is closed. */} + .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; diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 0e2074b..b2287af 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -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 { + 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 diff --git a/src/renderer/src/terminal/urlLinks.ts b/src/renderer/src/terminal/urlLinks.ts new file mode 100644 index 0000000..cc4db80 --- /dev/null +++ b/src/renderer/src/terminal/urlLinks.ts @@ -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 +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts index cfff41d..71b41b2 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -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 = {