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.
This commit is contained in:
Bill committed 2026-09-15 15:07:47 +08:00
1 parent 3b2fe06f71
commit 237265e6ba
5 files changed
+246 -5

No files matched your search

+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}
+69
View File
@@ -14,6 +14,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;
+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
}