From 80de4dd5233ddd96f795b7c9ac0f57eaaf9b3897 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 22:10:15 +0200 Subject: [PATCH] feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/package-lock.json | 40 ++++++++++++++ web/package.json | 4 ++ web/src/components/Shell.jsx | 104 ++++++++++++++++++++++++++++++++++- web/src/styles/global.css | 30 ++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index c95235a..d1a3899 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 814fd30..5c24f86 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 9e66382..0af85e7 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -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 }) {
+ {showSearch && ( +
+ + handleSearchChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() } + if (e.key === 'Escape') handleCloseSearch() + e.stopPropagation() + }} + placeholder="Rechercher..." + /> + + + +
+ )} {tabs.map(tab => (