feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
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:
Augustin
2026-04-24 22:10:15 +02:00
parent de52f4ebd6
commit 80de4dd523
4 changed files with 175 additions and 3 deletions

View File

@@ -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}