refactor: remove locale panel, improve provider validation and terminal buffer persistence
All checks were successful
Beta Release / beta (push) Successful in 47s
All checks were successful
Beta Release / beta (push) Successful in 47s
- Remove locale panel from config (language/keyboard already handled elsewhere) - Add per-provider key validation status with auto-check on load - Add missing tools section with AI-powered installation - Improve reset confirmation with modal - Persist terminal buffer to localStorage with auto-save - Detect clear command to wipe saved buffer - Remove AI tab concept (commands routed to active tab instead) - Remove renderTick hacks, use proper message keys 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
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, Bot } from 'lucide-react'
|
||||
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 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 = []
|
||||
@@ -132,7 +132,7 @@ function createTerminal(container, settings = {}) {
|
||||
const theme = getTheme(settings.theme || 'default')
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: settings.fontSize || 14,
|
||||
fontSize: settings.fontSize || 12,
|
||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
@@ -201,7 +201,7 @@ export default function Shell({ api }) {
|
||||
const { t } = useI18n()
|
||||
const tabsRef = useRef({})
|
||||
const nextIdRef = useRef(1)
|
||||
const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
||||
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
||||
|
||||
const savedTabs = (() => {
|
||||
try {
|
||||
@@ -217,15 +217,13 @@ export default function Shell({ api }) {
|
||||
})()
|
||||
|
||||
const [tabs, setTabs] = useState(savedTabs || [
|
||||
{ id: AI_TAB_ID, name: 'AI Terminal', type: 'ai', shell: '', connected: false, ai: true },
|
||||
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
||||
])
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
if (savedTabs) {
|
||||
const aiTab = savedTabs.find(t => t.ai)
|
||||
return aiTab ? aiTab.id : savedTabs[0].id
|
||||
return savedTabs[0]?.id || 1
|
||||
}
|
||||
return AI_TAB_ID
|
||||
return 1
|
||||
})
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
@@ -234,7 +232,7 @@ export default function Shell({ api }) {
|
||||
const [editingTab, setEditingTab] = useState(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [terminalSettings, setTerminalSettings] = useState({
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||
theme: 'default',
|
||||
})
|
||||
@@ -253,7 +251,6 @@ export default function Shell({ api }) {
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
const [analysisContent, setAnalysisContent] = useState('')
|
||||
const [renderTick, setRenderTick] = useState(0)
|
||||
const aiMessagesRef = useRef(null)
|
||||
const aiLoadedRef = useRef(false)
|
||||
|
||||
@@ -261,12 +258,6 @@ export default function Shell({ api }) {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
}, [aiMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const ms = aiLoading ? 1000 : 5000
|
||||
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
|
||||
return () => clearInterval(iv)
|
||||
}, [aiLoading])
|
||||
|
||||
useEffect(() => {
|
||||
api.getShellAnalysis?.().then(d => {
|
||||
if (d?.analysis) setAnalysisContent(d.analysis)
|
||||
@@ -305,7 +296,7 @@ export default function Shell({ api }) {
|
||||
api.getConfig().then(d => {
|
||||
if (d.terminal) {
|
||||
setTerminalSettings({
|
||||
fontSize: d.terminal.font_size || 14,
|
||||
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',
|
||||
})
|
||||
@@ -346,11 +337,58 @@ export default function Shell({ api }) {
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
|
||||
// Restore saved terminal buffer
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
} catch {}
|
||||
|
||||
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(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
savedBuffers[tabId] = lines.join('\n')
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
||||
|
||||
// Detect clear command to wipe saved buffer
|
||||
let inputBuffer = ''
|
||||
term.onData((data) => {
|
||||
if (data === '\r') {
|
||||
const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase()
|
||||
if (cmd === 'clear') {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
}
|
||||
inputBuffer = ''
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
} else if (data === '\x03') {
|
||||
inputBuffer = ''
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
inputBuffer += data
|
||||
}
|
||||
})
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
saveBuffer()
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
@@ -369,7 +407,7 @@ export default function Shell({ api }) {
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -444,7 +482,7 @@ export default function Shell({ api }) {
|
||||
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, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
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)
|
||||
}
|
||||
@@ -462,7 +500,7 @@ export default function Shell({ api }) {
|
||||
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, ai: t.ai || false, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||
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)
|
||||
}
|
||||
@@ -470,10 +508,12 @@ export default function Shell({ api }) {
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (!tab || tab.ai || tabs.length <= 1) return
|
||||
if (!tab || tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
||||
const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId]
|
||||
if (saveBuffer) saveBuffer()
|
||||
if (bufferSaveInterval) clearInterval(bufferSaveInterval)
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
@@ -527,19 +567,16 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
const sendToTerminal = useCallback((code) => {
|
||||
const aiEntry = tabsRef.current[AI_TAB_ID]
|
||||
if (aiEntry?.ws && aiEntry.ws.readyState === WebSocket.OPEN) {
|
||||
aiEntry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
const entry = tabsRef.current[activeTab]
|
||||
if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}
|
||||
}, [])
|
||||
}, [activeTab])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
setActiveTab(AI_TAB_ID)
|
||||
setTimeout(() => {
|
||||
const entry = tabsRef.current[AI_TAB_ID]
|
||||
if (entry) entry.term.focus()
|
||||
}, 150)
|
||||
}, [])
|
||||
const entry = tabsRef.current[activeTab]
|
||||
if (entry) entry.term.focus()
|
||||
}, [activeTab])
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
||||
@@ -596,21 +633,7 @@ export default function Shell({ api }) {
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const msg = e.detail?.message
|
||||
if (!msg) return
|
||||
setAiInput(msg)
|
||||
setActiveTab(AI_TAB_ID)
|
||||
setTimeout(() => {
|
||||
handleAiSendDirect(msg)
|
||||
}, 100)
|
||||
}
|
||||
window.addEventListener('ask-ai-terminal', handler)
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [])
|
||||
|
||||
const handleAiSendDirect = async (text) => {
|
||||
const handleAiSendDirect = useCallback(async (text) => {
|
||||
if (!text || aiLoading || aiAtLimit) return
|
||||
setAiInput('')
|
||||
|
||||
@@ -652,7 +675,20 @@ export default function Shell({ api }) {
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
}, [api, t, aiLoading, aiAtLimit])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const msg = e.detail?.message
|
||||
if (!msg) return
|
||||
setAiInput(msg)
|
||||
setTimeout(() => {
|
||||
handleAiSendDirect(msg)
|
||||
}, 100)
|
||||
}
|
||||
window.addEventListener('ask-ai-terminal', handler)
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [handleAiSendDirect])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
@@ -681,14 +717,13 @@ export default function Shell({ api }) {
|
||||
{tabs.map((tab, i) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''} ${tab.ai ? 'ai-tab' : ''}`}
|
||||
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)}
|
||||
onDoubleClick={(e) => startRename(tab.id, e)}
|
||||
>
|
||||
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
||||
{tab.ai && <Bot size={12} />}
|
||||
{!tab.ai && tab.type === 'ssh' && <Globe size={12} />}
|
||||
{!tab.ai && tab.type === 'local' && <Monitor size={12} />}
|
||||
{tab.type === 'ssh' && <Globe size={12} />}
|
||||
{tab.type === 'local' && <Monitor size={12} />}
|
||||
{editingTab === tab.id ? (
|
||||
<input
|
||||
className="shell-tab-rename"
|
||||
@@ -703,7 +738,7 @@ export default function Shell({ api }) {
|
||||
<span className="shell-tab-name">{tab.name}</span>
|
||||
)}
|
||||
<span className="shell-tab-index">{i + 1}</span>
|
||||
{!tab.ai && tabs.length > 1 && (
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
className="shell-tab-close"
|
||||
onClick={(e) => closeTab(tab.id, e)}
|
||||
@@ -823,7 +858,7 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<ShellAIMessage key={`${i}-${renderTick}`} msg={msg} sendToTerminal={sendToTerminal} renderTick={renderTick} />
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
@@ -915,7 +950,7 @@ export default function Shell({ api }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
|
||||
function ShellAIMessage({ msg, sendToTerminal }) {
|
||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||
const content = msg.content || ''
|
||||
|
||||
@@ -934,7 +969,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === 'code') {
|
||||
return (
|
||||
<div key={`${i}-${renderTick}`} className="shell-code-block">
|
||||
<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">
|
||||
@@ -948,7 +983,7 @@ function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span key={`${i}-${renderTick}`} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user