IndexedDB layer for SWITCH

cgen-e8367dc43f144fefaa618c07fa07b3a7
This commit is contained in:
Builder.io 2025-09-17 09:53:31 +00:00
parent 700215ed3f
commit 301bf98891
6 changed files with 411 additions and 9 deletions

View file

@ -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 (
<div className="switch-filetree" role="tree">
{tree.map((n) => (
<TreeNode key={n.path} node={n} onOpen={onOpen} depth={0} />
))}
</div>
);
}
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 (
<div>
<div className="switch-tree-row" style={{ paddingLeft }} onClick={() => setOpen(!open)}>
<span className="switch-tree-chevron">{open ? "▾" : "▸"}</span>
<span className="switch-tree-dir">{basename(node.path)}</span>
</div>
{open && node.children?.map((c) => (
<TreeNode key={c.path} node={c} onOpen={onOpen} depth={depth + 1} />
))}
</div>
);
}
return (
<div className="switch-tree-row" style={{ paddingLeft }} role="treeitem" onClick={() => onOpen(node.path)}>
<span className="switch-tree-file">{basename(node.path)}</span>
</div>
);
}
function basename(p: string) {
const idx = p.lastIndexOf("/");
return idx >= 0 ? p.slice(idx + 1) : p;
}

View file

@ -1,9 +1,87 @@
import * as React from "react"; import * as React from "react";
import { ControlledMonacoEditor } from "../../components/monaco/MonacoEditor"; import { ControlledMonacoEditor } from "../../components/monaco/MonacoEditor";
import "../../switch.scss"; 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() { export function SwitchPage() {
const [value, setValue] = React.useState<string>(`// Welcome to SWITCH\n// Start coding, create repos and manage issues here.\n`); const [repos, setRepos] = React.useState<RepoRecord[]>([]);
const [activeRepo, setActiveRepo] = React.useState<RepoRecord | undefined>();
const [tree, setTree] = React.useState<{ path: string; isDir: boolean; children?: any[] }[]>([]);
const [openFile, setOpenFile] = React.useState<OpenFile | undefined>();
const [editorValue, setEditorValue] = React.useState<string>("// 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<string, any>();
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 ( return (
<div className="switch-shell"> <div className="switch-shell">
<div className="switch-activity-bar" aria-label="Activity Bar"> <div className="switch-activity-bar" aria-label="Activity Bar">
@ -15,34 +93,89 @@ export function SwitchPage() {
<div className="switch-main"> <div className="switch-main">
<aside className="switch-sidebar" aria-label="Sidebar"> <aside className="switch-sidebar" aria-label="Sidebar">
<div className="switch-pane"> <div className="switch-pane">
<div className="switch-pane-header">Explorer</div> <div className="switch-pane-header">Repositories</div>
<div className="switch-pane-body"> <div className="switch-pane-body">
<button className="switch-secondary-btn">Open Folder</button> <button className="switch-secondary-btn" onClick={onOpenFolder}>Open Folder</button>
<button className="switch-secondary-btn">New Repository</button> <div>
{repos.map((r) => (
<div key={r.id}>
<label>
<input type="radio" name="repo" onChange={() => onSelectRepo(r.id)} checked={activeRepo?.id === r.id} /> {r.name}
</label>
</div>
))}
</div>
</div> </div>
</div> </div>
<div className="switch-pane"> <div className="switch-pane">
<div className="switch-pane-header">Repositories</div> <div className="switch-pane-header">Explorer</div>
<div className="switch-pane-body"> <div className="switch-pane-body">
<div className="switch-empty">No repositories yet</div> {tree.length === 0 ? (
<div className="switch-empty">No files</div>
) : (
<FileTree tree={tree} onOpen={onOpenFile} />
)}
</div> </div>
</div> </div>
</aside> </aside>
<section className="switch-editor" aria-label="Editor"> <section className="switch-editor" aria-label="Editor">
<div className="switch-tabbar"> <div className="switch-tabbar">
<div className="switch-tab active">welcome.ts</div> <div className="switch-tab active">{openFile?.path || "welcome.txt"}</div>
</div> </div>
<div className="switch-editor-inner"> <div className="switch-editor-inner">
<ControlledMonacoEditor value={value} onDidValueChange={setValue} language="typescript" theme="vs-dark" /> <ControlledMonacoEditor value={editorValue} onDidValueChange={setEditorValue} language={openFile?.language || "plaintext"} theme="vs-dark" />
</div> </div>
</section> </section>
</div> </div>
<footer className="switch-statusbar" aria-label="Status Bar"> <footer className="switch-statusbar" aria-label="Status Bar">
<div className="switch-status-item">SWITCH</div> <div className="switch-status-item">SWITCH</div>
<div className="switch-status-item">TypeScript</div> <div className="switch-status-item">{openFile?.language || "Plain Text"}</div>
<div className="switch-status-item">UTF-8</div> <div className="switch-status-item">UTF-8</div>
<div className="switch-status-item">LF</div> <div className="switch-status-item">LF</div>
</footer> </footer>
</div> </div>
); );
} }
async function getFileByPath(dir: FileSystemDirectoryHandle, path: string): Promise<File | undefined> {
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";
}
}

View file

@ -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<IDBDatabase> {
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<T>(storeNames: StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise<T>): Promise<T> {
const db = await openDB();
return new Promise<T>((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<T>(store: StoreName, value: T): Promise<void> {
await tx([store], "readwrite", async (t) => {
await requestAsPromise<void>(t.objectStore(store).put(value as any));
return undefined as any;
});
}
export async function get<T>(store: StoreName, key: IDBValidKey): Promise<T | undefined> {
return tx([store], "readonly", async (t) => {
return requestAsPromise<T | undefined>(t.objectStore(store).get(key));
});
}
export async function del(store: StoreName, key: IDBValidKey): Promise<void> {
await tx([store], "readwrite", async (t) => {
await requestAsPromise<void>(t.objectStore(store).delete(key));
return undefined as any;
});
}
export async function getAllByIndex<T>(store: StoreName, index: string, query: IDBValidKey | IDBKeyRange): Promise<T[]> {
return tx([store], "readonly", async (t) => {
const idx = t.objectStore(store).index(index);
return requestAsPromise<T[]>(idx.getAll(query));
});
}
export async function getAll<T>(store: StoreName): Promise<T[]> {
return tx([store], "readonly", async (t) => {
return requestAsPromise<T[]>(t.objectStore(store).getAll());
});
}
function requestAsPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
});
}

View file

@ -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<FileSystemDirectoryHandle | undefined> {
const rec = await get<FsHandleRecord>("fsHandles", id);
return rec?.handle;
}
export async function ensureReadPerm(dir: FileSystemDirectoryHandle): Promise<boolean> {
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 };
}
}
}

View file

@ -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<RepoRecord> {
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<RepoRecord[]> {
return getAll<RepoRecord>("repos");
}
export async function deleteRepository(repoId: string): Promise<void> {
// naive cascade delete
const branches = await getAllByIndex<BranchRecord>("branches", "by_repo", repoId);
for (const b of branches) await del("branches", b.id);
const commits = await getAllByIndex<CommitRecord>("commits", "by_repo", repoId);
for (const c of commits) await del("commits", c.id);
const issues = await getAllByIndex<IssueRecord>("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<BranchRecord> {
const existing = await getAllByIndex<BranchRecord>("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<BranchRecord[]> {
return getAllByIndex<BranchRecord>("branches", "by_repo", repoId);
}
export async function commit(repoId: string, message: string, parentIds: string[] = []): Promise<CommitRecord> {
const c: CommitRecord = { id: nanoid(), repoId, message, parentIds, timestamp: Date.now() };
await put("commits", c);
return c;
}
export async function getRepo(repoId: string): Promise<RepoRecord | undefined> {
return get<RepoRecord>("repos", repoId);
}

View file

@ -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;
}