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:
Hibryda 2026-03-22 11:17:05 +01:00
parent 1d2975b07b
commit 45bca3b96f
9 changed files with 1203 additions and 45 deletions

View file

@ -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) {

View 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 });
});
});
},
};
}