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"}
-
+
);
}
+
+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;
+}