From 301bf98891b5fe86b6b806f31f5ae1221c36cf9f Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 17 Sep 2025 09:53:31 +0000 Subject: [PATCH] IndexedDB layer for SWITCH cgen-e8367dc43f144fefaa618c07fa07b3a7 --- website/src/website/pages/switch/FileTree.tsx | 41 +++++ .../src/website/pages/switch/SwitchPage.tsx | 151 ++++++++++++++++-- website/src/website/switch/db.ts | 138 ++++++++++++++++ website/src/website/switch/fs.ts | 36 +++++ website/src/website/switch/models.ts | 47 ++++++ website/src/website/switch/uid.ts | 7 + 6 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 website/src/website/pages/switch/FileTree.tsx create mode 100644 website/src/website/switch/db.ts create mode 100644 website/src/website/switch/fs.ts create mode 100644 website/src/website/switch/models.ts create mode 100644 website/src/website/switch/uid.ts diff --git a/website/src/website/pages/switch/FileTree.tsx b/website/src/website/pages/switch/FileTree.tsx new file mode 100644 index 00000000..71f93d4b --- /dev/null +++ b/website/src/website/pages/switch/FileTree.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +export interface FileNode { path: string; isDir: boolean; children?: FileNode[] } + +export function FileTree({ tree, onOpen }: { tree: FileNode[]; onOpen: (path: string) => void }) { + return ( +
+ {tree.map((n) => ( + + ))} +
+ ); +} + +function TreeNode({ node, onOpen, depth }: { node: FileNode; onOpen: (path: string) => void; depth: number }) { + const [open, setOpen] = React.useState(true); + const paddingLeft = 8 + depth * 12; + if (node.isDir) { + return ( +
+
setOpen(!open)}> + {open ? "▾" : "▸"} + {basename(node.path)} +
+ {open && node.children?.map((c) => ( + + ))} +
+ ); + } + return ( +
onOpen(node.path)}> + {basename(node.path)} +
+ ); +} + +function basename(p: string) { + const idx = p.lastIndexOf("/"); + return idx >= 0 ? p.slice(idx + 1) : p; +} diff --git a/website/src/website/pages/switch/SwitchPage.tsx b/website/src/website/pages/switch/SwitchPage.tsx index db42be1d..3d389e30 100644 --- a/website/src/website/pages/switch/SwitchPage.tsx +++ b/website/src/website/pages/switch/SwitchPage.tsx @@ -1,9 +1,87 @@ import * as React from "react"; import { ControlledMonacoEditor } from "../../components/monaco/MonacoEditor"; import "../../switch.scss"; +import { createRepository, listRepositories } from "../../switch/models"; +import { pickDirectory, getDirectoryHandle, ensureReadPerm, walk } from "../../switch/fs"; +import type { RepoRecord } from "../../switch/db"; +import { FileTree } from "./FileTree"; + +interface OpenFile { path: string; content: string; language: string } export function SwitchPage() { - const [value, setValue] = React.useState(`// Welcome to SWITCH\n// Start coding, create repos and manage issues here.\n`); + const [repos, setRepos] = React.useState([]); + const [activeRepo, setActiveRepo] = React.useState(); + const [tree, setTree] = React.useState<{ path: string; isDir: boolean; children?: any[] }[]>([]); + const [openFile, setOpenFile] = React.useState(); + const [editorValue, setEditorValue] = React.useState("// SWITCH: Open a folder to get started\n"); + + React.useEffect(() => { + refreshRepos(); + }, []); + + async function refreshRepos() { + const list = await listRepositories(); + setRepos(list); + } + + async function onOpenFolder() { + const { id: fsId, handle } = await pickDirectory(); + if (!(await ensureReadPerm(handle))) return; + const repo = await createRepository(handle.name || "workspace", fsId); + await refreshRepos(); + setActiveRepo(repo); + await loadTree(repo); + } + + async function loadTree(repo: RepoRecord) { + if (!repo.fsHandleId) return; + const dir = await getDirectoryHandle(repo.fsHandleId); + if (!dir) return; + if (!(await ensureReadPerm(dir))) return; + + // flatten files then build a simple tree + const files: { path: string }[] = []; + for await (const f of walk(dir)) files.push({ path: f.path }); + const root: any = {}; + for (const f of files) { + const parts = f.path.split("/"); + let cur = root; + for (let i = 0; i < parts.length; i++) { + const key = parts.slice(0, i + 1).join("/"); + const isDir = i < parts.length - 1; + cur.children = cur.children || new Map(); + if (!cur.children.has(key)) cur.children.set(key, { path: key, isDir, children: undefined }); + cur = cur.children.get(key); + } + } + const toArray = (node: any): any[] => { + if (!node.children) return []; + const arr = Array.from(node.children.values()); + for (const n of arr) if (n.isDir) n.children = toArray(n); + arr.sort((a, b) => (a.isDir === b.isDir ? a.path.localeCompare(b.path) : a.isDir ? -1 : 1)); + return arr; + }; + setTree(toArray(root)); + } + + async function onSelectRepo(repoId: string) { + const repo = repos.find((r) => r.id === repoId); + setActiveRepo(repo); + if (repo) await loadTree(repo); + } + + async function onOpenFile(path: string) { + if (!activeRepo?.fsHandleId) return; + const dir = await getDirectoryHandle(activeRepo.fsHandleId); + if (!dir) return; + if (!(await ensureReadPerm(dir))) return; + const file = await getFileByPath(dir, path); + if (!file) return; + const content = await file.text(); + setOpenFile({ path, content, language: languageFromPath(path) }); + setEditorValue(content); + } + return (
@@ -15,34 +93,89 @@ export function SwitchPage() {
-
welcome.ts
+
{openFile?.path || "welcome.txt"}
- +
SWITCH
-
TypeScript
+
{openFile?.language || "Plain Text"}
UTF-8
LF
); } + +async function getFileByPath(dir: FileSystemDirectoryHandle, path: string): Promise { + const parts = path.split("/"); + let cur: FileSystemDirectoryHandle = dir; + for (let i = 0; i < parts.length; i++) { + const name = parts[i]; + if (i === parts.length - 1) { + const fh = await (cur as any).getFileHandle(name).catch(() => undefined) as FileSystemFileHandle | undefined; + return fh ? fh.getFile() : undefined; + } else { + cur = await (cur as any).getDirectoryHandle(name).catch(() => undefined); + if (!cur) return undefined as any; + } + } + return undefined as any; +} + +function languageFromPath(path: string): string { + const ext = path.split(".").pop()?.toLowerCase(); + switch (ext) { + case "ts": return "typescript"; + case "tsx": return "typescript"; + case "js": return "javascript"; + case "jsx": return "javascript"; + case "json": return "json"; + case "css": return "css"; + case "scss": return "scss"; + case "html": return "html"; + case "md": return "markdown"; + case "py": return "python"; + case "rb": return "ruby"; + case "go": return "go"; + case "rs": return "rust"; + case "java": return "java"; + case "cs": return "csharp"; + case "cpp": return "cpp"; + case "yml": + case "yaml": return "yaml"; + case "xml": return "xml"; + case "sql": return "sql"; + default: return "plaintext"; + } +} diff --git a/website/src/website/switch/db.ts b/website/src/website/switch/db.ts new file mode 100644 index 00000000..e59477c6 --- /dev/null +++ b/website/src/website/switch/db.ts @@ -0,0 +1,138 @@ +export type StoreName = "repos" | "branches" | "commits" | "issues" | "fsHandles" | "settings"; + +const DB_NAME = "switch-db"; +const DB_VERSION = 1; + +export interface RepoRecord { + id: string; + name: string; + createdAt: number; + defaultBranch: string; + fsHandleId?: string; +} + +export interface BranchRecord { + id: string; + repoId: string; + name: string; + headCommitId?: string; +} + +export interface CommitRecord { + id: string; + repoId: string; + message: string; + parentIds: string[]; + timestamp: number; +} + +export interface IssueRecord { + id: string; + repoId: string; + title: string; + body: string; + labels: string[]; + status: "open" | "closed"; + createdAt: number; + updatedAt: number; + branchId?: string; + filePath?: string; +} + +export interface FsHandleRecord { + id: string; + handle: FileSystemDirectoryHandle; +} + +export async function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("repos")) { + const store = db.createObjectStore("repos", { keyPath: "id" }); + store.createIndex("by_name", "name", { unique: false }); + } + if (!db.objectStoreNames.contains("branches")) { + const store = db.createObjectStore("branches", { keyPath: "id" }); + store.createIndex("by_repo", "repoId", { unique: false }); + store.createIndex("by_repo_name", ["repoId", "name"], { unique: true }); + } + if (!db.objectStoreNames.contains("commits")) { + const store = db.createObjectStore("commits", { keyPath: "id" }); + store.createIndex("by_repo", "repoId", { unique: false }); + } + if (!db.objectStoreNames.contains("issues")) { + const store = db.createObjectStore("issues", { keyPath: "id" }); + store.createIndex("by_repo", "repoId", { unique: false }); + } + if (!db.objectStoreNames.contains("fsHandles")) { + db.createObjectStore("fsHandles", { keyPath: "id" }); + } + if (!db.objectStoreNames.contains("settings")) { + db.createObjectStore("settings", { keyPath: "id" }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function tx(storeNames: StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeNames, mode); + const done = async () => { + try { + const r = await fn(transaction); + resolve(r); + } catch (e) { + reject(e); + } + }; + transaction.oncomplete = () => db.close(); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + done(); + }); +} + +export async function put(store: StoreName, value: T): Promise { + await tx([store], "readwrite", async (t) => { + await requestAsPromise(t.objectStore(store).put(value as any)); + return undefined as any; + }); +} + +export async function get(store: StoreName, key: IDBValidKey): Promise { + return tx([store], "readonly", async (t) => { + return requestAsPromise(t.objectStore(store).get(key)); + }); +} + +export async function del(store: StoreName, key: IDBValidKey): Promise { + await tx([store], "readwrite", async (t) => { + await requestAsPromise(t.objectStore(store).delete(key)); + return undefined as any; + }); +} + +export async function getAllByIndex(store: StoreName, index: string, query: IDBValidKey | IDBKeyRange): Promise { + return tx([store], "readonly", async (t) => { + const idx = t.objectStore(store).index(index); + return requestAsPromise(idx.getAll(query)); + }); +} + +export async function getAll(store: StoreName): Promise { + return tx([store], "readonly", async (t) => { + return requestAsPromise(t.objectStore(store).getAll()); + }); +} + +function requestAsPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(req.error); + }); +} diff --git a/website/src/website/switch/fs.ts b/website/src/website/switch/fs.ts new file mode 100644 index 00000000..3760411c --- /dev/null +++ b/website/src/website/switch/fs.ts @@ -0,0 +1,36 @@ +import { put, get, type FsHandleRecord } from "./db"; +import { nanoid } from "./uid"; + +export async function pickDirectory(): Promise<{ id: string; handle: FileSystemDirectoryHandle }> { + // @ts-ignore + const handle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker(); + const id = nanoid(); + const rec: FsHandleRecord = { id, handle }; + await put("fsHandles", rec); + return { id, handle }; +} + +export async function getDirectoryHandle(id: string): Promise { + const rec = await get("fsHandles", id); + return rec?.handle; +} + +export async function ensureReadPerm(dir: FileSystemDirectoryHandle): Promise { + const perm = await (dir as any).queryPermission?.({ mode: "read" }); + if (perm === "granted") return true; + const req = await (dir as any).requestPermission?.({ mode: "read" }); + return req === "granted"; +} + +export async function* walk(dir: FileSystemDirectoryHandle, pathPrefix = ""): AsyncGenerator<{ path: string; file: File }>{ + // @ts-ignore + for await (const [name, entry] of (dir as any).entries()) { + const p = pathPrefix ? `${pathPrefix}/${name}` : name; + if (entry.kind === "directory") { + yield* walk(entry as FileSystemDirectoryHandle, p); + } else { + const file = await (entry as FileSystemFileHandle).getFile(); + yield { path: p, file }; + } + } +} diff --git a/website/src/website/switch/models.ts b/website/src/website/switch/models.ts new file mode 100644 index 00000000..7e20b77e --- /dev/null +++ b/website/src/website/switch/models.ts @@ -0,0 +1,47 @@ +import { put, get, getAll, getAllByIndex, del, type RepoRecord, type BranchRecord, type CommitRecord, type IssueRecord } from "./db"; +import { nanoid } from "./uid"; + +export async function createRepository(name: string, fsHandleId?: string): Promise { + const repo: RepoRecord = { id: nanoid(), name, createdAt: Date.now(), defaultBranch: "main", fsHandleId }; + await put("repos", repo); + const main: BranchRecord = { id: nanoid(), repoId: repo.id, name: repo.defaultBranch, headCommitId: undefined }; + await put("branches", main); + return repo; +} + +export async function listRepositories(): Promise { + return getAll("repos"); +} + +export async function deleteRepository(repoId: string): Promise { + // naive cascade delete + const branches = await getAllByIndex("branches", "by_repo", repoId); + for (const b of branches) await del("branches", b.id); + const commits = await getAllByIndex("commits", "by_repo", repoId); + for (const c of commits) await del("commits", c.id); + const issues = await getAllByIndex("issues", "by_repo", repoId); + for (const i of issues) await del("issues", i.id); + await del("repos", repoId); +} + +export async function createBranch(repoId: string, name: string, fromCommitId?: string): Promise { + const existing = await getAllByIndex("branches", "by_repo_name", [repoId, name]).catch(() => []); + if (existing && existing.length) throw new Error("Branch already exists"); + const branch: BranchRecord = { id: nanoid(), repoId, name, headCommitId: fromCommitId }; + await put("branches", branch); + return branch; +} + +export async function listBranches(repoId: string): Promise { + return getAllByIndex("branches", "by_repo", repoId); +} + +export async function commit(repoId: string, message: string, parentIds: string[] = []): Promise { + const c: CommitRecord = { id: nanoid(), repoId, message, parentIds, timestamp: Date.now() }; + await put("commits", c); + return c; +} + +export async function getRepo(repoId: string): Promise { + return get("repos", repoId); +} diff --git a/website/src/website/switch/uid.ts b/website/src/website/switch/uid.ts new file mode 100644 index 00000000..08c56318 --- /dev/null +++ b/website/src/website/switch/uid.ts @@ -0,0 +1,7 @@ +export function nanoid(size = 21): string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"; + let id = ""; + const bytes = crypto.getRandomValues(new Uint8Array(size)); + for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63]; + return id; +}