feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign
All checks were successful
Beta Release / beta (push) Successful in 39s

- Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell)
- Config: inline profile & provider editing, system update management
- Dashboard: grid layout with inline tools/notifications/workflows sections
- Add lucide-react icons, i18n keys (FR/EN), and new CSS components

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-21 22:35:49 +02:00
parent ee18bbeb53
commit 3cdcb22068
14 changed files with 1342 additions and 267 deletions

View File

@@ -1,58 +1,251 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useState, useEffect, useCallback } from 'react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({})
const [toast, setToast] = useState(null)
useEffect(() => {
api.getConfig().then(d => setConfig(d)).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
}, [])
const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId))
setCurrentTheme(themeId)
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
useEffect(() => { loadData() }, [loadData])
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const themeColors = {
'cyberpunk-red': '#FF0033',
'cyberpunk-pink': '#FF1A8C',
'midnight-blue': '#0088FF',
'matrix-green': '#00FF41',
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
showToast(t('config.upToDate'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
setUpdating('__all__')
try {
await api.runUpdate('')
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleSaveProfile = async () => {
try {
await api.saveProfile(profileForm)
setEditProfile(false)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleSaveProvider = async () => {
try {
await api.saveProvider(providerForm)
setEditProvider(null)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const openProviderEdit = (p) => {
setProviderForm({
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
})
setEditProvider(p.name)
}
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(t => t.installed).length
const missingCount = tools.filter(t => !t.installed).length
return (
<div className="config-layout">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-section">
<div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? (
<div className="config-section-title">{t('config.systemUpdates')}</div>
<div className="config-actions-row">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? t('config.checking') : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
<div className="config-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
{updates.length === 0 ? (
<div className="empty-state">{t('config.noUpdates')}</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">
{t('config.profile')}
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}>
{editProfile ? t('config.cancel') : t('config.editProfile')}
</button>
</div>
{config?.profile && !editProfile ? (
<div>
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : editProfile ? (
<div className="config-form">
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-form-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</div>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
{editProvider !== p.name ? (
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
{!p.active && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
) : (
<div className="config-form" style={{ marginTop: 8 }}>
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-form-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
@@ -67,7 +260,7 @@ export default function Config({ api }) {
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
@@ -80,41 +273,6 @@ export default function Config({ api }) {
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker">
{themes.map(th => (
<div
key={th.id}
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(th.id)}
title={th.name}
/>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{skillList.length === 0 ? (
@@ -124,10 +282,10 @@ export default function Config({ api }) {
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="tool-row">
<span className="tool-name">{s.name}</span>
<div key={i} className="config-skill-row">
<span className="config-skill-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
<span className="config-skill-desc">{s.description}</span>
</div>
))
)}
@@ -144,3 +302,17 @@ function FieldRow({ label, value }) {
</div>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<input
className="config-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
)
}