Use min-height:0 on xterm-wrapper (flex child) instead of height:100% to properly fill available space in flex layout. Add delayed fit() calls after initialization to let the layout stabilize before calculating terminal cell dimensions. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
1062 lines
39 KiB
JavaScript
1062 lines
39 KiB
JavaScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Terminal as XTerm } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { useI18n } from '../i18n'
|
|
|
|
const MAX_TABS = 7
|
|
const SHELL_MAX_TOKENS = 100000
|
|
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
|
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
|
|
|
function renderContent(text) {
|
|
const parts = []
|
|
const codeBlockRegex = /(```[\s\S]*?```)/g
|
|
let match
|
|
let lastIndex = 0
|
|
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
|
}
|
|
const full = match[1]
|
|
const firstNewline = full.indexOf('\n')
|
|
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
|
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
|
parts.push({ type: 'code', lang, content: code })
|
|
lastIndex = match.index + full.length
|
|
}
|
|
if (lastIndex < text.length) {
|
|
const remaining = text.slice(lastIndex)
|
|
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
|
if (openBlock) {
|
|
if (openBlock.index > 0) {
|
|
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
|
}
|
|
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
|
} else {
|
|
parts.push({ type: 'text', content: remaining })
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
function formatText(text) {
|
|
let html = text
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
html = html
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
|
.replace(/\n/g, '<br/>')
|
|
|
|
html = html
|
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
|
.replace(/javascript:/gi, '')
|
|
.replace(/data:/gi, '')
|
|
|
|
return html
|
|
}
|
|
|
|
const THEMES = {
|
|
default: {
|
|
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
|
|
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
|
|
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
|
|
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
|
|
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
|
|
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
|
|
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
|
|
},
|
|
monokai: {
|
|
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
|
|
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
|
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
|
|
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
|
|
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
|
|
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
|
|
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
|
|
},
|
|
gruvbox: {
|
|
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
|
|
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
|
|
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
|
|
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
|
|
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
|
|
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
|
|
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
|
|
},
|
|
nord: {
|
|
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
|
|
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
|
|
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
|
|
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
|
|
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
|
|
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
|
|
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
|
|
},
|
|
'solarized-dark': {
|
|
background: '#002B36', foreground: '#839496', cursor: '#D33682',
|
|
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
|
|
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
|
|
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
|
|
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
|
|
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
|
|
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
|
|
},
|
|
dracula: {
|
|
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
|
|
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
|
|
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
|
|
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
|
|
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
|
|
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
|
|
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
|
|
},
|
|
}
|
|
|
|
function getTheme(themeName) {
|
|
return THEMES[themeName] || THEMES.default
|
|
}
|
|
|
|
function createTerminal(container, settings = {}) {
|
|
const theme = getTheme(settings.theme || 'default')
|
|
const term = new XTerm({
|
|
cursorBlink: true,
|
|
fontSize: settings.fontSize || 12,
|
|
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme,
|
|
allowTransparency: false,
|
|
scrollback: 5000,
|
|
})
|
|
|
|
const fitAddon = new FitAddon()
|
|
const webLinksAddon = new WebLinksAddon()
|
|
term.loadAddon(fitAddon)
|
|
term.loadAddon(webLinksAddon)
|
|
term.open(container)
|
|
fitAddon.fit()
|
|
|
|
return { term, fitAddon }
|
|
}
|
|
|
|
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
|
|
|
ws.addEventListener('open', () => {
|
|
ws.send(JSON.stringify(initPayload))
|
|
const dims = fitAddon.proposeDimensions()
|
|
if (dims) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
|
}
|
|
if (onStateChange) onStateChange(true)
|
|
})
|
|
|
|
let firstMessage = true
|
|
ws.addEventListener('message', (event) => {
|
|
if (firstMessage) {
|
|
firstMessage = false
|
|
if (onFirstMessage) onFirstMessage()
|
|
}
|
|
try {
|
|
const msg = JSON.parse(event.data)
|
|
if (msg.type === 'output') {
|
|
term.write(msg.data)
|
|
} else if (msg.type === 'error') {
|
|
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
|
|
}
|
|
} catch {
|
|
term.write(event.data)
|
|
}
|
|
})
|
|
|
|
ws.addEventListener('close', () => {
|
|
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
|
if (onStateChange) onStateChange(false)
|
|
})
|
|
|
|
ws.addEventListener('error', () => {
|
|
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
|
if (onStateChange) onStateChange(false)
|
|
})
|
|
|
|
term.onResize(({ rows, cols }) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
|
}
|
|
})
|
|
|
|
return ws
|
|
}
|
|
|
|
export default function Shell({ api }) {
|
|
const { t } = useI18n()
|
|
const tabsRef = useRef({})
|
|
const nextIdRef = useRef(1)
|
|
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
|
const pendingCommandsRef = useRef({})
|
|
|
|
const savedTabs = (() => {
|
|
try {
|
|
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
return parsed.map(t => ({ ...t, connected: false }))
|
|
}
|
|
}
|
|
} catch {}
|
|
return null
|
|
})()
|
|
|
|
const [tabs, setTabs] = useState(savedTabs || [
|
|
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
|
])
|
|
const [activeTab, setActiveTab] = useState(() => {
|
|
if (savedTabs) {
|
|
return savedTabs[0]?.id || 1
|
|
}
|
|
return 1
|
|
})
|
|
const activeTabRef = useRef(activeTab)
|
|
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
|
const [sshConnections, setSshConnections] = useState([])
|
|
const [systemTerminals, setSystemTerminals] = useState([])
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
const [showSshModal, setShowSshModal] = useState(false)
|
|
const [editingTab, setEditingTab] = useState(null)
|
|
const [editName, setEditName] = useState('')
|
|
const [terminalSettings, setTerminalSettings] = useState({
|
|
fontSize: 12,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: 'default',
|
|
})
|
|
|
|
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
|
|
|
const [sshForm, setSshForm] = useState({
|
|
name: '', host: '', port: 22, user: '', key_path: '',
|
|
})
|
|
|
|
const [aiMessages, setAiMessages] = useState([])
|
|
const [aiInput, setAiInput] = useState('')
|
|
const [aiLoading, setAiLoading] = useState(false)
|
|
const [aiTokens, setAiTokens] = useState(0)
|
|
const [aiAtLimit, setAiAtLimit] = useState(false)
|
|
const [analyzing, setAnalyzing] = useState(false)
|
|
const [showAnalysis, setShowAnalysis] = useState(false)
|
|
const [analysisContent, setAnalysisContent] = useState('')
|
|
const aiMessagesRef = useRef(null)
|
|
const aiLoadedRef = useRef(false)
|
|
const aiLoadingRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
|
}, [aiMessages])
|
|
|
|
useEffect(() => {
|
|
api.getShellAnalysis?.().then(d => {
|
|
if (d?.analysis) setAnalysisContent(d.analysis)
|
|
}).catch(() => {
|
|
const stored = localStorage.getItem('shell_analysis')
|
|
if (stored) setAnalysisContent(stored)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (aiLoadedRef.current) return
|
|
aiLoadedRef.current = true
|
|
api.getShellChatHistory().then(d => {
|
|
if (d.messages && d.messages.length > 0) {
|
|
setAiMessages(d.messages)
|
|
} else {
|
|
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
|
|
}
|
|
setAiTokens(d.tokens || 0)
|
|
setAiAtLimit(d.at_limit || false)
|
|
}).catch(() => {
|
|
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
|
|
nextIdRef.current = maxId + 1
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
api.getTerminalSessions().then(d => {
|
|
setSshConnections(d.ssh || [])
|
|
setSystemTerminals(d.system || [])
|
|
}).catch(() => {})
|
|
api.getConfig().then(d => {
|
|
if (d.terminal) {
|
|
setTerminalSettings({
|
|
fontSize: d.terminal.font_size || 12,
|
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: d.terminal.theme || 'default',
|
|
})
|
|
}
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const initTerminal = useCallback((tabId, tab) => {
|
|
if (tabsRef.current[tabId]) return
|
|
|
|
const container = document.getElementById(`terminal-${tabId}`)
|
|
if (!container) return
|
|
|
|
const s = settingsRef.current
|
|
const { term, fitAddon } = createTerminal(container, {
|
|
fontSize: s.fontSize,
|
|
fontFamily: s.fontFamily,
|
|
theme: s.theme,
|
|
})
|
|
|
|
let initPayload
|
|
if (tab.type === 'ssh') {
|
|
initPayload = {
|
|
type: 'ssh',
|
|
data: JSON.stringify({
|
|
host: tab.host,
|
|
port: tab.port || 22,
|
|
user: tab.user || 'root',
|
|
key_path: tab.key_path || '',
|
|
}),
|
|
}
|
|
} else {
|
|
initPayload = {
|
|
type: 'shell',
|
|
data: tab.shell || '',
|
|
}
|
|
}
|
|
|
|
let disposed = false
|
|
|
|
const saveBuffer = () => {
|
|
try {
|
|
const buf = term.buffer.active
|
|
const lines = []
|
|
for (let i = 0; i < buf.length; i++) {
|
|
const line = buf.getLine(i)
|
|
if (line) lines.push(line.translateToString(true))
|
|
}
|
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
savedBuffers[tabId] = lines.join('\n')
|
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
|
|
}
|
|
|
|
const onWsState = (connected) => {
|
|
if (disposed) return
|
|
if (!connected) saveBuffer()
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
|
}
|
|
|
|
const restoreBuffer = () => {
|
|
try {
|
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
if (savedBuffers[tabId]) {
|
|
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
|
term.write(savedBuffers[tabId])
|
|
}
|
|
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
|
|
}
|
|
|
|
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
|
|
|
|
const clearBufferOnClear = () => {
|
|
try {
|
|
const buf = term.buffer.active
|
|
const lineY = buf.length - 1
|
|
const line = buf.getLine(lineY)
|
|
if (line) {
|
|
const text = line.translateToString(true).trim().toLowerCase()
|
|
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
|
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
delete savedBuffers[tabId]
|
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
}
|
|
}
|
|
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
|
|
}
|
|
|
|
term.onData((data) => {
|
|
if (data === '\r') clearBufferOnClear()
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }))
|
|
}
|
|
})
|
|
|
|
const onResize = () => {
|
|
fitAddon.fit()
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(onResize)
|
|
resizeObserver.observe(container)
|
|
window.addEventListener('resize', onResize)
|
|
|
|
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]._markDisposed = () => { disposed = true }
|
|
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
|
|
|
const pending = pendingCommandsRef.current[tabId]
|
|
if (pending && pending.length > 0) {
|
|
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
|
|
for (const cmd of pending) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
|
}
|
|
}
|
|
delete pendingCommandsRef.current[tabId]
|
|
}
|
|
}, [])
|
|
|
|
const initPendingTabs = useCallback(() => {
|
|
for (const tab of tabsRef.current._tabList || []) {
|
|
if (!tabsRef.current[tab.id]) {
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
if (container && container.offsetHeight > 0) {
|
|
initTerminal(tab.id, tab)
|
|
}
|
|
}
|
|
}
|
|
requestAnimationFrame(() => {
|
|
for (const tab of tabsRef.current._tabList || []) {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
}
|
|
setTimeout(() => {
|
|
for (const tab of tabsRef.current._tabList || []) {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
}
|
|
}, 150)
|
|
})
|
|
}, [initTerminal])
|
|
|
|
useEffect(() => {
|
|
tabsRef.current._tabList = tabs
|
|
}, [tabs])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const pending = []
|
|
|
|
const tryInitTab = (tab, attempt) => {
|
|
if (cancelled) return
|
|
const shellCol = document.querySelector('.shell-terminal-col')
|
|
if (!shellCol || shellCol.offsetParent === null) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200))
|
|
return
|
|
}
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
if (!container) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
|
return
|
|
}
|
|
if (container.offsetHeight === 0) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
|
return
|
|
}
|
|
if (!tabsRef.current[tab.id]) {
|
|
initTerminal(tab.id, tab)
|
|
}
|
|
requestAnimationFrame(() => {
|
|
if (cancelled) return
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) {
|
|
entry.fitAddon.fit()
|
|
setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100)
|
|
}
|
|
})
|
|
if (!tabsRef.current[tab.id]) {
|
|
tryInitTab(tab, 0)
|
|
}
|
|
}
|
|
|
|
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
|
let observer
|
|
if (wrapper) {
|
|
observer = new MutationObserver(() => {
|
|
if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) {
|
|
initPendingTabs()
|
|
}
|
|
})
|
|
observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] })
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true
|
|
pending.forEach(clearTimeout)
|
|
observer?.disconnect()
|
|
}
|
|
}, [tabs, initTerminal, initPendingTabs])
|
|
|
|
useEffect(() => {
|
|
const entry = tabsRef.current[activeTab]
|
|
if (entry) {
|
|
requestAnimationFrame(() => {
|
|
if (activeTabRef.current === activeTab) {
|
|
entry.fitAddon.fit()
|
|
}
|
|
})
|
|
}
|
|
}, [activeTab])
|
|
|
|
useEffect(() => {
|
|
const iv = setInterval(() => {
|
|
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
|
if (wrapper && wrapper.classList.contains('tab-hidden')) return
|
|
const entry = tabsRef.current[activeTabRef.current]
|
|
if (entry) {
|
|
entry.fitAddon.fit()
|
|
}
|
|
}, 2000)
|
|
return () => clearInterval(iv)
|
|
}, [tabs])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
|
|
entry._markDisposed?.()
|
|
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
|
window.removeEventListener('resize', entry.onResize)
|
|
entry.resizeObserver?.disconnect()
|
|
entry.ws?.close()
|
|
entry.term?.dispose()
|
|
}
|
|
tabsRef.current = {}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const onKey = (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
|
|
|
if (e.key === 'Tab' && e.shiftKey) {
|
|
const shellTab = document.querySelector('.shell-layout')
|
|
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
|
e.preventDefault()
|
|
const idx = tabs.findIndex(t => t.id === activeTab)
|
|
const next = (idx + 1) % tabs.length
|
|
setActiveTab(tabs[next].id)
|
|
return
|
|
}
|
|
|
|
const num = parseInt(e.key)
|
|
if (num >= 1 && num <= tabs.length) {
|
|
e.preventDefault()
|
|
setActiveTab(tabs[num - 1].id)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [tabs])
|
|
|
|
const addLocalTab = (shell, name) => {
|
|
if (tabs.length >= MAX_TABS) return
|
|
const id = nextIdRef.current++
|
|
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
|
|
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
|
setActiveTab(id)
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const addSSHTab = (conn) => {
|
|
if (tabs.length >= MAX_TABS) return
|
|
const id = nextIdRef.current++
|
|
const newTab = {
|
|
id,
|
|
name: conn.name || `${conn.user}@${conn.host}`,
|
|
type: 'ssh',
|
|
host: conn.host,
|
|
port: conn.port || 22,
|
|
user: conn.user || 'root',
|
|
key_path: conn.key_path || '',
|
|
connected: false,
|
|
}
|
|
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
|
setActiveTab(id)
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const closeTab = (tabId, e) => {
|
|
if (e) e.stopPropagation()
|
|
|
|
const entry = tabsRef.current[tabId]
|
|
if (entry) {
|
|
entry._markDisposed?.()
|
|
entry.saveBuffer?.()
|
|
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
|
window.removeEventListener('resize', entry.onResize)
|
|
entry.resizeObserver.disconnect()
|
|
entry.ws.close()
|
|
entry.term.dispose()
|
|
delete tabsRef.current[tabId]
|
|
}
|
|
|
|
try {
|
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
delete savedBuffers[tabId]
|
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
|
|
|
|
setTabs(prev => {
|
|
if (prev.length <= 1) return prev
|
|
const next = prev.filter(t => t.id !== tabId)
|
|
if (activeTab === tabId && next.length > 0) {
|
|
setActiveTab(next[next.length - 1].id)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const startRename = (tabId, e) => {
|
|
if (e) e.stopPropagation()
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
setEditingTab(tabId)
|
|
setEditName(tab.name)
|
|
}
|
|
|
|
const finishRename = () => {
|
|
if (editName.trim() && editingTab) {
|
|
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
|
|
}
|
|
setEditingTab(null)
|
|
setEditName('')
|
|
}
|
|
|
|
const saveSSHConnection = async () => {
|
|
if (!sshForm.name.trim() || !sshForm.host.trim()) return
|
|
try {
|
|
await api.addSSHConnection(sshForm)
|
|
setSshConnections(prev => [...prev, { ...sshForm }])
|
|
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
|
|
setShowSshModal(false)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const deleteSSHConnection = async (name) => {
|
|
try {
|
|
await api.deleteSSHConnection(name)
|
|
setSshConnections(prev => prev.filter(c => c.name !== name))
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}
|
|
|
|
const sendToTerminal = useCallback((code, tabId) => {
|
|
const targetId = tabId || activeTabRef.current
|
|
const entry = tabsRef.current[targetId]
|
|
if (!entry) {
|
|
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
|
|
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
|
pendingCommandsRef.current[targetId].push(code)
|
|
return
|
|
}
|
|
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
|
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
|
|
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
|
pendingCommandsRef.current[targetId].push(code)
|
|
return
|
|
}
|
|
console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`)
|
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
|
}, [])
|
|
|
|
const focusAiTerminal = useCallback(() => {
|
|
const entry = tabsRef.current[activeTabRef.current]
|
|
if (entry) entry.term.focus()
|
|
}, [])
|
|
|
|
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
|
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
|
const trimmed = text.trim()
|
|
aiLoadingRef.current = true
|
|
|
|
if (!fromEvent) {
|
|
setAiInput('')
|
|
focusAiTerminal()
|
|
}
|
|
|
|
if (trimmed === '/clear') {
|
|
try {
|
|
await api.clearShellChat()
|
|
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
|
setAiTokens(0)
|
|
setAiAtLimit(false)
|
|
} catch {}
|
|
aiLoadingRef.current = false
|
|
return
|
|
}
|
|
|
|
if (trimmed === '/help') {
|
|
setAiMessages(prev => [...prev,
|
|
{ role: 'user', content: trimmed },
|
|
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
|
])
|
|
aiLoadingRef.current = false
|
|
return
|
|
}
|
|
|
|
const currentTab = activeTabRef.current
|
|
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
|
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
|
|
setAiLoading(true)
|
|
|
|
try {
|
|
let accumulated = ''
|
|
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
|
accumulated = partial
|
|
setAiMessages(prev => {
|
|
const filtered = prev.filter(m => !m._streaming)
|
|
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
|
|
})
|
|
})
|
|
|
|
setAiMessages(prev => {
|
|
const filtered = prev.filter(m => !m._streaming)
|
|
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
|
|
})
|
|
api.getShellChatHistory().then(d => {
|
|
setAiTokens(d.tokens || 0)
|
|
setAiAtLimit(d.at_limit || false)
|
|
}).catch(() => {})
|
|
} catch (err) {
|
|
if (err.message?.includes('context limit')) {
|
|
setAiAtLimit(true)
|
|
}
|
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
|
}
|
|
setAiLoading(false)
|
|
aiLoadingRef.current = false
|
|
}, [api, t, aiAtLimit, focusAiTerminal])
|
|
|
|
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
|
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
const msg = e.detail?.message
|
|
if (!msg) return
|
|
setAiInput(msg)
|
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
|
}
|
|
window.addEventListener('ask-ai-terminal', handler)
|
|
return () => window.removeEventListener('ask-ai-terminal', handler)
|
|
}, [_sendAiMessage])
|
|
|
|
const handleAnalyze = async () => {
|
|
setAnalyzing(true)
|
|
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
|
try {
|
|
const d = await api.analyzeSystem()
|
|
if (d.analysis) {
|
|
setAnalysisContent(d.analysis)
|
|
localStorage.setItem('shell_analysis', d.analysis)
|
|
}
|
|
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
|
|
role: 'system',
|
|
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
|
|
}])
|
|
} catch (err) {
|
|
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
|
|
}
|
|
setAnalyzing(false)
|
|
}
|
|
|
|
return (
|
|
<div className="shell-layout">
|
|
<div className="shell-terminal-col">
|
|
<div className="shell-tabs-bar">
|
|
<div className="shell-tabs">
|
|
{tabs.map((tab, i) => (
|
|
<div
|
|
key={tab.id}
|
|
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
onDoubleClick={(e) => startRename(tab.id, e)}
|
|
>
|
|
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
|
{tab.type === 'ssh' && <Globe size={12} />}
|
|
{tab.type === 'local' && <Monitor size={12} />}
|
|
{editingTab === tab.id ? (
|
|
<input
|
|
className="shell-tab-rename"
|
|
value={editName}
|
|
onChange={e => setEditName(e.target.value)}
|
|
onBlur={finishRename}
|
|
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
|
autoFocus
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<span className="shell-tab-name">{tab.name}</span>
|
|
)}
|
|
<span className="shell-tab-index">{i + 1}</span>
|
|
{tabs.length > 1 && (
|
|
<button
|
|
className="shell-tab-close"
|
|
onClick={(e) => closeTab(tab.id, e)}
|
|
title={t('shell.closeTab')}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="shell-tab-actions">
|
|
{tabs.length < MAX_TABS && (
|
|
<div className="shell-new-tab-wrapper">
|
|
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
|
<Plus size={16} />
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
{showMenu && (
|
|
<>
|
|
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
|
<div className="shell-new-tab-menu">
|
|
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
|
{systemTerminals.map(st => (
|
|
<button
|
|
key={st.name}
|
|
className="shell-menu-item"
|
|
onClick={() => addLocalTab(st.shell, st.name)}
|
|
>
|
|
<Monitor size={14} />
|
|
<span>{st.name}</span>
|
|
<span className="shell-menu-item-sub">{st.shell}</span>
|
|
</button>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
|
{sshConnections.length === 0 && (
|
|
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
|
)}
|
|
{sshConnections.map(conn => (
|
|
<div key={conn.name} className="shell-menu-item-row">
|
|
<button
|
|
className="shell-menu-item"
|
|
onClick={() => addSSHTab(conn)}
|
|
>
|
|
<Globe size={14} />
|
|
<span>{conn.name}</span>
|
|
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
|
</button>
|
|
<button
|
|
className="shell-menu-item-icon"
|
|
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
|
title={t('shell.deleteConnection')}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
|
<Plus size={14} />
|
|
<span>{t('shell.addConnection')}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-xterm-wrapper">
|
|
{tabs.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
id={`terminal-${tab.id}`}
|
|
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-ai-col">
|
|
<div className="ai-panel-header">
|
|
<span>Analyste Système</span>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button
|
|
className="shell-analyze-btn"
|
|
onClick={() => setShowAnalysis(true)}
|
|
disabled={!analysisContent}
|
|
title="Voir l'analyse"
|
|
>
|
|
<Eye size={13} />
|
|
Analyse
|
|
</button>
|
|
<button
|
|
className="shell-analyze-btn"
|
|
onClick={handleAnalyze}
|
|
disabled={analyzing}
|
|
title="Analyser le système"
|
|
>
|
|
<Search size={13} />
|
|
{analyzing ? '...' : 'Analyser'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="shell-ai-token-bar">
|
|
<div className="shell-ai-token-track">
|
|
<div
|
|
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
|
|
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
|
|
</div>
|
|
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
|
{aiMessages.map((msg, i) => (
|
|
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
|
))}
|
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
|
</div>
|
|
<div className="ai-panel-input">
|
|
<input
|
|
value={aiInput}
|
|
onChange={e => setAiInput(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
|
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
|
|
disabled={aiAtLimit && aiInput !== '/clear'}
|
|
/>
|
|
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showAnalysis && analysisContent && (
|
|
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
|
|
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="shell-analysis-modal-header">
|
|
<span>Analyse Système</span>
|
|
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
|
|
</div>
|
|
<div className="shell-analysis-modal-body">
|
|
{renderContent(analysisContent).map((part, i) =>
|
|
part.type === 'code' ? (
|
|
<div key={i} className="shell-code-block">
|
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
|
<pre><code>{part.content}</code></pre>
|
|
</div>
|
|
) : (
|
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showSshModal && (
|
|
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
|
<div className="shell-modal-body">
|
|
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
|
<input
|
|
value={sshForm.name}
|
|
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
|
placeholder="prod-server"
|
|
/>
|
|
<label className="shell-modal-label">{t('shell.host')}</label>
|
|
<input
|
|
value={sshForm.host}
|
|
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
|
placeholder="192.168.1.100"
|
|
/>
|
|
<div className="shell-modal-row">
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.port')}</label>
|
|
<input
|
|
type="number"
|
|
value={sshForm.port}
|
|
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.user')}</label>
|
|
<input
|
|
value={sshForm.user}
|
|
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
|
placeholder="root"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
|
<input
|
|
value={sshForm.key_path}
|
|
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
|
placeholder="~/.ssh/id_rsa"
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-footer">
|
|
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
|
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
|
const content = msg.content || ''
|
|
|
|
if (role === 'user') {
|
|
return <div className={`ai-message user`}>{content}</div>
|
|
}
|
|
|
|
if (role === 'system') {
|
|
return <div className={`ai-message system`}>{content}</div>
|
|
}
|
|
|
|
const parts = renderContent(content)
|
|
|
|
return (
|
|
<div className={`ai-message assistant`}>
|
|
{parts.map((part, i) => {
|
|
if (part.type === 'code') {
|
|
return (
|
|
<div key={i} className="shell-code-block">
|
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
|
<pre><code>{part.content}</code></pre>
|
|
<div className="shell-code-actions">
|
|
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
|
<Copy size={12} /> Copier
|
|
</button>
|
|
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
|
<Send size={12} /> Terminal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
|
})}
|
|
</div>
|
|
)
|
|
}
|