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>
1316 lines
48 KiB
JavaScript
1316 lines
48 KiB
JavaScript
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'
|
|
|
|
const AI_TAB_ID = 0
|
|
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
|
|
}
|
|
|
|
// === Style thème système pour xterm ===
|
|
function getCSSVariable(varName) {
|
|
if (typeof document === 'undefined') return null;
|
|
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || null;
|
|
}
|
|
|
|
function parseHexColor(hex) {
|
|
if (!hex || hex.startsWith('var(')) return null;
|
|
hex = hex.replace('#', '');
|
|
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
|
if (hex.length !== 6) return null;
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return { r, g, b };
|
|
}
|
|
|
|
function toRgbString(hex) {
|
|
const c = parseHexColor(hex);
|
|
if (!c) return '#000000';
|
|
return `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`;
|
|
}
|
|
|
|
function buildSystemTheme() {
|
|
const bg = getCSSVariable('--bg-base') || '#0F0D10';
|
|
const fg = getCSSVariable('--text-primary') || '#EAE0E2';
|
|
const accent = getCSSVariable('--accent-light') || '#FF1A5E';
|
|
const accentDim = getCSSVariable('--accent-dim') || '#6B2033';
|
|
const success = '#00E676';
|
|
const warning = '#FFD740';
|
|
const error = getCSSVariable('--accent-bright') || '#FF1744';
|
|
const bgSurface = getCSSVariable('--bg-surface') || bg;
|
|
const bgElevated = getCSSVariable('--bg-elevated') || bgSurface;
|
|
const textSecondary = getCSSVariable('--text-secondary') || fg;
|
|
const textTertiary = getCSSVariable('--text-tertiary') || textSecondary;
|
|
|
|
return {
|
|
background: toRgbString(bg),
|
|
foreground: toRgbString(fg),
|
|
cursor: toRgbString(accent),
|
|
cursorAccent: toRgbString(bg),
|
|
selectionBackground: `${toRgbString(accentDim)}44`,
|
|
selectionForeground: '#FFFFFF',
|
|
black: toRgbString(bgElevated),
|
|
red: toRgbString(error),
|
|
green: toRgbString(success),
|
|
yellow: toRgbString(warning),
|
|
blue: toRgbString(getCSSVariable('--accent') || '#448AFF'),
|
|
magenta: toRgbString(accent),
|
|
cyan: '#00BCD4',
|
|
white: toRgbString(fg),
|
|
brightBlack: toRgbString(bgSurface),
|
|
brightRed: toRgbString(accent),
|
|
brightGreen: toRgbString(success),
|
|
brightYellow: toRgbString(warning),
|
|
brightBlue: toRgbString(getCSSVariable('--accent-muted') || '#82B1FF'),
|
|
brightMagenta: toRgbString(getCSSVariable('--accent-soft') || '#FF80AB'),
|
|
brightCyan: '#84FFFF',
|
|
brightWhite: '#FFFFFF',
|
|
};
|
|
}
|
|
|
|
const THEMES = {
|
|
system: buildSystemTheme(),
|
|
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) {
|
|
if (themeName === 'system' || themeName === 'default') {
|
|
return buildSystemTheme()
|
|
}
|
|
return THEMES[themeName] || buildSystemTheme()
|
|
}
|
|
|
|
function createTerminal(container, settings = {}) {
|
|
const theme = getTheme(settings.theme || 'system')
|
|
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()
|
|
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
|
|
const ctrl = e.ctrlKey || e.metaKey
|
|
const shift = e.shiftKey
|
|
|
|
if (ctrl && shift && e.key === 'C') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const selection = term.getSelection()
|
|
if (selection) navigator.clipboard.writeText(selection)
|
|
return false
|
|
}
|
|
|
|
if (ctrl && shift && e.key === 'V') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
navigator.clipboard.readText().then(text => {
|
|
if (text) term.paste(text)
|
|
}).catch(() => {})
|
|
return false
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
term.open(container)
|
|
fitAddon.fit()
|
|
|
|
return { term, fitAddon, searchAddon }
|
|
}
|
|
|
|
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()
|
|
// Envoyer resize avec dimensions minimales garanties (24x80)
|
|
const rows = dims?.rows || 24
|
|
const cols = dims?.cols || 80
|
|
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
|
|
// Forcer un fit après l'ouverture
|
|
setTimeout(() => {
|
|
try {
|
|
fitAddon.fit()
|
|
const newDims = fitAddon.proposeDimensions()
|
|
if (newDims && newDims.rows > 0 && newDims.cols > 0) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows: newDims.rows, cols: newDims.cols }))
|
|
}
|
|
} catch (e) { console.warn('[Shell] fit failed:', e) }
|
|
}, 50)
|
|
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: 'system' })
|
|
const pendingCommandsRef = useRef({})
|
|
|
|
const [tabs, setTabs] = useState(() => {
|
|
try {
|
|
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed) && parsed.length > 0 && parsed.length <= MAX_TABS) {
|
|
return parsed.map((t, i) => ({
|
|
id: t.id || i + 1,
|
|
name: t.name || `Tab ${i + 1}`,
|
|
type: t.type || 'local',
|
|
shell: t.shell || '',
|
|
host: t.host,
|
|
port: t.port,
|
|
user: t.user,
|
|
key_path: t.key_path,
|
|
connected: false
|
|
}))
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Shell] Failed to parse saved tabs:', e)
|
|
localStorage.removeItem(TABS_STORAGE_KEY)
|
|
}
|
|
return [
|
|
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
|
]
|
|
})
|
|
const [activeTab, setActiveTab] = useState(() => {
|
|
try {
|
|
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.id || 1
|
|
}
|
|
} catch {}
|
|
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: '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({
|
|
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 || 'system',
|
|
})
|
|
}
|
|
}).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, searchAddon } = 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, 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))
|
|
|
|
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 = []
|
|
|
|
// Forcer le layout à se calculer
|
|
const forceLayout = () => {
|
|
const el = document.querySelector('.shell-terminal-col')
|
|
if (el) {
|
|
el.style.height = ''
|
|
el.style.minHeight = ''
|
|
// Forcer reflow
|
|
void el.offsetHeight
|
|
}
|
|
}
|
|
|
|
const tryInitTab = (tab, attempt) => {
|
|
if (cancelled) return
|
|
if (attempt > 20) {
|
|
console.warn(`[Shell] max attempts reached for tab ${tab.id}`)
|
|
return
|
|
}
|
|
|
|
forceLayout()
|
|
const shellCol = document.querySelector('.shell-terminal-col')
|
|
if (!shellCol) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150))
|
|
return
|
|
}
|
|
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
if (!container) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
|
return
|
|
}
|
|
|
|
const rect = container.getBoundingClientRect()
|
|
if (rect.height < 10 || rect.width < 10) {
|
|
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
|
|
return
|
|
}
|
|
|
|
if (!tabsRef.current[tab.id]) {
|
|
initTerminal(tab.id, tab)
|
|
}
|
|
|
|
// Multiple fit attempts avec délais croissants
|
|
const fitAttempts = [0, 50, 100, 200, 400]
|
|
fitAttempts.forEach(delay => {
|
|
setTimeout(() => {
|
|
if (cancelled) return
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry && entry.fitAddon) {
|
|
try {
|
|
entry.fitAddon.fit()
|
|
} catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) }
|
|
}
|
|
}, delay)
|
|
})
|
|
}
|
|
|
|
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) => {
|
|
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
|
|
|
|
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])
|
|
|
|
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++
|
|
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
|
|
})
|
|
|
|
// Redimensionner le nouveau tab actif
|
|
setTimeout(() => {
|
|
const newActiveTabId = next.length > 0 ? next[next.length - 1].id : null
|
|
if (newActiveTabId) {
|
|
const entry = tabsRef.current[newActiveTabId]
|
|
if (entry && entry.fitAddon) {
|
|
try {
|
|
entry.fitAddon.fit()
|
|
} catch (e) { console.warn('[Shell] fit after close failed:', e) }
|
|
}
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
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">
|
|
{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}
|
|
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>
|
|
)
|
|
}
|