diff --git a/pages/app/index.jsx b/pages/app/index.jsx index b53c167..0f0e652 100644 --- a/pages/app/index.jsx +++ b/pages/app/index.jsx @@ -1,300 +1,665 @@ "use client" -import { useState, useEffect } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { - Menu, - X, - FolderOpen, - Settings, - Play, - Save, - Download, - MessageSquare, - Send, - ChevronLeft, - ChevronRight +import { useEffect, useMemo, useRef, useState } from "react" +import { AnimatePresence, motion } from "framer-motion" +import { + ChevronDown, + ChevronRight, + FileCode2, + FolderClosed, + FolderOpen, + Menu, + MessageSquare, + Play, + Plus, + Save, + Send, + Settings, + X, } from "lucide-react" + import { Button } from "@/components/ui/button" -export default function IDEApp() { - const [sidebarOpen, setSidebarOpen] = useState(true) - const [chatOpen, setChatOpen] = useState(true) - const [message, setMessage] = useState("") - const [isMobile, setIsMobile] = useState(false) - - // Check if we're on mobile - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768) - // Auto-collapse sidebar and chat on mobile - if (window.innerWidth < 768) { - setSidebarOpen(false) - setChatOpen(false) - } else if (window.innerWidth >= 1024) { - // Auto-open on desktop - setSidebarOpen(true) - setChatOpen(true) - } - } - - checkMobile() - window.addEventListener("resize", checkMobile) - return () => window.removeEventListener("resize", checkMobile) - }, []) - - const sidebarVariants = { - open: { x: 0, opacity: 1 }, - closed: { x: "-100%", opacity: 0 } +const STORAGE_KEY = "calliope.workspace.v1" +const AUTOSAVE_DELAY_MS = 700 + +const defaultWorkspace = { + id: "root", + name: "stellar-token-starter", + type: "folder", + children: [ + { + id: "src", + name: "src", + type: "folder", + children: [ + { + id: "contract", + name: "contract.rs", + type: "file", + language: "rust", + content: `#![no_std] +use soroban_sdk::{contract, contractimpl, Env, String}; + +#[contract] +pub struct GreetingContract; + +#[contractimpl] +impl GreetingContract { + pub fn hello(env: Env, name: String) -> String { + let mut response = String::from_str(&env, "Hello, "); + response.push_str(&name); + response } +} +`, + }, + { + id: "lib", + name: "lib.rs", + type: "file", + language: "rust", + content: `mod contract; + +pub use crate::contract::GreetingContract; +`, + }, + ], + }, + { + id: "tests", + name: "tests", + type: "folder", + children: [ + { + id: "contract-test", + name: "contract.test.ts", + type: "file", + language: "typescript", + content: `describe("GreetingContract", () => { + it("returns a greeting", () => { + expect("Hello, Soroban").toContain("Soroban"); + }); +}); +`, + }, + ], + }, + { + id: "cargo", + name: "Cargo.toml", + type: "file", + language: "toml", + content: `[package] +name = "stellar-token-starter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "22.0.0" +`, + }, + { + id: "readme", + name: "README.md", + type: "file", + language: "markdown", + content: `# Stellar Token Starter + +Browser-persistent starter workspace for Soroban contract experiments. +`, + }, + ], +} + +function collectFileIds(node, ids = []) { + if (node.type === "file") { + ids.push(node.id) + return ids + } + + ;(node.children || []).forEach((child) => collectFileIds(child, ids)) + return ids +} + +function findNodeById(node, id) { + if (!node) return null + if (node.id === id) return node + if (node.type !== "folder") return null - const chatVariants = { - open: { x: 0, opacity: 1 }, - closed: { x: "100%", opacity: 0 } + for (const child of node.children || []) { + const found = findNodeById(child, id) + if (found) return found + } + + return null +} + +function updateNodeById(node, id, updater) { + if (node.id === id) { + return updater(node) + } + + if (node.type !== "folder") { + return node + } + + return { + ...node, + children: (node.children || []).map((child) => updateNodeById(child, id, updater)), + } +} + +function insertNode(parent, parentId, newNode) { + if (parent.id === parentId && parent.type === "folder") { + return { + ...parent, + children: [...(parent.children || []), newNode], } + } + + if (parent.type !== "folder") { + return parent + } + + return { + ...parent, + children: (parent.children || []).map((child) => insertNode(child, parentId, newNode)), + } +} + +function getLineNumbers(content) { + return Array.from({ length: Math.max(content.split("\n").length, 1) }, (_, index) => index + 1) +} + +function createNodeId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` +} + +function buildDefaultExpandedFolders() { + return ["root", "src", "tests"] +} + +function renderStatusLabel(status, unsavedChanges) { + if (unsavedChanges) return "Unsaved changes" + return status +} + +function WorkspaceTree({ + node, + expandedFolders, + activeFileId, + onToggleFolder, + onSelectFile, + depth = 0, +}) { + const isFolder = node.type === "folder" + const isExpanded = expandedFolders.includes(node.id) + if (isFolder) { return ( -
- {/* Mobile Backdrop */} - {isMobile && (sidebarOpen || chatOpen) && ( - { - setSidebarOpen(false) - setChatOpen(false) - }} - /> - )} +
+ - {/* Sidebar */} - - {(sidebarOpen || !isMobile) && ( - - {/* Sidebar Header */} -
-

Explorer

- -
- - {/* File Explorer */} -
-
-
- - src/ -
-
- 📄 - contract.rs -
-
- 📄 - lib.rs -
-
- - tests/ -
-
- 📄 - Cargo.toml -
-
-
- - {/* Sidebar Footer */} -
- -
-
- )} -
- - {/* Main Content Area */} -
- {/* Top Toolbar */} -
-
- - contract.rs -
- -
- -
- - - -
+ {isExpanded && + (node.children || []).map((child) => ( + + ))} +
+ ) + } + + return ( + + ) +} + +export default function IDEApp() { + const [sidebarOpen, setSidebarOpen] = useState(true) + const [chatOpen, setChatOpen] = useState(true) + const [message, setMessage] = useState("") + const [isMobile, setIsMobile] = useState(false) + const [workspace, setWorkspace] = useState(defaultWorkspace) + const [expandedFolders, setExpandedFolders] = useState(buildDefaultExpandedFolders()) + const [activeFileId, setActiveFileId] = useState("contract") + const [editorValue, setEditorValue] = useState("") + const [saveStatus, setSaveStatus] = useState("All changes saved") + const [isHydrated, setIsHydrated] = useState(false) + const autosaveTimerRef = useRef(null) + + useEffect(() => { + const checkMobile = () => { + const mobile = window.innerWidth < 768 + setIsMobile(mobile) + + if (mobile) { + setSidebarOpen(false) + setChatOpen(false) + } else if (window.innerWidth >= 1024) { + setSidebarOpen(true) + setChatOpen(true) + } + } + + const storedWorkspace = window.localStorage.getItem(STORAGE_KEY) + if (storedWorkspace) { + try { + const parsed = JSON.parse(storedWorkspace) + setWorkspace(parsed.workspace || defaultWorkspace) + setExpandedFolders(parsed.expandedFolders || buildDefaultExpandedFolders()) + setActiveFileId(parsed.activeFileId || "contract") + } catch { + window.localStorage.removeItem(STORAGE_KEY) + } + } + + setIsHydrated(true) + checkMobile() + window.addEventListener("resize", checkMobile) + return () => window.removeEventListener("resize", checkMobile) + }, []) + + const activeFile = useMemo(() => findNodeById(workspace, activeFileId), [workspace, activeFileId]) + const activeFileContent = activeFile?.type === "file" ? activeFile.content : "" + const unsavedChanges = editorValue !== activeFileContent + + useEffect(() => { + if (activeFile?.type === "file") { + setEditorValue(activeFile.content) + } + }, [activeFileId, activeFile]) + + useEffect(() => { + if (!isHydrated) return + + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ workspace, expandedFolders, activeFileId }) + ) + }, [workspace, expandedFolders, activeFileId, isHydrated]) + + useEffect(() => { + if (!isHydrated || !activeFile || activeFile.type !== "file") { + return undefined + } + + if (!unsavedChanges) { + if (autosaveTimerRef.current) { + window.clearTimeout(autosaveTimerRef.current) + } + return undefined + } + + setSaveStatus("Autosaving...") + autosaveTimerRef.current = window.setTimeout(() => { + setWorkspace((current) => + updateNodeById(current, activeFileId, (node) => ({ + ...node, + content: editorValue, + })) + ) + setSaveStatus("All changes saved") + }, AUTOSAVE_DELAY_MS) + + return () => { + if (autosaveTimerRef.current) { + window.clearTimeout(autosaveTimerRef.current) + } + } + }, [activeFile, activeFileId, editorValue, isHydrated, unsavedChanges]) + + const fileCount = collectFileIds(workspace).length + const lineNumbers = getLineNumbers(editorValue) + + const toggleFolder = (folderId) => { + setExpandedFolders((current) => + current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId] + ) + } + + const handleManualSave = () => { + if (!activeFile || activeFile.type !== "file") return + + if (autosaveTimerRef.current) { + window.clearTimeout(autosaveTimerRef.current) + } + + setWorkspace((current) => + updateNodeById(current, activeFileId, (node) => ({ + ...node, + content: editorValue, + })) + ) + setSaveStatus("All changes saved") + } + + const createWorkspaceItem = (type) => { + const input = window.prompt(type === "file" ? "New file name" : "New folder name") + const name = input?.trim() + + if (!name) return + + const newNode = + type === "file" + ? { + id: createNodeId("file"), + name, + type: "file", + language: name.split(".").pop() || "text", + content: "", + } + : { + id: createNodeId("folder"), + name, + type: "folder", + children: [], + } + + setWorkspace((current) => insertNode(current, "root", newNode)) + + if (type === "file") { + setActiveFileId(newNode.id) + } else { + setExpandedFolders((current) => [...new Set([...current, "root", newNode.id])]) + } + } + + const sidebarVariants = { + open: { x: 0, opacity: 1 }, + closed: { x: "-100%", opacity: 0 }, + } + + const chatVariants = { + open: { x: 0, opacity: 1 }, + closed: { x: "100%", opacity: 0 }, + } + + return ( +
+ {isMobile && (sidebarOpen || chatOpen) && ( + { + setSidebarOpen(false) + setChatOpen(false) + }} + /> + )} + + + {(sidebarOpen || !isMobile) && ( + +
+
+

Workspace

+

{fileCount} files tracked

+
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+ +
+
+ )} +
+ +
+
+
+ +
+

{activeFile?.name || "No file selected"}

+

{renderStatusLabel(saveStatus, unsavedChanges)}

+
+
+ +
+ +
+ + + +
+
+ +
+
+
+ {workspace.name} / {activeFile?.name || "untitled"} +
+ +
+
+ {lineNumbers.map((lineNumber) => ( +
+ {lineNumber} +
+ ))} +
+ +