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}
+
+ ))}
+
+
+
+
+
+
+ {(chatOpen || !isMobile) && (
+
+
+
+
AI Assistant
+
Workspace-aware draft guidance
+
+
- {/* Editor and Chat Container */}
-
- {/* Code Editor */}
-
-
-
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
-
-
-
- {/* Chat Panel */}
-
- {(chatOpen || !isMobile) && (
-
- {/* Chat Header */}
-
-
AI Assistant
-
-
-
- {/* Chat Messages */}
-
-
-
Hello! I'm your AI assistant for Soroban smart contract development. How can I help you today?
-
-
-
-
Can you help me write a token contract?
-
-
-
-
Absolutely! I'll help you create a basic Soroban token contract. Let me start with the basic structure...
-
-
-
- {/* Chat Input */}
-
-
- setMessage(e.target.value)}
- placeholder="Ask about your code..."
- className="flex-1 bg-[#0D1117] border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[40px]"
- onKeyPress={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- // Handle send message
- setMessage("")
- }
- }}
- />
-
-
-
-
- )}
-
+
+
+
+ Open file: {activeFile?.name || "None"}
+
+
+ I can help refine the active contract, propose tests, or scaffold missing files.
+
+
+
+
+
Add storage-backed token balances and tests.
+
+
+
+
+ Start by extending the contract state, then add a focused test file for mint and transfer flows.
+
+
-
+
+
+
+ setMessage(event.target.value)}
+ placeholder="Ask about the active file..."
+ className="min-h-[40px] flex-1 rounded border border-gray-700 bg-[#0D1117] px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault()
+ setMessage("")
+ }
+ }}
+ />
+
+
+
+
+ )}
+
- )
-}
\ No newline at end of file
+
+
+ )
+}
diff --git a/tests/components/ide-app.test.tsx b/tests/components/ide-app.test.tsx
new file mode 100644
index 0000000..f49c478
--- /dev/null
+++ b/tests/components/ide-app.test.tsx
@@ -0,0 +1,62 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import IDEApp from "@/pages/app/index";
+
+jest.mock("framer-motion", () => ({
+ AnimatePresence: ({ children }: any) => <>{children}>,
+ motion: {
+ aside: ({ children, ...props }: any) =>
,
+ div: ({ children, ...props }: any) =>
{children}
,
+ },
+}));
+
+describe("IDE workspace app", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ jest.restoreAllMocks();
+ });
+
+ it("loads the default workspace and switches active files from the explorer", () => {
+ render(
);
+
+ expect(screen.getByText("stellar-token-starter / contract.rs")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "README.md" }));
+
+ expect(screen.getByText("stellar-token-starter / README.md")).toBeInTheDocument();
+ expect(screen.getByDisplayValue(/Browser-persistent starter workspace/)).toBeInTheDocument();
+ });
+
+ it("autosaves editor content into local storage", async () => {
+ jest.useFakeTimers();
+
+ render(
);
+
+ const editor = screen.getByLabelText("Code editor");
+ fireEvent.change(editor, { target: { value: "fn main() {}\n" } });
+
+ expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(750);
+ });
+
+ await waitFor(() => {
+ const stored = JSON.parse(window.localStorage.getItem("calliope.workspace.v1") || "{}");
+ expect(stored.workspace.children[0].children[0].content).toBe("fn main() {}\n");
+ });
+
+ jest.useRealTimers();
+ });
+
+ it("creates a new file from the workspace actions", () => {
+ jest.spyOn(window, "prompt").mockReturnValue("notes.md");
+
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: /New File/i }));
+
+ expect(screen.getByRole("button", { name: "notes.md" })).toBeInTheDocument();
+ expect(screen.getByText("stellar-token-starter / notes.md")).toBeInTheDocument();
+ });
+});