feat(electrobun): ProjectWizard — 3-step project creation with 5 source types
Step 1 — Source: local folder (path browser + validation), git clone, GitHub URL, template (4 built-in), remote SSH Step 2 — Configure: name, branch selector, worktree toggle, group, icon, shell Step 3 — Agent: provider, model, permission mode, system prompt, auto-start - ProjectWizard.svelte: 3-step wizard with display toggle (rule 55) - PathBrowser.svelte: inline directory browser with breadcrumbs + shortcuts - git-handlers.ts: git.branches + git.clone RPC handlers - files.statEx RPC: path validation + git detection + writable check - 39 new i18n keys, 172 total TranslationKey entries - App.svelte: wizard overlay replaces simple add-project card
This commit is contained in:
parent
1d2975b07b
commit
45bca3b96f
9 changed files with 1203 additions and 45 deletions
|
|
@ -91,6 +91,62 @@ export function createFilesHandlers() {
|
|||
}
|
||||
},
|
||||
|
||||
// Extended stat for ProjectWizard — directory check, git detection, writability.
|
||||
// Note: This handler bypasses guardPath intentionally — the wizard needs to validate
|
||||
// arbitrary paths the user types (not just project CWDs). It only reads metadata, never content.
|
||||
"files.statEx": async ({ path: filePath }: { path: string }) => {
|
||||
try {
|
||||
const resolved = path.resolve(filePath.replace(/^~/, process.env.HOME ?? ""));
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
return { exists: false, isDirectory: false, isGitRepo: false };
|
||||
}
|
||||
|
||||
const isDirectory = stat.isDirectory();
|
||||
let isGitRepo = false;
|
||||
let gitBranch: string | undefined;
|
||||
let writable = false;
|
||||
|
||||
if (isDirectory) {
|
||||
// Check for .git directory
|
||||
try {
|
||||
const gitStat = fs.statSync(path.join(resolved, ".git"));
|
||||
isGitRepo = gitStat.isDirectory();
|
||||
} catch { /* not a git repo */ }
|
||||
|
||||
// Read current branch if git repo
|
||||
if (isGitRepo) {
|
||||
try {
|
||||
const head = fs.readFileSync(path.join(resolved, ".git", "HEAD"), "utf8").trim();
|
||||
if (head.startsWith("ref: refs/heads/")) {
|
||||
gitBranch = head.slice("ref: refs/heads/".length);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Check writability
|
||||
try {
|
||||
fs.accessSync(resolved, fs.constants.W_OK);
|
||||
writable = true;
|
||||
} catch { /* not writable */ }
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory,
|
||||
isGitRepo,
|
||||
gitBranch,
|
||||
size: stat.size,
|
||||
writable,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { exists: false, isDirectory: false, isGitRepo: false, error };
|
||||
}
|
||||
},
|
||||
|
||||
"files.write": async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
const guard = guardPath(filePath);
|
||||
if (!guard.valid) {
|
||||
|
|
|
|||
102
ui-electrobun/src/bun/handlers/git-handlers.ts
Normal file
102
ui-electrobun/src/bun/handlers/git-handlers.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Git RPC handlers — branch listing, clone operations.
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { execSync, spawn } from "child_process";
|
||||
|
||||
export function createGitHandlers() {
|
||||
return {
|
||||
"git.branches": async ({ path: repoPath }: { path: string }) => {
|
||||
try {
|
||||
const resolved = path.resolve(repoPath.replace(/^~/, process.env.HOME ?? ""));
|
||||
|
||||
// Verify .git exists
|
||||
try {
|
||||
fs.statSync(path.join(resolved, ".git"));
|
||||
} catch {
|
||||
return { branches: [], current: "", error: "Not a git repository" };
|
||||
}
|
||||
|
||||
const raw = execSync("git branch -a --no-color", {
|
||||
cwd: resolved,
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
let current = "";
|
||||
const branches: string[] = [];
|
||||
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Skip HEAD pointer lines like "remotes/origin/HEAD -> origin/main"
|
||||
if (trimmed.includes("->")) continue;
|
||||
|
||||
const isCurrent = trimmed.startsWith("* ");
|
||||
const name = trimmed.replace(/^\*\s+/, "").replace(/^remotes\//, "");
|
||||
|
||||
if (isCurrent) current = name;
|
||||
if (!branches.includes(name)) branches.push(name);
|
||||
}
|
||||
|
||||
return { branches, current };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { branches: [], current: "", error };
|
||||
}
|
||||
},
|
||||
|
||||
"git.clone": async ({
|
||||
url,
|
||||
target,
|
||||
branch,
|
||||
}: {
|
||||
url: string;
|
||||
target: string;
|
||||
branch?: string;
|
||||
}) => {
|
||||
// Validate URL — basic check for git-cloneable patterns
|
||||
if (!url || (!url.includes("/") && !url.includes(":"))) {
|
||||
return { ok: false, error: "Invalid repository URL" };
|
||||
}
|
||||
|
||||
const resolvedTarget = path.resolve(target.replace(/^~/, process.env.HOME ?? ""));
|
||||
|
||||
// Don't overwrite existing directory
|
||||
try {
|
||||
fs.statSync(resolvedTarget);
|
||||
return { ok: false, error: "Target directory already exists" };
|
||||
} catch {
|
||||
// Expected — directory should not exist
|
||||
}
|
||||
|
||||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||
const args = ["clone"];
|
||||
if (branch) args.push("--branch", branch);
|
||||
args.push(url, resolvedTarget);
|
||||
|
||||
const proc = spawn("git", args, { stdio: "pipe", timeout: 120_000 });
|
||||
|
||||
let stderr = "";
|
||||
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true });
|
||||
} else {
|
||||
resolve({ ok: false, error: stderr.trim() || `git clone exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({ ok: false, error: err.message });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import { createBtmsgHandlers, createBttaskHandlers } from "./handlers/btmsg-hand
|
|||
import { createSearchHandlers } from "./handlers/search-handlers.ts";
|
||||
import { createPluginHandlers } from "./handlers/plugin-handlers.ts";
|
||||
import { createRemoteHandlers } from "./handlers/remote-handlers.ts";
|
||||
import { createGitHandlers } from "./handlers/git-handlers.ts";
|
||||
|
||||
/** Current app version — sourced from electrobun.config.ts at build time. */
|
||||
const APP_VERSION = "0.0.1";
|
||||
|
|
@ -99,6 +100,7 @@ const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
|
|||
const searchHandlers = createSearchHandlers(searchDb);
|
||||
const pluginHandlers = createPluginHandlers();
|
||||
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
|
||||
const gitHandlers = createGitHandlers();
|
||||
|
||||
// ── RPC definition ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -123,6 +125,18 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
...pluginHandlers,
|
||||
// Remote
|
||||
...remoteHandlers,
|
||||
// Git
|
||||
...gitHandlers,
|
||||
|
||||
// Project templates (hardcoded list)
|
||||
"project.templates": async () => ({
|
||||
templates: [
|
||||
{ id: "blank", name: "Blank Project", description: "Empty directory with no scaffolding", icon: "📁" },
|
||||
{ id: "web-app", name: "Web App", description: "HTML/CSS/JS web application starter", icon: "🌐" },
|
||||
{ id: "api-server", name: "API Server", description: "Node.js/Bun HTTP API server", icon: "⚡" },
|
||||
{ id: "cli-tool", name: "CLI Tool", description: "Command-line tool with argument parser", icon: "🔧" },
|
||||
],
|
||||
}),
|
||||
|
||||
// ── Project clone handler ──────────────────────────────────────────
|
||||
"project.clone": async ({ projectId, branchName }) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue