All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal focus to next event loop tick to prevent xterm from capturing the same key event. 💘 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('')
|
|
setTimeout(() => focusAiTerminal(), 0)
|
|
}
|
|
|
|
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 => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); 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>
|
|
)
|
|
}
|