Files
MuyueWorkspace/web/src/components/App.jsx
Augustin b85ebb8e54
All checks were successful
Beta Release / beta (push) Successful in 48s
feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00

180 lines
6.4 KiB
JavaScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
useEffect(() => {
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
const hasProfile = d.profile?.name || d.profile?.pseudo
if (!hasProfile) setShowOnboarding(true)
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
setShowOnboarding(true)
})
}, [])
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.ctrlKey && !e.metaKey) return
const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.code])
return
}
if (e.ctrlKey && e.code === 'KeyR') {
e.preventDefault()
if (dashRefreshRef.current) dashRefreshRef.current()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
useEffect(() => {
const handler = () => setActiveTab('shell')
window.addEventListener('navigate-to-shell', handler)
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
],
shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],
config: [],
}), [layout, t])
return (
<div className="app-layout">
<header className="header">
<div className="header-brand">
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
</div>
<nav className="header-nav">
{TABS.map(tab => (
<div
key={tab.id}
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => switchTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
<span className="tab-icon">{tab.icon}</span>
{tab.label}
</div>
))}
</nav>
<div className="header-spacer" />
<div className="header-indicators">
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock">
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
<main className="content">
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>
<footer className="statusbar">
<div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>}
{activeTab === 'dash' && (
<span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh
</span>
)}
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">
<span style={{ fontFamily: 'var(--font-mono)' }}>
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div>
</footer>
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
</div>
)
}
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}