feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and inline image protocol. All existing functionality preserved. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
40
web/package-lock.json
generated
40
web/package-lock.json
generated
@@ -7,7 +7,11 @@
|
||||
"name": "muyue-web",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
@@ -406,12 +410,48 @@
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-image": {
|
||||
"version": "0.10.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz",
|
||||
"integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.17.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz",
|
||||
"integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.10.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz",
|
||||
"integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.202",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz",
|
||||
"integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { ImageAddon } from '@xterm/addon-image'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
@@ -208,8 +212,25 @@ function createTerminal(container, settings = {}) {
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
const unicode11Addon = new Unicode11Addon()
|
||||
const imageAddon = new ImageAddon()
|
||||
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(webLinksAddon)
|
||||
term.loadAddon(searchAddon)
|
||||
term.loadAddon(unicode11Addon)
|
||||
term.loadAddon(imageAddon)
|
||||
|
||||
term.unicode.activeVersion = '11'
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon()
|
||||
webglAddon.onContextLoss(() => { webglAddon.dispose() })
|
||||
term.loadAddon(webglAddon)
|
||||
} catch (e) {
|
||||
console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e)
|
||||
}
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true
|
||||
@@ -239,7 +260,7 @@ function createTerminal(container, settings = {}) {
|
||||
term.open(container)
|
||||
fitAddon.fit()
|
||||
|
||||
return { term, fitAddon }
|
||||
return { term, fitAddon, searchAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
||||
@@ -361,6 +382,11 @@ export default function Shell({ api }) {
|
||||
theme: 'system',
|
||||
})
|
||||
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const searchInputRef = useRef(null)
|
||||
const searchDecorationsRef = useRef(null)
|
||||
|
||||
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||
|
||||
const [sshForm, setSshForm] = useState({
|
||||
@@ -436,7 +462,7 @@ export default function Shell({ api }) {
|
||||
if (!container) return
|
||||
|
||||
const s = settingsRef.current
|
||||
const { term, fitAddon } = createTerminal(container, {
|
||||
const { term, fitAddon, searchAddon } = createTerminal(container, {
|
||||
fontSize: s.fontSize,
|
||||
fontFamily: s.fontFamily,
|
||||
theme: s.theme,
|
||||
@@ -528,7 +554,7 @@ export default function Shell({ api }) {
|
||||
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||
|
||||
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||
tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
||||
|
||||
@@ -688,6 +714,15 @@ export default function Shell({ api }) {
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
if (ctrl && e.shiftKey && e.key === 'F') {
|
||||
const shellTab = document.querySelector('.shell-layout')
|
||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||
e.preventDefault()
|
||||
setShowSearch(prev => !prev)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||
|
||||
@@ -711,6 +746,49 @@ export default function Shell({ api }) {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSearch && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [showSearch])
|
||||
|
||||
const handleSearchChange = useCallback((value) => {
|
||||
setSearchText(value)
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (!entry?.searchAddon) return
|
||||
if (!value) {
|
||||
entry.searchAddon.clearDecorations()
|
||||
entry.searchAddon.clearActiveDecoration()
|
||||
return
|
||||
}
|
||||
try {
|
||||
searchDecorationsRef.current = entry.searchAddon.findNext(value)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const handleSearchNext = useCallback(() => {
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (!entry?.searchAddon || !searchText) return
|
||||
try { entry.searchAddon.findNext(searchText) } catch {}
|
||||
}, [searchText])
|
||||
|
||||
const handleSearchPrev = useCallback(() => {
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (!entry?.searchAddon || !searchText) return
|
||||
try { entry.searchAddon.findPrevious(searchText) } catch {}
|
||||
}, [searchText])
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setShowSearch(false)
|
||||
setSearchText('')
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (entry?.searchAddon) {
|
||||
entry.searchAddon.clearDecorations()
|
||||
entry.searchAddon.clearActiveDecoration()
|
||||
}
|
||||
if (entry?.term) entry.term.focus()
|
||||
}, [])
|
||||
|
||||
const addLocalTab = (shell, name) => {
|
||||
if (tabs.length >= MAX_TABS) return
|
||||
const id = nextIdRef.current++
|
||||
@@ -1040,6 +1118,26 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
|
||||
<div className="shell-xterm-wrapper">
|
||||
{showSearch && (
|
||||
<div className="shell-search-bar">
|
||||
<Search size={14} className="shell-search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="shell-search-input"
|
||||
value={searchText}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
|
||||
if (e.key === 'Escape') handleCloseSearch()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder="Rechercher..."
|
||||
/>
|
||||
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)">↑</button>
|
||||
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)">↓</button>
|
||||
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
|
||||
@@ -383,6 +383,36 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||
|
||||
.shell-search-bar {
|
||||
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 4px 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.shell-search-input {
|
||||
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||
font-family: var(--font-mono); outline: none;
|
||||
}
|
||||
.shell-search-input:focus { border-color: var(--accent); }
|
||||
.shell-search-nav {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||
padding: 0; transition: all 0.1s;
|
||||
}
|
||||
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||
.shell-search-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: none;
|
||||
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||
}
|
||||
.shell-search-close:hover { color: var(--accent); }
|
||||
.shell-xterm-instance {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user