refactor: modularize nerd mode ide layout

This commit is contained in:
2025-12-27 23:00:13 +00:00
parent 99d4411a41
commit f747301f65
4 changed files with 378 additions and 234 deletions

View File

@@ -1,246 +1,58 @@
import { useEffect, useMemo, useState } from 'react'
import { Card, CardContent } from '@/components/ui'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
import type { FileNode } from '@/lib/nerd-mode-ide'
import {
appendExportPath,
buildZipFromFileTree,
fileTreeOperations,
getPackageTemplateById,
getPackageTemplates,
} from '@/lib/nerd-mode-ide'
import { Card, CardContent } from '@/components/ui'
import { GitConfigDialog } from '../dialogs/GitConfigDialog'
import { NerdModeEditorPanel } from '../panels/NerdModeEditorPanel'
import { NerdModeIDEFileExplorer } from '../components/NerdModeIDEFileExplorer'
import { NerdModeIDEHeader } from '../components/NerdModeIDEHeader'
import { NewItemDialog } from '../dialogs/NewItemDialog'
import { TemplateDialog } from '../dialogs/TemplateDialog'
import type { GitConfig, TestResult } from './types'
import { EditorPane } from './NerdModeIDE/EditorPane'
import { Sidebar } from './NerdModeIDE/Sidebar'
import { useNerdIdeState } from './NerdModeIDE/useNerdIdeState'
interface NerdModeIDEProps {
className?: string
}
export function NerdModeIDE({ className }: NerdModeIDEProps) {
const templates = useMemo(() => getPackageTemplates(), [])
const defaultTemplate = templates[0]
const [fileTree, setFileTree] = useKV<FileNode[]>('nerd-mode-file-tree', defaultTemplate.tree)
const [workspaceName, setWorkspaceName] = useKV<string>('nerd-mode-workspace', defaultTemplate.rootName)
const [selectedFileId, setSelectedFileId] = useState<string | null>(null)
const [activeFolderId, setActiveFolderId] = useState<string | null>(fileTree?.[0]?.id ?? null)
const [fileContent, setFileContent] = useState('')
const [gitConfig, setGitConfig] = useKV<GitConfig | null>('nerd-mode-git-config', null)
const [showGitDialog, setShowGitDialog] = useState(false)
const [showNewItemDialog, setShowNewItemDialog] = useState(false)
const [showTemplateDialog, setShowTemplateDialog] = useState(false)
const [newItemName, setNewItemName] = useState('')
const [newItemType, setNewItemType] = useState<'file' | 'folder'>('file')
const [testResults, setTestResults] = useState<TestResult[]>([])
const [consoleOutput, setConsoleOutput] = useState<string[]>([])
const [isRunning, setIsRunning] = useState(false)
const [gitCommitMessage, setGitCommitMessage] = useState('')
const selectedFile = useMemo(() => {
if (!fileTree || !selectedFileId) return null
return fileTreeOperations.findNodeById(fileTree, selectedFileId)
}, [fileTree, selectedFileId])
useEffect(() => {
if (!fileTree) return
if (!selectedFileId) {
const firstFile = fileTreeOperations.findFirstFile(fileTree)
if (firstFile) {
setSelectedFileId(firstFile.id)
}
return
}
const fileNode = fileTreeOperations.findNodeById(fileTree, selectedFileId)
if (fileNode?.type === 'file') {
setFileContent(fileNode.content ?? '')
}
}, [fileTree, selectedFileId])
const handleToggleFolder = (nodeId: string) => {
if (!fileTree) return
const node = fileTreeOperations.findNodeById(fileTree, nodeId)
if (node?.type === 'folder') {
setFileTree(fileTreeOperations.updateNode(fileTree, nodeId, { expanded: !node.expanded }))
}
}
const handleSaveFile = () => {
if (!fileTree || !selectedFileId) return
setFileTree(fileTreeOperations.updateNode(fileTree, selectedFileId, { content: fileContent }))
toast.success(`Saved ${selectedFile?.name || 'file'}`)
}
const handleDeleteFile = () => {
if (!fileTree || !selectedFileId) return
setFileTree(fileTreeOperations.deleteNode(fileTree, selectedFileId))
setSelectedFileId(null)
setFileContent('')
toast.success(`Deleted ${selectedFile?.name || 'file'}`)
}
const handleCreateItem = () => {
if (!newItemName.trim() || !fileTree) {
toast.error('Please enter a name')
return
}
const parentNode = activeFolderId
? fileTreeOperations.findNodeById(fileTree, activeFolderId)
: null
const exportPath = parentNode?.exportPath
? appendExportPath(parentNode.exportPath, newItemName)
: undefined
const newNode = newItemType === 'file'
? fileTreeOperations.createFileNode({ name: newItemName, exportPath })
: fileTreeOperations.createFolderNode({ name: newItemName, exportPath, expanded: true })
setFileTree(fileTreeOperations.appendNode(fileTree, activeFolderId, newNode))
setNewItemName('')
setShowNewItemDialog(false)
if (newNode.type === 'file') {
setSelectedFileId(newNode.id)
setFileContent(newNode.content ?? '')
}
toast.success(`Created ${newNode.name}`)
}
const handleRunCode = () => {
setIsRunning(true)
setConsoleOutput((current) => [...current, `> Running ${selectedFile?.name || 'code'}...`])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
'OK Code executed successfully',
'> Output: Hello from MetaBuilder IDE!',
])
setIsRunning(false)
toast.success('Code executed')
}, 1000)
}
const handleRunTests = () => {
setConsoleOutput((current) => [...current, '> Running test suite...'])
const mockTests: TestResult[] = [
{ name: 'Feed component renders', status: 'passed', duration: 45 },
{ name: 'Package export bundles files', status: 'passed', duration: 123 },
{ name: 'Lua manifest loads', status: 'passed', duration: 234 },
{ name: 'Permissions hook', status: 'passed', duration: 67 },
]
setTimeout(() => {
setTestResults(mockTests)
setConsoleOutput((current) => [
...current,
`OK ${mockTests.filter((test) => test.status === 'passed').length} tests passed`,
`FAIL ${mockTests.filter((test) => test.status === 'failed').length} tests failed`,
])
toast.success('Tests completed')
}, 1500)
}
const handleGitPush = () => {
if (!gitConfig) {
toast.error('Please configure Git first')
setShowGitDialog(true)
return
}
if (!gitCommitMessage.trim()) {
toast.error('Please enter a commit message')
return
}
setConsoleOutput((current) => [
...current,
`> git add .`,
`> git commit -m "${gitCommitMessage}"`,
`> git push origin ${gitConfig.branch}`,
])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
`OK Pushed to ${gitConfig.provider} (${gitConfig.repoUrl})`,
])
setGitCommitMessage('')
toast.success('Changes pushed to repository')
}, 1000)
}
const handleGitPull = () => {
if (!gitConfig) {
toast.error('Please configure Git first')
setShowGitDialog(true)
return
}
setConsoleOutput((current) => [...current, `> git pull origin ${gitConfig.branch}`])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
`OK Pulled latest changes from ${gitConfig.branch}`,
])
toast.success('Repository updated')
}, 1000)
}
const handleExportZip = async () => {
if (!fileTree || fileTree.length === 0) {
toast.error('No files to export')
return
}
try {
const blob = await buildZipFromFileTree(fileTree)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${workspaceName || 'workspace'}.zip`
anchor.click()
URL.revokeObjectURL(url)
toast.success('Zip exported')
} catch (error) {
toast.error('Failed to export zip')
}
}
const handleTemplateSelect = (templateId: string) => {
const template = getPackageTemplateById(templateId)
if (!template) return
setFileTree(template.tree)
setWorkspaceName(template.rootName)
setActiveFolderId(template.tree[0]?.id ?? null)
const firstFile = fileTreeOperations.findFirstFile(template.tree)
setSelectedFileId(firstFile?.id ?? null)
setFileContent(firstFile?.content ?? '')
setShowTemplateDialog(false)
toast.success(`Loaded ${template.name}`)
}
const handleUpdateGitConfig = (updates: Partial<GitConfig>) => {
setGitConfig((current) => ({
provider: current?.provider ?? 'github',
repoUrl: current?.repoUrl ?? '',
branch: current?.branch ?? 'main',
token: current?.token ?? '',
...updates,
}))
}
const {
activeFolderId,
consoleOutput,
fileContent,
fileTree,
gitCommitMessage,
gitConfig,
isRunning,
newItemName,
newItemType,
selectedFile,
selectedFileId,
showGitDialog,
showNewItemDialog,
showTemplateDialog,
templates,
testResults,
workspaceName,
handleCreateItem,
handleDeleteFile,
handleExportZip,
handleGitPull,
handleGitPush,
handleRunCode,
handleRunTests,
handleSaveFile,
handleTemplateSelect,
handleToggleFolder,
handleUpdateGitConfig,
setActiveFolderId,
setConsoleOutput,
setFileContent,
setGitCommitMessage,
setNewItemName,
setNewItemType,
setSelectedFileId,
setShowGitDialog,
setShowNewItemDialog,
setShowTemplateDialog,
} = useNerdIdeState()
return (
<div className={className}>
@@ -254,7 +66,7 @@ export function NerdModeIDE({ className }: NerdModeIDEProps) {
/>
<CardContent className="p-0">
<div className="grid grid-cols-4 h-[calc(100vh-200px)]">
<NerdModeIDEFileExplorer
<Sidebar
nodes={fileTree || []}
selectedFileId={selectedFileId}
activeFolderId={activeFolderId}
@@ -262,7 +74,7 @@ export function NerdModeIDE({ className }: NerdModeIDEProps) {
onSelectFile={setSelectedFileId}
onSelectFolder={setActiveFolderId}
/>
<NerdModeEditorPanel
<EditorPane
selectedFile={selectedFile && selectedFile.type === 'file' ? selectedFile : null}
fileContent={fileContent}
isRunning={isRunning}

View File

@@ -0,0 +1,27 @@
import type { FileNode } from '@/lib/nerd-mode-ide'
import type { GitConfig, TestResult } from '../types'
import { NerdModeEditorPanel } from '../../panels/NerdModeEditorPanel'
interface EditorPaneProps {
selectedFile: FileNode | null
fileContent: string
isRunning: boolean
consoleOutput: string[]
testResults: TestResult[]
gitConfig: GitConfig | null
gitCommitMessage: string
onFileChange: (value: string) => void
onRunCode: () => void
onSaveFile: () => void
onDeleteFile: () => void
onClearConsole: () => void
onRunTests: () => void
onCommitMessageChange: (value: string) => void
onGitPush: () => void
onGitPull: () => void
onOpenGitConfig: () => void
}
export function EditorPane(props: EditorPaneProps) {
return <NerdModeEditorPanel {...props} />
}

View File

@@ -0,0 +1,31 @@
import type { FileNode } from '@/lib/nerd-mode-ide'
import { NerdModeIDEFileExplorer } from '../../components/NerdModeIDEFileExplorer'
interface SidebarProps {
nodes: FileNode[]
selectedFileId: string | null
activeFolderId: string | null
onToggleFolder: (nodeId: string) => void
onSelectFile: (nodeId: string) => void
onSelectFolder: (nodeId: string | null) => void
}
export function Sidebar({
nodes,
selectedFileId,
activeFolderId,
onToggleFolder,
onSelectFile,
onSelectFolder,
}: SidebarProps) {
return (
<NerdModeIDEFileExplorer
nodes={nodes}
selectedFileId={selectedFileId}
activeFolderId={activeFolderId}
onToggleFolder={onToggleFolder}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
/>
)
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useKV } from '@github/spark/hooks'
import type { FileNode } from '@/lib/nerd-mode-ide'
import {
appendExportPath,
buildZipFromFileTree,
fileTreeOperations,
getPackageTemplateById,
getPackageTemplates,
} from '@/lib/nerd-mode-ide'
import type { GitConfig, TestResult } from '../types'
export function useNerdIdeState() {
const templates = useMemo(() => getPackageTemplates(), [])
const defaultTemplate = templates[0]
const [fileTree, setFileTree] = useKV<FileNode[]>('nerd-mode-file-tree', defaultTemplate.tree)
const [workspaceName, setWorkspaceName] = useKV<string>('nerd-mode-workspace', defaultTemplate.rootName)
const [selectedFileId, setSelectedFileId] = useState<string | null>(null)
const [activeFolderId, setActiveFolderId] = useState<string | null>(fileTree?.[0]?.id ?? null)
const [fileContent, setFileContent] = useState('')
const [gitConfig, setGitConfig] = useKV<GitConfig | null>('nerd-mode-git-config', null)
const [showGitDialog, setShowGitDialog] = useState(false)
const [showNewItemDialog, setShowNewItemDialog] = useState(false)
const [showTemplateDialog, setShowTemplateDialog] = useState(false)
const [newItemName, setNewItemName] = useState('')
const [newItemType, setNewItemType] = useState<'file' | 'folder'>('file')
const [testResults, setTestResults] = useState<TestResult[]>([])
const [consoleOutput, setConsoleOutput] = useState<string[]>([])
const [isRunning, setIsRunning] = useState(false)
const [gitCommitMessage, setGitCommitMessage] = useState('')
const selectedFile = useMemo(() => {
if (!fileTree || !selectedFileId) return null
return fileTreeOperations.findNodeById(fileTree, selectedFileId)
}, [fileTree, selectedFileId])
useEffect(() => {
if (!fileTree) return
if (!selectedFileId) {
const firstFile = fileTreeOperations.findFirstFile(fileTree)
if (firstFile) {
setSelectedFileId(firstFile.id)
}
return
}
const fileNode = fileTreeOperations.findNodeById(fileTree, selectedFileId)
if (fileNode?.type === 'file') {
setFileContent(fileNode.content ?? '')
}
}, [fileTree, selectedFileId])
const handleToggleFolder = (nodeId: string) => {
if (!fileTree) return
const node = fileTreeOperations.findNodeById(fileTree, nodeId)
if (node?.type === 'folder') {
setFileTree(fileTreeOperations.updateNode(fileTree, nodeId, { expanded: !node.expanded }))
}
}
const handleSaveFile = () => {
if (!fileTree || !selectedFileId) return
setFileTree(fileTreeOperations.updateNode(fileTree, selectedFileId, { content: fileContent }))
toast.success(`Saved ${selectedFile?.name || 'file'}`)
}
const handleDeleteFile = () => {
if (!fileTree || !selectedFileId) return
setFileTree(fileTreeOperations.deleteNode(fileTree, selectedFileId))
setSelectedFileId(null)
setFileContent('')
toast.success(`Deleted ${selectedFile?.name || 'file'}`)
}
const handleCreateItem = () => {
if (!newItemName.trim() || !fileTree) {
toast.error('Please enter a name')
return
}
const parentNode = activeFolderId
? fileTreeOperations.findNodeById(fileTree, activeFolderId)
: null
const exportPath = parentNode?.exportPath
? appendExportPath(parentNode.exportPath, newItemName)
: undefined
const newNode = newItemType === 'file'
? fileTreeOperations.createFileNode({ name: newItemName, exportPath })
: fileTreeOperations.createFolderNode({ name: newItemName, exportPath, expanded: true })
setFileTree(fileTreeOperations.appendNode(fileTree, activeFolderId, newNode))
setNewItemName('')
setShowNewItemDialog(false)
if (newNode.type === 'file') {
setSelectedFileId(newNode.id)
setFileContent(newNode.content ?? '')
}
toast.success(`Created ${newNode.name}`)
}
const handleRunCode = () => {
setIsRunning(true)
setConsoleOutput((current) => [...current, `> Running ${selectedFile?.name || 'code'}...`])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
'OK Code executed successfully',
'> Output: Hello from MetaBuilder IDE!',
])
setIsRunning(false)
toast.success('Code executed')
}, 1000)
}
const handleRunTests = () => {
setConsoleOutput((current) => [...current, '> Running test suite...'])
const mockTests: TestResult[] = [
{ name: 'Feed component renders', status: 'passed', duration: 45 },
{ name: 'Package export bundles files', status: 'passed', duration: 123 },
{ name: 'Lua manifest loads', status: 'passed', duration: 234 },
{ name: 'Permissions hook', status: 'passed', duration: 67 },
]
setTimeout(() => {
setTestResults(mockTests)
setConsoleOutput((current) => [
...current,
`OK ${mockTests.filter((test) => test.status === 'passed').length} tests passed`,
`FAIL ${mockTests.filter((test) => test.status === 'failed').length} tests failed`,
])
toast.success('Tests completed')
}, 1500)
}
const handleGitPush = () => {
if (!gitConfig) {
toast.error('Please configure Git first')
setShowGitDialog(true)
return
}
if (!gitCommitMessage.trim()) {
toast.error('Please enter a commit message')
return
}
setConsoleOutput((current) => [
...current,
`> git add .`,
`> git commit -m "${gitCommitMessage}"`,
`> git push origin ${gitConfig.branch}`,
])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
`OK Pushed to ${gitConfig.provider} (${gitConfig.repoUrl})`,
])
setGitCommitMessage('')
toast.success('Changes pushed to repository')
}, 1000)
}
const handleGitPull = () => {
if (!gitConfig) {
toast.error('Please configure Git first')
setShowGitDialog(true)
return
}
setConsoleOutput((current) => [...current, `> git pull origin ${gitConfig.branch}`])
setTimeout(() => {
setConsoleOutput((current) => [
...current,
`OK Pulled latest changes from ${gitConfig.branch}`,
])
toast.success('Repository updated')
}, 1000)
}
const handleExportZip = async () => {
if (!fileTree || fileTree.length === 0) {
toast.error('No files to export')
return
}
try {
const blob = await buildZipFromFileTree(fileTree)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${workspaceName || 'workspace'}.zip`
anchor.click()
URL.revokeObjectURL(url)
toast.success('Zip exported')
} catch (error) {
toast.error('Failed to export zip')
}
}
const handleTemplateSelect = (templateId: string) => {
const template = getPackageTemplateById(templateId)
if (!template) return
setFileTree(template.tree)
setWorkspaceName(template.rootName)
setActiveFolderId(template.tree[0]?.id ?? null)
const firstFile = fileTreeOperations.findFirstFile(template.tree)
setSelectedFileId(firstFile?.id ?? null)
setFileContent(firstFile?.content ?? '')
setShowTemplateDialog(false)
toast.success(`Loaded ${template.name}`)
}
const handleUpdateGitConfig = (updates: Partial<GitConfig>) => {
setGitConfig((current) => ({
provider: current?.provider ?? 'github',
repoUrl: current?.repoUrl ?? '',
branch: current?.branch ?? 'main',
token: current?.token ?? '',
...updates,
}))
}
return {
activeFolderId,
consoleOutput,
fileContent,
fileTree,
gitCommitMessage,
gitConfig,
isRunning,
newItemName,
newItemType,
selectedFile,
selectedFileId,
showGitDialog,
showNewItemDialog,
showTemplateDialog,
templates,
testResults,
workspaceName,
handleCreateItem,
handleDeleteFile,
handleExportZip,
handleGitPull,
handleGitPush,
handleRunCode,
handleRunTests,
handleSaveFile,
handleTemplateSelect,
handleToggleFolder,
handleUpdateGitConfig,
setActiveFolderId,
setConsoleOutput,
setFileContent,
setGitCommitMessage,
setNewItemName,
setNewItemType,
setSelectedFileId,
setShowGitDialog,
setShowNewItemDialog,
setShowTemplateDialog,
}
}