From 45bca3b96f860ace03bbf9e438a362b2aa7b253b Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 22 Mar 2026 11:17:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(electrobun):=20ProjectWizard=20=E2=80=94?= =?UTF-8?q?=203-step=20project=20creation=20with=205=20source=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui-electrobun/locales/en.json | 41 +- .../src/bun/handlers/files-handlers.ts | 56 ++ .../src/bun/handlers/git-handlers.ts | 102 +++ ui-electrobun/src/bun/index.ts | 14 + ui-electrobun/src/mainview/App.svelte | 76 +-- ui-electrobun/src/mainview/PathBrowser.svelte | 252 +++++++ .../src/mainview/ProjectWizard.svelte | 626 ++++++++++++++++++ ui-electrobun/src/mainview/i18n.types.ts | 40 +- ui-electrobun/src/shared/pty-rpc-schema.ts | 41 ++ 9 files changed, 1203 insertions(+), 45 deletions(-) create mode 100644 ui-electrobun/src/bun/handlers/git-handlers.ts create mode 100644 ui-electrobun/src/mainview/PathBrowser.svelte create mode 100644 ui-electrobun/src/mainview/ProjectWizard.svelte diff --git a/ui-electrobun/locales/en.json b/ui-electrobun/locales/en.json index 9745d4c..a9745a0 100644 --- a/ui-electrobun/locales/en.json +++ b/ui-electrobun/locales/en.json @@ -146,5 +146,44 @@ "palette.zoomOut": "Zoom Out", "palette.addProjectDesc": "Open a project directory", "palette.clearAgentDesc": "Reset agent session", - "palette.changeThemeDesc": "Switch between 17 themes" + "palette.changeThemeDesc": "Switch between 17 themes", + + "wizard.title": "Add Project", + "wizard.step1.title": "Choose Source", + "wizard.step1.local": "Local Folder", + "wizard.step1.gitClone": "Git Clone", + "wizard.step1.github": "GitHub Repository", + "wizard.step1.template": "From Template", + "wizard.step1.remote": "Remote (SSH)", + "wizard.step1.pathLabel": "Project Path", + "wizard.step1.pathPlaceholder": "/home/user/projects/my-app", + "wizard.step1.browse": "Browse", + "wizard.step1.validDir": "Valid directory", + "wizard.step1.invalidPath": "Path does not exist", + "wizard.step1.notDir": "Path is not a directory", + "wizard.step1.gitDetected": "Git repository detected", + "wizard.step1.repoUrl": "Repository URL", + "wizard.step1.targetDir": "Clone to", + "wizard.step1.githubRepo": "owner/repo", + "wizard.step2.title": "Configure", + "wizard.step2.name": "Project Name", + "wizard.step2.branch": "Branch", + "wizard.step2.worktree": "Use worktrees for agent sessions", + "wizard.step2.group": "Group", + "wizard.step2.icon": "Icon", + "wizard.step2.shell": "Shell", + "wizard.step3.title": "Agent Settings", + "wizard.step3.provider": "AI Provider", + "wizard.step3.model": "Model", + "wizard.step3.permission": "Permission Mode", + "wizard.step3.systemPrompt": "System Prompt", + "wizard.step3.autoStart": "Start agent after creation", + "wizard.back": "Back", + "wizard.next": "Next", + "wizard.skip": "Skip", + "wizard.create": "Create Project", + "wizard.cloning": "Cloning repository...", + "wizard.step1.hostLabel": "Host", + "wizard.step1.userLabel": "User", + "wizard.step2.newGroup": "New group" } diff --git a/ui-electrobun/src/bun/handlers/files-handlers.ts b/ui-electrobun/src/bun/handlers/files-handlers.ts index 87ff6da..7d0d17a 100644 --- a/ui-electrobun/src/bun/handlers/files-handlers.ts +++ b/ui-electrobun/src/bun/handlers/files-handlers.ts @@ -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) { diff --git a/ui-electrobun/src/bun/handlers/git-handlers.ts b/ui-electrobun/src/bun/handlers/git-handlers.ts new file mode 100644 index 0000000..50d1d9e --- /dev/null +++ b/ui-electrobun/src/bun/handlers/git-handlers.ts @@ -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 }); + }); + }); + }, + }; +} diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 5be798a..b2c1df8 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -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({ ...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 }) => { diff --git a/ui-electrobun/src/mainview/App.svelte b/ui-electrobun/src/mainview/App.svelte index 06d4dd4..292be0b 100644 --- a/ui-electrobun/src/mainview/App.svelte +++ b/ui-electrobun/src/mainview/App.svelte @@ -8,6 +8,7 @@ import StatusBar from './StatusBar.svelte'; import SearchOverlay from './SearchOverlay.svelte'; import SplashScreen from './SplashScreen.svelte'; + import ProjectWizard from './ProjectWizard.svelte'; import { themeStore } from './theme-store.svelte.ts'; import { fontStore } from './font-store.svelte.ts'; import { keybindingStore } from './keybinding-store.svelte.ts'; @@ -71,34 +72,37 @@ ]); // ── Add/Remove project UI state ────────────────────────────────── - let showAddProject = $state(false); - let newProjectName = $state(''); - let newProjectCwd = $state(''); + let showWizard = $state(false); let projectToDelete = $state(null); - async function addProject() { - const name = newProjectName.trim(); - const cwd = newProjectCwd.trim(); - if (!name || !cwd) return; - - const id = `p-${Date.now()}`; + function handleWizardCreated(result: { + id: string; name: string; cwd: string; provider?: string; model?: string; + systemPrompt?: string; autoStart?: boolean; groupId?: string; + useWorktrees?: boolean; shell?: string; icon?: string; + }) { const accent = ACCENTS[PROJECTS.length % ACCENTS.length]; const project: Project = { - id, name, cwd, accent, - status: 'idle', costUsd: 0, tokens: 0, messages: [], - provider: 'claude', groupId: activeGroupId, + id: result.id, + name: result.name, + cwd: result.cwd, + accent, + status: 'idle', + costUsd: 0, + tokens: 0, + messages: [], + provider: result.provider ?? 'claude', + model: result.model, + groupId: result.groupId ?? activeGroupId, }; PROJECTS = [...PROJECTS, project]; - trackProject(id); + trackProject(project.id); - await appRpc.request['settings.setProject']({ - id, - config: JSON.stringify(project), + appRpc.request['settings.setProject']({ + id: project.id, + config: JSON.stringify({ ...project, ...result }), }).catch(console.error); - showAddProject = false; - newProjectName = ''; - newProjectCwd = ''; + showWizard = false; } async function confirmDeleteProject() { @@ -357,7 +361,7 @@ switch (detail) { case 'settings': settingsOpen = !settingsOpen; break; case 'search': searchOpen = !searchOpen; break; - case 'new-project': showAddProject = true; break; + case 'new-project': showWizard = true; break; case 'toggle-sidebar': settingsOpen = !settingsOpen; break; default: console.log(`[palette] unhandled command: ${detail}`); } @@ -439,7 +443,7 @@ - - - + +
+ showWizard = false} + onCreated={handleWizardCreated} + groupId={activeGroupId} + groups={groups.map(g => ({ id: g.id, name: g.name }))} + />
diff --git a/ui-electrobun/src/mainview/PathBrowser.svelte b/ui-electrobun/src/mainview/PathBrowser.svelte new file mode 100644 index 0000000..75fcdbf --- /dev/null +++ b/ui-electrobun/src/mainview/PathBrowser.svelte @@ -0,0 +1,252 @@ + + +
+ +
+ {t('wizard.step1.browse' as any)} + +
+ + +
+ {#each SHORTCUTS as sc} + + {/each} +
+ + +
+ {#each breadcrumbs() as crumb, i} + {#if i > 0}/{/if} + + {/each} +
+ + + + + +
+ {#if loading} +
Loading...
+ {:else if error} +
{error}
+ {:else if filteredEntries.length === 0} +
No directories
+ {:else} + {#each filteredEntries as entry} + + {/each} + {/if} +
+ + + +
+ + diff --git a/ui-electrobun/src/mainview/ProjectWizard.svelte b/ui-electrobun/src/mainview/ProjectWizard.svelte new file mode 100644 index 0000000..da099c6 --- /dev/null +++ b/ui-electrobun/src/mainview/ProjectWizard.svelte @@ -0,0 +1,626 @@ + + + + + diff --git a/ui-electrobun/src/mainview/i18n.types.ts b/ui-electrobun/src/mainview/i18n.types.ts index a0a421f..ef56154 100644 --- a/ui-electrobun/src/mainview/i18n.types.ts +++ b/ui-electrobun/src/mainview/i18n.types.ts @@ -137,4 +137,42 @@ export type TranslationKey = | 'terminal.closeTab' | 'terminal.collapse' | 'terminal.expand' - | 'terminal.shell'; + | 'terminal.shell' + | 'wizard.back' + | 'wizard.cloning' + | 'wizard.create' + | 'wizard.next' + | 'wizard.skip' + | 'wizard.step1.browse' + | 'wizard.step1.gitClone' + | 'wizard.step1.gitDetected' + | 'wizard.step1.github' + | 'wizard.step1.githubRepo' + | 'wizard.step1.hostLabel' + | 'wizard.step1.invalidPath' + | 'wizard.step1.local' + | 'wizard.step1.notDir' + | 'wizard.step1.pathLabel' + | 'wizard.step1.pathPlaceholder' + | 'wizard.step1.remote' + | 'wizard.step1.repoUrl' + | 'wizard.step1.targetDir' + | 'wizard.step1.template' + | 'wizard.step1.title' + | 'wizard.step1.userLabel' + | 'wizard.step1.validDir' + | 'wizard.step2.branch' + | 'wizard.step2.group' + | 'wizard.step2.icon' + | 'wizard.step2.name' + | 'wizard.step2.newGroup' + | 'wizard.step2.shell' + | 'wizard.step2.title' + | 'wizard.step2.worktree' + | 'wizard.step3.autoStart' + | 'wizard.step3.model' + | 'wizard.step3.permission' + | 'wizard.step3.provider' + | 'wizard.step3.systemPrompt' + | 'wizard.step3.title' + | 'wizard.title'; diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index b2fa34a..8fd7fad 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -133,6 +133,19 @@ export type PtyRPCRequests = { params: { path: string }; response: { mtimeMs: number; size: number; error?: string }; }; + /** Extended stat — directory check, git detection, writability. Used by ProjectWizard. */ + "files.statEx": { + params: { path: string }; + response: { + exists: boolean; + isDirectory: boolean; + isGitRepo: boolean; + gitBranch?: string; + size?: number; + writable?: boolean; + error?: string; + }; + }; /** Write text content to a file (atomic temp+rename). */ "files.write": { params: { path: string; content: string }; @@ -196,6 +209,34 @@ export type PtyRPCRequests = { response: { ok: boolean; project?: { id: string; config: string }; error?: string }; }; + // ── Git RPC ────────────────────────────────────────────────────────────────── + + /** List branches in a git repository. */ + "git.branches": { + params: { path: string }; + response: { branches: string[]; current: string; error?: string }; + }; + /** Clone a git repository. */ + "git.clone": { + params: { url: string; target: string; branch?: string }; + response: { ok: boolean; error?: string }; + }; + + // ── Project templates RPC ─────────────────────────────────────────────────── + + /** Return available project templates. */ + "project.templates": { + params: Record; + response: { + templates: Array<{ + id: string; + name: string; + description: string; + icon: string; + }>; + }; + }; + // ── Window control RPC ───────────────────────────────────────────────────── /** Minimize the main window. */