mirror of
https://github.com/microsoft/monaco-editor.git
synced 2025-12-22 17:25:39 +01:00
IndexedDB layer for SWITCH
cgen-e8367dc43f144fefaa618c07fa07b3a7
This commit is contained in:
parent
700215ed3f
commit
301bf98891
6 changed files with 411 additions and 9 deletions
41
website/src/website/pages/switch/FileTree.tsx
Normal file
41
website/src/website/pages/switch/FileTree.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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<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 (
|
||||
<div className="switch-shell">
|
||||
<div className="switch-activity-bar" aria-label="Activity Bar">
|
||||
|
|
@ -15,34 +93,89 @@ export function SwitchPage() {
|
|||
<div className="switch-main">
|
||||
<aside className="switch-sidebar" aria-label="Sidebar">
|
||||
<div className="switch-pane">
|
||||
<div className="switch-pane-header">Explorer</div>
|
||||
<div className="switch-pane-header">Repositories</div>
|
||||
<div className="switch-pane-body">
|
||||
<button className="switch-secondary-btn">Open Folder</button>
|
||||
<button className="switch-secondary-btn">New Repository</button>
|
||||
<button className="switch-secondary-btn" onClick={onOpenFolder}>Open Folder</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 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-empty">No repositories yet</div>
|
||||
{tree.length === 0 ? (
|
||||
<div className="switch-empty">No files</div>
|
||||
) : (
|
||||
<FileTree tree={tree} onOpen={onOpenFile} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="switch-editor" aria-label="Editor">
|
||||
<div className="switch-tabbar">
|
||||
<div className="switch-tab active">welcome.ts</div>
|
||||
<div className="switch-tab active">{openFile?.path || "welcome.txt"}</div>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="switch-statusbar" aria-label="Status Bar">
|
||||
<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">LF</div>
|
||||
</footer>
|
||||
</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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
website/src/website/switch/db.ts
Normal file
138
website/src/website/switch/db.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
36
website/src/website/switch/fs.ts
Normal file
36
website/src/website/switch/fs.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
47
website/src/website/switch/models.ts
Normal file
47
website/src/website/switch/models.ts
Normal 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);
|
||||
}
|
||||
7
website/src/website/switch/uid.ts
Normal file
7
website/src/website/switch/uid.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue