Prettier format pending files

This commit is contained in:
Builder.io 2025-09-18 05:10:58 +00:00
parent c3b3d72037
commit 34b0d92e8a
11 changed files with 1330 additions and 726 deletions

View file

@ -1,14 +1,23 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
const { app, BrowserWindow } = require("electron");
const path = require("path");
function createWindow() {
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { nodeIntegration: false, contextIsolation: true } });
const startUrl = process.env.SWITCH_URL || 'http://localhost:8080/switch.html';
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: { nodeIntegration: false, contextIsolation: true },
});
const startUrl =
process.env.SWITCH_URL || "http://localhost:8080/switch.html";
win.loadURL(startUrl);
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });

View file

@ -1,26 +1,54 @@
const CACHE = 'switch-cache-v1';
const CACHE = "switch-cache-v1";
const ASSETS = [
'/',
'/index.html',
'/playground.html',
'/monarch.html',
'/switch.html'
"/",
"/index.html",
"/playground.html",
"/monarch.html",
"/switch.html",
];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS)).then(()=>self.skipWaiting()));
self.addEventListener("install", (e) => {
e.waitUntil(
caches
.open(CACHE)
.then((c) => c.addAll(ASSETS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));
self.addEventListener("activate", (e) => {
e.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (url.origin === location.origin) {
e.respondWith(caches.match(e.request).then(r=> r || fetch(e.request).then(resp=>{
if (e.request.method==='GET' && resp.ok && resp.type==='basic') {
e.respondWith(
caches.match(e.request).then(
(r) =>
r ||
fetch(e.request)
.then((resp) => {
if (
e.request.method === "GET" &&
resp.ok &&
resp.type === "basic"
) {
const clone = resp.clone();
caches.open(CACHE).then(c=>c.put(e.request, clone));
caches
.open(CACHE)
.then((c) => c.put(e.request, clone));
}
return resp;
}).catch(()=>caches.match('/index.html'))));
})
.catch(() => caches.match("/index.html"))
)
);
}
});

View file

@ -93,7 +93,10 @@ async function _loadMonaco(setup: IMonacoSetup): Promise<typeof monaco> {
);
} catch (e) {
// If loading optional language contributions fails, still resolve the editor to keep app functional.
console.error('Failed to load Monaco language contributions, continuing without them.', e);
console.error(
"Failed to load Monaco language contributions, continuing without them.",
e
);
res(monaco);
}
});

View file

@ -9,8 +9,8 @@ elem.className = "root";
document.body.append(elem);
ReactDOM.render(<App />, elem);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}

View file

@ -1,42 +1,83 @@
import * as React from "react";
import { ControlledMonacoEditor } from "../../components/monaco/MonacoEditor";
import "../../switch.scss";
import { createRepository, listRepositories, listBranches, createBranch } from "../../switch/models";
import { pickDirectory, getDirectoryHandle, ensureReadPerm, walk } from "../../switch/fs";
import type { RepoRecord, BranchRecord, CommitRecord, IssueRecord } from "../../switch/db";
import {
createRepository,
listRepositories,
listBranches,
createBranch,
} from "../../switch/models";
import {
pickDirectory,
getDirectoryHandle,
ensureReadPerm,
walk,
} from "../../switch/fs";
import type {
RepoRecord,
BranchRecord,
CommitRecord,
IssueRecord,
} from "../../switch/db";
import { FileTree } from "./FileTree";
import { getCurrentBranchId, setCurrentBranchId, commitOnBranch, listCommitsReachable, listIssues, createIssue } from "../../switch/logic";
import {
getCurrentBranchId,
setCurrentBranchId,
commitOnBranch,
listCommitsReachable,
listIssues,
createIssue,
} from "../../switch/logic";
interface OpenFile { path: string; content: string; language: string }
interface OpenFile {
path: string;
content: string;
language: string;
}
export function SwitchPage() {
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 [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");
const [editorValue, setEditorValue] = React.useState<string>(
"// SWITCH: Open a folder to get started\n"
);
const [branches, setBranches] = React.useState<BranchRecord[]>([]);
const [currentBranchId, setCurrentBranchIdState] = React.useState<string | undefined>(undefined);
const [currentBranchId, setCurrentBranchIdState] = React.useState<
string | undefined
>(undefined);
const [commitMsg, setCommitMsg] = React.useState("");
const [history, setHistory] = React.useState<CommitRecord[]>([]);
const [issues, setIssues] = React.useState<IssueRecord[]>([]);
const [newIssueTitle, setNewIssueTitle] = React.useState("");
const [selectedIssueId, setSelectedIssueId] = React.useState<string | undefined>(undefined);
const selectedIssue = issues.find(i=>i.id===selectedIssueId);
const [selectedIssueId, setSelectedIssueId] = React.useState<
string | undefined
>(undefined);
const selectedIssue = issues.find((i) => i.id === selectedIssueId);
const [issueTitle, setIssueTitle] = React.useState("");
const [issueBody, setIssueBody] = React.useState("");
const [issueLabels, setIssueLabels] = React.useState<string>("");
const [issueStatus, setIssueStatus] = React.useState<"open"|"closed">("open");
const [issueStatus, setIssueStatus] = React.useState<"open" | "closed">(
"open"
);
React.useEffect(() => {
refreshRepos();
}, []);
React.useEffect(() => { (async () => {
React.useEffect(() => {
(async () => {
if (!activeRepo) return;
const b = await listBranches(activeRepo.id);
setBranches(b);
const cur = await getCurrentBranchId(activeRepo.id) || b.find(x=>x.name===activeRepo.defaultBranch)?.id;
const cur =
(await getCurrentBranchId(activeRepo.id)) ||
b.find((x) => x.name === activeRepo.defaultBranch)?.id;
if (cur) {
setCurrentBranchIdState(cur);
await refreshHistory(activeRepo.id, cur);
@ -44,11 +85,12 @@ export function SwitchPage() {
const iss = await listIssues(activeRepo.id);
setIssues(iss);
if (iss.length) selectIssue(iss[0].id);
})(); }, [activeRepo?.id]);
})();
}, [activeRepo?.id]);
function selectIssue(id: string) {
setSelectedIssueId(id);
const it = issues.find(i=>i.id===id);
const it = issues.find((i) => i.id === id);
if (it) {
setIssueTitle(it.title);
setIssueBody(it.body);
@ -88,7 +130,12 @@ export function SwitchPage() {
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 });
if (!cur.children.has(key))
cur.children.set(key, {
path: key,
isDir,
children: undefined,
});
cur = cur.children.get(key);
}
}
@ -96,7 +143,13 @@ export function SwitchPage() {
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));
arr.sort((a, b) =>
a.isDir === b.isDir
? a.path.localeCompare(b.path)
: a.isDir
? -1
: 1
);
return arr;
};
setTree(toArray(root));
@ -121,7 +174,7 @@ export function SwitchPage() {
}
async function refreshHistory(repoId: string, branchId: string) {
const b = branches.find(x=>x.id===branchId);
const b = branches.find((x) => x.id === branchId);
const list = await listCommitsReachable(repoId, b?.headCommitId);
setHistory(list);
}
@ -129,22 +182,61 @@ export function SwitchPage() {
return (
<div className="switch-shell">
<div className="switch-activity-bar" aria-label="Activity Bar">
<button className="switch-activity-item" title="Explorer" aria-label="Explorer">📁</button>
<button className="switch-activity-item" title="Search" aria-label="Search">🔎</button>
<button className="switch-activity-item" title="Source Control" aria-label="Source Control">🔀</button>
<button className="switch-activity-item" title="Issues" aria-label="Issues">🏷</button>
<button
className="switch-activity-item"
title="Explorer"
aria-label="Explorer"
>
📁
</button>
<button
className="switch-activity-item"
title="Search"
aria-label="Search"
>
🔎
</button>
<button
className="switch-activity-item"
title="Source Control"
aria-label="Source Control"
>
🔀
</button>
<button
className="switch-activity-item"
title="Issues"
aria-label="Issues"
>
🏷
</button>
</div>
<div className="switch-main">
<aside className="switch-sidebar" aria-label="Sidebar">
<div className="switch-pane">
<div className="switch-pane-header">Repositories</div>
<div className="switch-pane-body">
<button className="switch-secondary-btn" onClick={onOpenFolder}>Open Folder</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}
<input
type="radio"
name="repo"
onChange={() =>
onSelectRepo(r.id)
}
checked={
activeRepo?.id === r.id
}
/>{" "}
{r.name}
</label>
</div>
))}
@ -155,21 +247,116 @@ export function SwitchPage() {
<div className="switch-pane-header">Branches</div>
<div className="switch-pane-body">
<div style={{ marginBottom: 8 }}>
<select value={currentBranchId || ""} onChange={async (e)=>{ if (!activeRepo) return; const id=e.target.value; setCurrentBranchIdState(id); await setCurrentBranchId(activeRepo.id, id); await refreshHistory(activeRepo.id, id); }}>
<option value="" disabled> Select branch </option>
{branches.map(b=> (<option key={b.id} value={b.id}>{b.name}</option>))}
<select
value={currentBranchId || ""}
onChange={async (e) => {
if (!activeRepo) return;
const id = e.target.value;
setCurrentBranchIdState(id);
await setCurrentBranchId(
activeRepo.id,
id
);
await refreshHistory(activeRepo.id, id);
}}
>
<option value="" disabled>
Select branch
</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
<button className="switch-secondary-btn" onClick={async ()=>{ if (!activeRepo) return; const name=prompt("New branch name:", "feature"); if (!name) return; const from = branches.find(b=>b.id===currentBranchId)?.headCommitId; const b = await createBranch(activeRepo.id, name, from); const list=await listBranches(activeRepo.id); setBranches(list); setCurrentBranchIdState(b.id); await setCurrentBranchId(activeRepo.id, b.id); await refreshHistory(activeRepo.id, b.id); }}>New Branch</button>
<button
className="switch-secondary-btn"
onClick={async () => {
if (!activeRepo) return;
const name = prompt(
"New branch name:",
"feature"
);
if (!name) return;
const from = branches.find(
(b) => b.id === currentBranchId
)?.headCommitId;
const b = await createBranch(
activeRepo.id,
name,
from
);
const list = await listBranches(
activeRepo.id
);
setBranches(list);
setCurrentBranchIdState(b.id);
await setCurrentBranchId(
activeRepo.id,
b.id
);
await refreshHistory(
activeRepo.id,
b.id
);
}}
>
New Branch
</button>
</div>
<div>
<input placeholder="Commit message" value={commitMsg} onChange={(e)=>setCommitMsg(e.target.value)} />
<button className="switch-secondary-btn" onClick={async ()=>{ if (!activeRepo||!currentBranchId) return; await commitOnBranch(activeRepo.id, currentBranchId, commitMsg || "chore: update"); setCommitMsg(""); await refreshHistory(activeRepo.id, currentBranchId); }}>Commit</button>
<input
placeholder="Commit message"
value={commitMsg}
onChange={(e) =>
setCommitMsg(e.target.value)
}
/>
<button
className="switch-secondary-btn"
onClick={async () => {
if (!activeRepo || !currentBranchId)
return;
await commitOnBranch(
activeRepo.id,
currentBranchId,
commitMsg || "chore: update"
);
setCommitMsg("");
await refreshHistory(
activeRepo.id,
currentBranchId
);
}}
>
Commit
</button>
</div>
<div style={{ marginTop: 8 }}>
<div className="switch-pane-header">History</div>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{history.map(c => (<li key={c.id}> {new Date(c.timestamp).toLocaleString()} {c.message}</li>))}
{history.length===0 && <li className="switch-empty">No commits</li>}
<div className="switch-pane-header">
History
</div>
<ul
style={{
listStyle: "none",
padding: 0,
margin: 0,
}}
>
{history.map((c) => (
<li key={c.id}>
{" "}
{new Date(
c.timestamp
).toLocaleString()}{" "}
{c.message}
</li>
))}
{history.length === 0 && (
<li className="switch-empty">
No commits
</li>
)}
</ul>
</div>
</div>
@ -178,13 +365,43 @@ export function SwitchPage() {
<div className="switch-pane-header">Templates</div>
<div className="switch-pane-body">
<div style={{ marginBottom: 8 }}>
<button className="switch-secondary-btn" onClick={onSaveRepoAsTemplate} disabled={!activeRepo}>Save Current Repo as Template</button>
<button className="switch-secondary-btn" onClick={onCreateRepoFromTemplate}>New Repo from Template</button>
<button
className="switch-secondary-btn"
onClick={onSaveRepoAsTemplate}
disabled={!activeRepo}
>
Save Current Repo as Template
</button>
<button
className="switch-secondary-btn"
onClick={onCreateRepoFromTemplate}
>
New Repo from Template
</button>
</div>
<div>
<div style={{ fontSize: 12, color: '#9da5b4', margin: '6px 0' }}>Issue Templates</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<button className="switch-secondary-btn" onClick={onCreateIssueTemplate}>New Issue Template</button>
<div
style={{
fontSize: 12,
color: "#9da5b4",
margin: "6px 0",
}}
>
Issue Templates
</div>
<div
style={{
display: "flex",
gap: 6,
marginBottom: 6,
}}
>
<button
className="switch-secondary-btn"
onClick={onCreateIssueTemplate}
>
New Issue Template
</button>
</div>
</div>
</div>
@ -192,12 +409,26 @@ export function SwitchPage() {
<div className="switch-pane">
<div className="switch-pane-header">Cloud Sync</div>
<div className="switch-pane-body">
<div style={{ marginBottom: 8, color: '#9da5b4' }}>
Supabase storage bucket "switch" is required (public read/write via RLS). URL and anon key are read from env.
<div style={{ marginBottom: 8, color: "#9da5b4" }}>
Supabase storage bucket "switch" is required
(public read/write via RLS). URL and anon key
are read from env.
</div>
<div>
<button className="switch-secondary-btn" onClick={onPushToCloud} disabled={!activeRepo}>Push</button>
<button className="switch-secondary-btn" onClick={onPullFromCloud} disabled={!activeRepo}>Pull</button>
<button
className="switch-secondary-btn"
onClick={onPushToCloud}
disabled={!activeRepo}
>
Push
</button>
<button
className="switch-secondary-btn"
onClick={onPullFromCloud}
disabled={!activeRepo}
>
Pull
</button>
</div>
</div>
</div>
@ -205,9 +436,24 @@ export function SwitchPage() {
<div className="switch-pane-header">Explorer</div>
<div className="switch-pane-body">
<div style={{ marginBottom: 8 }}>
<button className="switch-secondary-btn" onClick={onNewFile}>New File</button>
<button className="switch-secondary-btn" onClick={onNewFolder}>New Folder</button>
<button className="switch-secondary-btn" onClick={onRefresh}>Refresh</button>
<button
className="switch-secondary-btn"
onClick={onNewFile}
>
New File
</button>
<button
className="switch-secondary-btn"
onClick={onNewFolder}
>
New Folder
</button>
<button
className="switch-secondary-btn"
onClick={onRefresh}
>
Refresh
</button>
</div>
{tree.length === 0 ? (
<div className="switch-empty">No files</div>
@ -220,33 +466,128 @@ export function SwitchPage() {
<div className="switch-pane-header">Issues</div>
<div className="switch-pane-body">
<div style={{ display: "flex", gap: 8 }}>
<input placeholder="New issue title" value={newIssueTitle} onChange={(e)=>setNewIssueTitle(e.target.value)} />
<button className="switch-secondary-btn" onClick={async ()=>{ if(!activeRepo||!newIssueTitle) return; await createIssue(activeRepo.id, { title: newIssueTitle, branchId: currentBranchId, filePath: openFile?.path }); setNewIssueTitle(""); setIssues(await listIssues(activeRepo.id)); }}>Add</button>
<input
placeholder="New issue title"
value={newIssueTitle}
onChange={(e) =>
setNewIssueTitle(e.target.value)
}
/>
<button
className="switch-secondary-btn"
onClick={async () => {
if (!activeRepo || !newIssueTitle)
return;
await createIssue(activeRepo.id, {
title: newIssueTitle,
branchId: currentBranchId,
filePath: openFile?.path,
});
setNewIssueTitle("");
setIssues(
await listIssues(activeRepo.id)
);
}}
>
Add
</button>
</div>
<ul style={{ listStyle: "none", padding: 0, marginTop: 8 }}>
{issues.map(i => (
<li key={i.id} onClick={()=>selectIssue(i.id)} style={{ cursor: "pointer", padding: "2px 0", color: selectedIssueId===i.id? "#fff": undefined }}>
#{i.id.slice(-5)} {i.title} <span style={{ color: "#858585" }}>({i.status})</span>
<ul
style={{
listStyle: "none",
padding: 0,
marginTop: 8,
}}
>
{issues.map((i) => (
<li
key={i.id}
onClick={() => selectIssue(i.id)}
style={{
cursor: "pointer",
padding: "2px 0",
color:
selectedIssueId === i.id
? "#fff"
: undefined,
}}
>
#{i.id.slice(-5)} {i.title}{" "}
<span style={{ color: "#858585" }}>
({i.status})
</span>
</li>
))}
{issues.length===0 && <li className="switch-empty">No issues</li>}
{issues.length === 0 && (
<li className="switch-empty">No issues</li>
)}
</ul>
{selectedIssue && (
<div style={{ borderTop: "1px solid #2d2d2d", marginTop: 8, paddingTop: 8 }}>
<div style={{ marginBottom: 6 }}>Edit Issue</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<input placeholder="Title" value={issueTitle} onChange={e=>setIssueTitle(e.target.value)} />
<select value={issueStatus} onChange={(e)=>setIssueStatus(e.target.value as any)}>
<div
style={{
borderTop: "1px solid #2d2d2d",
marginTop: 8,
paddingTop: 8,
}}
>
<div style={{ marginBottom: 6 }}>
Edit Issue
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<input
placeholder="Title"
value={issueTitle}
onChange={(e) =>
setIssueTitle(e.target.value)
}
/>
<select
value={issueStatus}
onChange={(e) =>
setIssueStatus(
e.target.value as any
)
}
>
<option value="open">open</option>
<option value="closed">closed</option>
<option value="closed">
closed
</option>
</select>
<input placeholder="labels (comma separated)" value={issueLabels} onChange={e=>setIssueLabels(e.target.value)} />
<input
placeholder="labels (comma separated)"
value={issueLabels}
onChange={(e) =>
setIssueLabels(e.target.value)
}
/>
<div style={{ height: 160 }}>
<ControlledMonacoEditor value={issueBody} onDidValueChange={setIssueBody} language="markdown" theme="vs-dark" />
<ControlledMonacoEditor
value={issueBody}
onDidValueChange={setIssueBody}
language="markdown"
theme="vs-dark"
/>
</div>
<div>
<button className="switch-secondary-btn" onClick={onSaveIssue}>Save</button>
<button className="switch-secondary-btn" onClick={onDeleteIssue}>Delete</button>
<button
className="switch-secondary-btn"
onClick={onSaveIssue}
>
Save
</button>
<button
className="switch-secondary-btn"
onClick={onDeleteIssue}
>
Delete
</button>
</div>
</div>
</div>
@ -256,22 +597,55 @@ export function SwitchPage() {
</aside>
<section className="switch-editor" aria-label="Editor">
<div className="switch-tabbar">
<div className="switch-tab active">{openFile?.path || "welcome.txt"}</div>
<div className="switch-tab active">
{openFile?.path || "welcome.txt"}
</div>
<div style={{ marginLeft: "auto" }}>
<button className="switch-secondary-btn" onClick={onSave} disabled={!openFile}>Save</button>
<button className="switch-secondary-btn" onClick={onSaveAs} disabled={!activeRepo}>Save As</button>
<button className="switch-secondary-btn" onClick={onRename} disabled={!openFile}>Rename</button>
<button className="switch-secondary-btn" onClick={onDelete} disabled={!openFile}>Delete</button>
<button
className="switch-secondary-btn"
onClick={onSave}
disabled={!openFile}
>
Save
</button>
<button
className="switch-secondary-btn"
onClick={onSaveAs}
disabled={!activeRepo}
>
Save As
</button>
<button
className="switch-secondary-btn"
onClick={onRename}
disabled={!openFile}
>
Rename
</button>
<button
className="switch-secondary-btn"
onClick={onDelete}
disabled={!openFile}
>
Delete
</button>
</div>
</div>
<div className="switch-editor-inner">
<ControlledMonacoEditor value={editorValue} onDidValueChange={setEditorValue} language={openFile?.language || "plaintext"} 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">{openFile?.language || "Plain Text"}</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>
@ -305,17 +679,28 @@ export function SwitchPage() {
if (!activeRepo?.fsHandleId || !openFile) return;
const dir = await getDirectoryHandle(activeRepo.fsHandleId);
if (!dir) return;
await (await import("../../switch/fs")).writeFileText(dir, openFile.path, editorValue);
await (
await import("../../switch/fs")
).writeFileText(dir, openFile.path, editorValue);
}
async function onSaveAs() {
if (!activeRepo?.fsHandleId) return;
const target = prompt("Save As path (relative):", openFile?.path || "untitled.txt");
const target = prompt(
"Save As path (relative):",
openFile?.path || "untitled.txt"
);
if (!target) return;
const dir = await getDirectoryHandle(activeRepo.fsHandleId);
if (!dir) return;
await (await import("../../switch/fs")).writeFileText(dir, target, editorValue);
setOpenFile({ path: target, content: editorValue, language: languageFromPath(target) });
await (
await import("../../switch/fs")
).writeFileText(dir, target, editorValue);
setOpenFile({
path: target,
content: editorValue,
language: languageFromPath(target),
});
await onRefresh();
}
@ -331,16 +716,29 @@ export function SwitchPage() {
async function onCreateRepoFromTemplate() {
const { listRepoTemplates } = await import("../../switch/templates");
const list = await listRepoTemplates();
if (!list.length) { alert("No repo templates."); return; }
if (!list.length) {
alert("No repo templates.");
return;
}
const names = list.map((t, i) => `${i + 1}. ${t.name}`).join("\n");
const idxStr = prompt(`Choose template:\n${names}`, "1");
if (!idxStr) return; const idx = parseInt(idxStr, 10) - 1; if (isNaN(idx) || idx<0 || idx>=list.length) return;
if (!idxStr) return;
const idx = parseInt(idxStr, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= list.length) return;
const tpl = list[idx];
const dest = await pickDirectory();
const srcHandle = await getDirectoryHandle(tpl.fsHandleId);
if (!srcHandle) { alert("Template folder permission required. Re-save the template."); return; }
await (await import("../../switch/fs")).copyDirectory(srcHandle, dest.handle);
const repo = await createRepository(dest.handle.name || "workspace", dest.id);
if (!srcHandle) {
alert("Template folder permission required. Re-save the template.");
return;
}
await (
await import("../../switch/fs")
).copyDirectory(srcHandle, dest.handle);
const repo = await createRepository(
dest.handle.name || "workspace",
dest.id
);
await refreshRepos();
setActiveRepo(repo);
await loadTree(repo);
@ -355,7 +753,11 @@ export function SwitchPage() {
const fs = await import("../../switch/fs");
await fs.writeFileText(dir, next, editorValue);
await fs.deleteEntry(dir, openFile.path);
setOpenFile({ path: next, content: editorValue, language: languageFromPath(next) });
setOpenFile({
path: next,
content: editorValue,
language: languageFromPath(next),
});
await onRefresh();
}
@ -375,7 +777,10 @@ export function SwitchPage() {
const { updateIssue } = await import("../../switch/logic");
selectedIssue.title = issueTitle;
selectedIssue.body = issueBody;
selectedIssue.labels = issueLabels.split(",").map(s=>s.trim()).filter(Boolean);
selectedIssue.labels = issueLabels
.split(",")
.map((s) => s.trim())
.filter(Boolean);
selectedIssue.status = issueStatus;
await updateIssue(selectedIssue);
setIssues(await listIssues(activeRepo.id));
@ -405,25 +810,43 @@ export function SwitchPage() {
try {
const { pullRepoFromSupabase } = await import("../../switch/sync");
const bundle = await pullRepoFromSupabase(activeRepo.id);
if (!bundle) { alert("No bundle found."); return; }
if (!bundle) {
alert("No bundle found.");
return;
}
// For now, just show summary; full import/merge logic can be added as next step
alert(`Fetched bundle exportedAt=${new Date(bundle.exportedAt).toLocaleString()}\nbranches=${bundle.branches.length}, commits=${bundle.commits.length}, issues=${bundle.issues.length}`);
alert(
`Fetched bundle exportedAt=${new Date(
bundle.exportedAt
).toLocaleString()}\nbranches=${
bundle.branches.length
}, commits=${bundle.commits.length}, issues=${
bundle.issues.length
}`
);
} catch (e: any) {
alert("Cloud pull failed: " + (e?.message || e));
}
}
}
async function getFileByPath(dir: FileSystemDirectoryHandle, path: string): Promise<File | undefined> {
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;
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);
cur = await (cur as any)
.getDirectoryHandle(name)
.catch(() => undefined);
if (!cur) return undefined as any;
}
}
@ -433,26 +856,46 @@ async function getFileByPath(dir: FileSystemDirectoryHandle, path: string): Prom
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 "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";
case "yaml":
return "yaml";
case "xml":
return "xml";
case "sql":
return "sql";
default:
return "plaintext";
}
}

View file

@ -1,4 +1,10 @@
export type StoreName = "repos" | "branches" | "commits" | "issues" | "fsHandles" | "settings";
export type StoreName =
| "repos"
| "branches"
| "commits"
| "issues"
| "fsHandles"
| "settings";
const DB_NAME = "switch-db";
const DB_VERSION = 2;
@ -68,12 +74,18 @@ export async function openDB(): Promise<IDBDatabase> {
store.createIndex("by_name", "name", { unique: false });
}
if (!db.objectStoreNames.contains("branches")) {
const store = db.createObjectStore("branches", { keyPath: "id" });
const store = db.createObjectStore("branches", {
keyPath: "id",
});
store.createIndex("by_repo", "repoId", { unique: false });
store.createIndex("by_repo_name", ["repoId", "name"], { unique: true });
store.createIndex("by_repo_name", ["repoId", "name"], {
unique: true,
});
}
if (!db.objectStoreNames.contains("commits")) {
const store = db.createObjectStore("commits", { keyPath: "id" });
const store = db.createObjectStore("commits", {
keyPath: "id",
});
store.createIndex("by_repo", "repoId", { unique: false });
}
if (!db.objectStoreNames.contains("issues")) {
@ -98,7 +110,11 @@ export async function openDB(): Promise<IDBDatabase> {
});
}
export async function tx<T>(storeNames: StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise<T>): Promise<T> {
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);
@ -124,7 +140,10 @@ export async function put<T>(store: StoreName, value: T): Promise<void> {
});
}
export async function get<T>(store: StoreName, key: IDBValidKey): Promise<T | undefined> {
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));
});
@ -137,7 +156,11 @@ export async function del(store: StoreName, key: IDBValidKey): Promise<void> {
});
}
export async function getAllByIndex<T>(store: StoreName, index: string, query: IDBValidKey | IDBKeyRange): Promise<T[]> {
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));

View file

@ -1,62 +1,94 @@
import { put, get, type FsHandleRecord } from "./db";
import { nanoid } from "./uid";
export async function pickDirectory(): Promise<{ id: string; handle: FileSystemDirectoryHandle }> {
export async function pickDirectory(): Promise<{
id: string;
handle: FileSystemDirectoryHandle;
}> {
// @ts-ignore
const handle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker();
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> {
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> {
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 ensureWritePerm(dir: FileSystemDirectoryHandle): Promise<boolean> {
export async function ensureWritePerm(
dir: FileSystemDirectoryHandle
): Promise<boolean> {
const perm = await (dir as any).queryPermission?.({ mode: "readwrite" });
if (perm === "granted") return true;
const req = await (dir as any).requestPermission?.({ mode: "readwrite" });
return req === "granted";
}
export async function getDirectoryHandleByPath(root: FileSystemDirectoryHandle, path: string, create = false): Promise<FileSystemDirectoryHandle | undefined> {
export async function getDirectoryHandleByPath(
root: FileSystemDirectoryHandle,
path: string,
create = false
): Promise<FileSystemDirectoryHandle | undefined> {
const parts = path.split("/").filter(Boolean);
let cur: FileSystemDirectoryHandle = root;
for (const name of parts) {
const next = await (cur as any).getDirectoryHandle(name, { create }).catch(() => undefined);
const next = await (cur as any)
.getDirectoryHandle(name, { create })
.catch(() => undefined);
if (!next) return undefined;
cur = next;
}
return cur;
}
export async function getFileHandleByPath(root: FileSystemDirectoryHandle, path: string, create = false): Promise<FileSystemFileHandle | undefined> {
export async function getFileHandleByPath(
root: FileSystemDirectoryHandle,
path: string,
create = false
): Promise<FileSystemFileHandle | undefined> {
const parts = path.split("/");
const dirPath = parts.slice(0, -1).join("/");
const fileName = parts[parts.length - 1];
const dir = dirPath ? await getDirectoryHandleByPath(root, dirPath, create) : root;
const dir = dirPath
? await getDirectoryHandleByPath(root, dirPath, create)
: root;
if (!dir) return undefined;
return (dir as any).getFileHandle(fileName, { create }).catch(() => undefined);
return (dir as any)
.getFileHandle(fileName, { create })
.catch(() => undefined);
}
export async function readFileText(root: FileSystemDirectoryHandle, path: string): Promise<string | undefined> {
export async function readFileText(
root: FileSystemDirectoryHandle,
path: string
): Promise<string | undefined> {
const fh = await getFileHandleByPath(root, path);
if (!fh) return undefined;
const file = await fh.getFile();
return file.text();
}
export async function writeFileText(root: FileSystemDirectoryHandle, path: string, content: string): Promise<void> {
export async function writeFileText(
root: FileSystemDirectoryHandle,
path: string,
content: string
): Promise<void> {
const dirPerm = await ensureWritePerm(root);
if (!dirPerm) throw new Error("No write permission");
const fh = await getFileHandleByPath(root, path, true);
@ -66,13 +98,20 @@ export async function writeFileText(root: FileSystemDirectoryHandle, path: strin
await w.close();
}
export async function createDirectory(root: FileSystemDirectoryHandle, path: string): Promise<void> {
export async function createDirectory(
root: FileSystemDirectoryHandle,
path: string
): Promise<void> {
const ok = await ensureWritePerm(root);
if (!ok) throw new Error("No write permission");
await getDirectoryHandleByPath(root, path, true);
}
export async function deleteEntry(root: FileSystemDirectoryHandle, path: string, recursive = false): Promise<void> {
export async function deleteEntry(
root: FileSystemDirectoryHandle,
path: string,
recursive = false
): Promise<void> {
const parts = path.split("/");
const dirPath = parts.slice(0, -1).join("/");
const name = parts[parts.length - 1];
@ -81,7 +120,10 @@ export async function deleteEntry(root: FileSystemDirectoryHandle, path: string,
await (dir as any).removeEntry(name, { recursive }).catch(() => undefined);
}
export async function* walk(dir: FileSystemDirectoryHandle, pathPrefix = ""): AsyncGenerator<{ path: string; file: File }>{
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;
@ -94,17 +136,24 @@ export async function* walk(dir: FileSystemDirectoryHandle, pathPrefix = ""): As
}
}
export async function copyDirectory(src: FileSystemDirectoryHandle, dest: FileSystemDirectoryHandle): Promise<void> {
export async function copyDirectory(
src: FileSystemDirectoryHandle,
dest: FileSystemDirectoryHandle
): Promise<void> {
const ok = await ensureWritePerm(dest);
if (!ok) throw new Error("No write permission on destination");
// @ts-ignore
for await (const [name, entry] of (src as any).entries()) {
if (entry.kind === "directory") {
const sub = await (dest as any).getDirectoryHandle(name, { create: true });
const sub = await (dest as any).getDirectoryHandle(name, {
create: true,
});
await copyDirectory(entry as FileSystemDirectoryHandle, sub);
} else {
const file = await (entry as FileSystemFileHandle).getFile();
const fh = await (dest as any).getFileHandle(name, { create: true });
const fh = await (dest as any).getFileHandle(name, {
create: true,
});
const w = await (fh as any).createWritable();
await w.write(await file.arrayBuffer());
await w.close();

View file

@ -1,5 +1,6 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { createClient, SupabaseClient } from "@supabase/supabase-js";
const url = process.env.SUPABASE_URL as string;
const key = process.env.SUPABASE_ANON_KEY as string;
export const supabase: SupabaseClient | undefined = url && key ? createClient(url, key) : undefined;
export const supabase: SupabaseClient | undefined =
url && key ? createClient(url, key) : undefined;

View file

@ -1,5 +1,12 @@
import { getAll, getAllByIndex, type RepoRecord, type BranchRecord, type CommitRecord, type IssueRecord } from './db';
import { supabase } from './supabase';
import {
getAll,
getAllByIndex,
type RepoRecord,
type BranchRecord,
type CommitRecord,
type IssueRecord,
} from "./db";
import { supabase } from "./supabase";
export interface ExportBundle {
repo: RepoRecord;
@ -10,27 +17,47 @@ export interface ExportBundle {
}
export async function exportRepoBundle(repoId: string): Promise<ExportBundle> {
const repos = await getAll<RepoRecord>('repos');
const repo = repos.find(r=>r.id===repoId)!;
const branches = await getAllByIndex<BranchRecord>('branches','by_repo', repoId);
const commits = await getAllByIndex<CommitRecord>('commits','by_repo', repoId);
const issues = await getAllByIndex<IssueRecord>('issues','by_repo', repoId);
const repos = await getAll<RepoRecord>("repos");
const repo = repos.find((r) => r.id === repoId)!;
const branches = await getAllByIndex<BranchRecord>(
"branches",
"by_repo",
repoId
);
const commits = await getAllByIndex<CommitRecord>(
"commits",
"by_repo",
repoId
);
const issues = await getAllByIndex<IssueRecord>(
"issues",
"by_repo",
repoId
);
return { repo, branches, commits, issues, exportedAt: Date.now() };
}
export async function pushRepoToSupabase(repoId: string): Promise<void> {
if (!supabase) throw new Error('Supabase not configured');
if (!supabase) throw new Error("Supabase not configured");
const bundle = await exportRepoBundle(repoId);
const path = `repos/${repoId}.json`;
const data = new Blob([JSON.stringify(bundle)], { type: 'application/json' });
const { error } = await supabase.storage.from('switch').upload(path, data, { upsert: true });
const data = new Blob([JSON.stringify(bundle)], {
type: "application/json",
});
const { error } = await supabase.storage
.from("switch")
.upload(path, data, { upsert: true });
if (error) throw error;
}
export async function pullRepoFromSupabase(repoId: string): Promise<ExportBundle | undefined> {
if (!supabase) throw new Error('Supabase not configured');
export async function pullRepoFromSupabase(
repoId: string
): Promise<ExportBundle | undefined> {
if (!supabase) throw new Error("Supabase not configured");
const path = `repos/${repoId}.json`;
const { data, error } = await supabase.storage.from('switch').download(path);
const { data, error } = await supabase.storage
.from("switch")
.download(path);
if (error) throw error;
const text = await data.text();
return JSON.parse(text) as ExportBundle;

View file

@ -1,7 +1,17 @@
import { get, put, del, getAll, type RepoTemplateRecord, type IssueTemplateRecord } from "./db";
import {
get,
put,
del,
getAll,
type RepoTemplateRecord,
type IssueTemplateRecord,
} from "./db";
import { nanoid } from "./uid";
export async function createRepoTemplate(name: string, fsHandleId: string): Promise<RepoTemplateRecord> {
export async function createRepoTemplate(
name: string,
fsHandleId: string
): Promise<RepoTemplateRecord> {
const rec: RepoTemplateRecord = { id: nanoid(), name, fsHandleId };
await put("repoTemplates" as any, rec as any);
return rec;
@ -13,7 +23,12 @@ export async function deleteRepoTemplate(id: string): Promise<void> {
await del("repoTemplates" as any, id);
}
export async function createIssueTemplate(data: { name: string; title: string; body: string; labels: string[] }): Promise<IssueTemplateRecord> {
export async function createIssueTemplate(data: {
name: string;
title: string;
body: string;
labels: string[];
}): Promise<IssueTemplateRecord> {
const rec: IssueTemplateRecord = { id: nanoid(), ...data };
await put("issueTemplates" as any, rec as any);
return rec;

View file

@ -76,7 +76,9 @@ module.exports = {
"process.env": {
YEAR: JSON.stringify(new Date().getFullYear()),
SUPABASE_URL: JSON.stringify(process.env.SUPABASE_URL || ""),
SUPABASE_ANON_KEY: JSON.stringify(process.env.SUPABASE_ANON_KEY || ""),
SUPABASE_ANON_KEY: JSON.stringify(
process.env.SUPABASE_ANON_KEY || ""
),
},
}),
new CleanWebpackPlugin(),
@ -119,7 +121,11 @@ module.exports = {
}),
new CopyPlugin({
patterns: [
{ from: "./typedoc/dist", to: "./typedoc/", noErrorOnMissing: true },
{
from: "./typedoc/dist",
to: "./typedoc/",
noErrorOnMissing: true,
},
],
}),
new CopyPlugin({