Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/components/dashboard/CreateScapeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,37 @@ export function CreateScapeDialog() {

if (!templateConfig) throw new Error("Template not found")

// 1. Create Scape
const scapeId = await db.scapes.add({
// Create Scape (Hybrid: New ones use UUIDs)
const newId = crypto.randomUUID()

await db.scapes.add({
id: newId,
name: name.trim(),
environment: selectedEnv,
template: selectedTemplate,
template: templateConfig.id,
source: source,
syncStatus: "offline",
createdAt: new Date(),
updatedAt: new Date(),
thumbnail: undefined,
dependencies: [],
})

// 2. Create Files from Template
await db.files.bulkAdd(
templateConfig.files.map((file) => ({
scapeId: scapeId as number,
name: file.name,
content: file.content,
language: file.language,
// Add default files from template
if (templateConfig.files) {
const filesToAdd = templateConfig.files.map((f) => ({
scapeId: newId,
name: f.name,
content: f.content,
language: f.language || "plaintext",
}))
)
await db.files.bulkAdd(filesToAdd)
}

// 3. Navigate
setOpen(false)
navigate("/scape/" + scapeId)
setName("")
// Redirect using the new ID (string)
navigate(`/scape/${newId}`)

// Reset form
setName("")
Expand Down
84 changes: 75 additions & 9 deletions src/components/editor/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ interface FileExplorerProps {
activeFileId?: string
onFileSelect: (file: ScapeFile) => void
onToggleFolder: (path: string) => void
onCreateFile: (name: string) => void
onCreateFile: (
name: string,
type?: string,
content?: string | Blob | ArrayBuffer | Uint8Array
) => void
onCreateFolder: (name: string) => void
onDelete: (path: string) => void
onMove: (oldPath: string, newPath: string) => void
Expand Down Expand Up @@ -98,12 +102,17 @@ export function FileExplorer({
return
}

// Only allow dropping into folders
// If over a folder, target that folder
if (node.type === "folder") {
setDragOverNodeId(node.path)
e.dataTransfer.dropEffect = "move"
} else {
setDragOverNodeId(null)
// If over a file, target its parent folder
const parentPath = node.path.includes("/")
? node.path.substring(0, node.path.lastIndexOf("/"))
: "root"
setDragOverNodeId(parentPath)
e.dataTransfer.dropEffect = "move"
}
}

Expand All @@ -112,18 +121,75 @@ export function FileExplorer({
setDragOverNodeId(null)
}

const handleDrop = (e: React.DragEvent, targetNode: FileNode | null) => {
const handleDrop = async (e: React.DragEvent, targetNode: FileNode | null) => {
e.preventDefault()
e.stopPropagation()
setDragOverNodeId(null)

const sourcePath = e.dataTransfer.getData("text/plain")
// Determine Target Path
let targetPath = ""
if (targetNode) {
if (targetNode.type === "folder") {
targetPath = targetNode.path
} else {
// Dropped on a file -> Goes to same folder
targetPath = targetNode.path.includes("/")
? targetNode.path.substring(0, targetNode.path.lastIndexOf("/"))
: ""
}
}

// Strict Check: Can only drop into Folders or Root
if (targetNode && targetNode.type !== "folder") return
// Check for Native Files first
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const filesList = Array.from(e.dataTransfer.files)

for (const file of filesList) {
// 1. Size Check (10MB)
if (file.size > 10 * 1024 * 1024) {
alert(`File ${file.name} is too large (Max 10MB).`)
continue
}

// 2. Determine Type & Read Strategy
const isText =
file.type.startsWith("text/") ||
file.name.endsWith(".json") ||
file.name.endsWith(".js") ||
file.name.endsWith(".ts") ||
file.name.endsWith(".py") ||
file.name.endsWith(".md")

let content: string | ArrayBuffer
let fileType = "binary"

if (isText) {
content = await file.text()
// Infer simpler type for code editor support
if (file.name.endsWith(".html")) fileType = "html"
else if (file.name.endsWith(".css")) fileType = "css"
else if (file.name.endsWith(".json")) fileType = "json"
else if (file.name.endsWith(".md")) fileType = "markdown"
else if (file.name.endsWith(".py")) fileType = "python"
else if (file.name.endsWith(".js") || file.name.endsWith(".ts")) fileType = "javascript"
else fileType = "binary" // Fallback if unsure, but we read as text
} else {
// Binary (Images, WASM, etc.)
content = await file.arrayBuffer()
if (file.type.startsWith("image/")) fileType = "image"
else fileType = "binary"
}

// 3. Create File
const newPath = targetPath ? `${targetPath}/${file.name}` : file.name
onCreateFile(newPath, fileType, content)
}
return
}

// --- Internal App DnD (Moving Files) ---
const sourcePath = e.dataTransfer.getData("text/plain")

// Target path is empty string for root, or node.path
const targetPath = targetNode ? targetNode.path : ""
// Strict Check removed: Can drop on files (to resolve parent)

if (!sourcePath) return

Expand Down
20 changes: 19 additions & 1 deletion src/components/editor/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface TerminalPaneProps {
onClose?: () => void
isCollapsed?: boolean
files?: ScapeFile[]
scapeId?: string | number
scapeName?: string
onDeleteFile?: (path: string) => void
outputLogs?: LogEntry[]
Expand All @@ -38,6 +39,7 @@ export function TerminalPane({
isCollapsed = false,
files = [],
scapeName = "project",
scapeId,
onDeleteFile,
outputLogs = [],
onExecCommand,
Expand Down Expand Up @@ -365,7 +367,23 @@ export function TerminalPane({
<>
<span className="text-green-500">➜</span>
<span className="whitespace-nowrap text-blue-500">
~/{scapeName?.replace(/\s+/g, "-").toLowerCase() || "project"}
{(() => {
const nameSlug = (scapeName || "project")
.trim()
.replace(/\s+/g, "-")
.toLowerCase()
let idSuffix = ""
if (scapeId) {
const idStr = String(scapeId)
// if it looks like a UUID (long string), shorten it
if (idStr.length > 10) {
idSuffix = `-${idStr.slice(0, 8)}`
} else {
idSuffix = `-${idStr}`
}
}
return `~/${nameSlug}${idSuffix}`
})()}
</span>
</>
)}
Expand Down
15 changes: 10 additions & 5 deletions src/hooks/useFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useLiveQuery } from "dexie-react-hooks"
import { db } from "@/lib/db"
import type { ScapeFile, FileType } from "@/types/file"

export function useFileSystem(scapeId: number) {
export function useFileSystem(scapeId: string | number) {
const [files, setFiles] = useState<ScapeFile[]>([])
const [isInitialized, setIsInitialized] = useState(false)

Expand Down Expand Up @@ -72,14 +72,15 @@ export function useFileSystem(scapeId: number) {
const saveTimeoutRef = useRef<Record<number, NodeJS.Timeout>>({})

const saveContentToDb = useCallback(
(fileId: number, content: string) => {
(fileId: number, content: string | Blob | ArrayBuffer | Uint8Array) => {
if (saveTimeoutRef.current[fileId]) {
clearTimeout(saveTimeoutRef.current[fileId])
}

saveTimeoutRef.current[fileId] = setTimeout(async () => {
await db.files.update(fileId, { content })
await db.scapes.update(scapeId, { updatedAt: new Date() })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.scapes.update(scapeId as any, { updatedAt: new Date() }) // Cast scapeId for update
delete saveTimeoutRef.current[fileId]
}, 500) // 500ms Debounce for DB writes
},
Expand All @@ -89,7 +90,11 @@ export function useFileSystem(scapeId: number) {
// --- Actions ---

const createFile = useCallback(
async (name: string, language: FileType, content: string = "") => {
async (
name: string,
language: FileType,
content: string | Blob | ArrayBuffer | Uint8Array = ""
) => {
// Optimistic: We can't really do optimistic create easily because we need the ID from DB.
// So we await DB. The `useEffect` above will handle adding it to state when it detects structure change.
await db.files.add({
Expand All @@ -103,7 +108,7 @@ export function useFileSystem(scapeId: number) {
)

const updateFile = useCallback(
(name: string, content: string) => {
(name: string, content: string | Blob | ArrayBuffer | Uint8Array) => {
setFiles((prev) =>
prev.map((f) => {
if (f.name === name) {
Expand Down
16 changes: 12 additions & 4 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Dexie, { type EntityTable } from "dexie"
import type { EnvironmentId } from "@/types/environment"

export interface Scape {
id: number
id: string | number
name: string
environment: EnvironmentId
template: string // The ID of the template used (e.g. 'blank', 'threejs')
Expand All @@ -18,9 +18,9 @@ export interface Scape {

export interface File {
id: number
scapeId: number
scapeId: string | number
name: string
content: string
content: string | Blob | ArrayBuffer | Uint8Array
language: string
}

Expand Down Expand Up @@ -94,8 +94,16 @@ db.version(5)
})
})

// Version 6: Hybrid UUID Migration
// We KEEP '++' to avoid "UpgradeError: Not yet support for changing primary key".
// IndexedDB allows string keys in auto-increment tables if provided manually.
db.version(6).stores({
scapes: "++id, name, environment, source, createdAt, updatedAt, *dependencies",
files: "++id, scapeId, name, [scapeId+name]",
})

// Helper to delete a scape and its files transactionally
export async function deleteScape(id: number) {
export async function deleteScape(id: string | number) {
await db.transaction("rw", db.scapes, db.files, async () => {
await db.files.where("scapeId").equals(id).delete()
await db.scapes.delete(id)
Expand Down
Loading