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
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 @@
|
|||
<!-- Add project button -->
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
onclick={() => showAddProject = !showAddProject}
|
||||
onclick={() => showWizard = !showWizard}
|
||||
aria-label="Add project"
|
||||
title="Add project"
|
||||
>
|
||||
|
|
@ -501,28 +505,14 @@
|
|||
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
|
||||
</div>
|
||||
|
||||
<!-- Add project card -->
|
||||
<div class="add-card" role="listitem" style:display={showAddProject ? 'flex' : 'none'}>
|
||||
<div class="add-card-form">
|
||||
<input
|
||||
class="add-input"
|
||||
type="text"
|
||||
placeholder="Project name"
|
||||
bind:value={newProjectName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
||||
/>
|
||||
<input
|
||||
class="add-input"
|
||||
type="text"
|
||||
placeholder="Working directory (e.g. ~/code/myproject)"
|
||||
bind:value={newProjectCwd}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addProject(); if (e.key === 'Escape') showAddProject = false; }}
|
||||
/>
|
||||
<div class="add-card-actions">
|
||||
<button class="add-cancel" onclick={() => showAddProject = false}>Cancel</button>
|
||||
<button class="add-confirm" onclick={addProject}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Project wizard overlay (display toggle) -->
|
||||
<div style:display={showWizard ? 'contents' : 'none'}>
|
||||
<ProjectWizard
|
||||
onClose={() => showWizard = false}
|
||||
onCreated={handleWizardCreated}
|
||||
groupId={activeGroupId}
|
||||
groups={groups.map(g => ({ id: g.id, name: g.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Delete project confirmation -->
|
||||
|
|
|
|||
252
ui-electrobun/src/mainview/PathBrowser.svelte
Normal file
252
ui-electrobun/src/mainview/PathBrowser.svelte
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onSelect, onClose }: Props = $props();
|
||||
|
||||
const HOME = '/home/' + (typeof window !== 'undefined' ? '' : '');
|
||||
const SHORTCUTS = [
|
||||
{ label: 'Home', path: '~' },
|
||||
{ label: 'Desktop', path: '~/Desktop' },
|
||||
{ label: 'Documents', path: '~/Documents' },
|
||||
{ label: '/tmp', path: '/tmp' },
|
||||
];
|
||||
|
||||
let currentPath = $state('~');
|
||||
let entries = $state<Array<{ name: string; type: 'file' | 'dir'; size: number }>>([]);
|
||||
let loading = $state(false);
|
||||
let filter = $state('');
|
||||
let error = $state('');
|
||||
|
||||
// Breadcrumb segments
|
||||
let breadcrumbs = $derived(() => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const crumbs: Array<{ label: string; path: string }> = [];
|
||||
let acc = '';
|
||||
for (const part of parts) {
|
||||
acc += (acc === '' && currentPath.startsWith('~') && crumbs.length === 0) ? part : '/' + part;
|
||||
if (crumbs.length === 0 && currentPath.startsWith('~')) acc = part;
|
||||
crumbs.push({ label: part, path: acc.startsWith('~') ? acc : '/' + acc });
|
||||
}
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
let filteredEntries = $derived(
|
||||
entries.filter(e => e.type === 'dir' && (filter === '' || e.name.toLowerCase().includes(filter.toLowerCase())))
|
||||
);
|
||||
|
||||
async function loadDir(dirPath: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const expandedPath = dirPath.replace(/^~/, process.env.HOME ?? '/home');
|
||||
const result = await appRpc.request['files.list']({ path: expandedPath });
|
||||
if (result?.error) {
|
||||
error = result.error;
|
||||
entries = [];
|
||||
} else {
|
||||
entries = result?.entries ?? [];
|
||||
}
|
||||
currentPath = dirPath;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
entries = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(dirPath: string) {
|
||||
filter = '';
|
||||
loadDir(dirPath);
|
||||
}
|
||||
|
||||
function selectFolder(name: string) {
|
||||
const sep = currentPath.endsWith('/') ? '' : '/';
|
||||
const newPath = currentPath + sep + name;
|
||||
navigateTo(newPath);
|
||||
}
|
||||
|
||||
function confirmSelect() {
|
||||
onSelect(currentPath);
|
||||
}
|
||||
|
||||
// Load initial directory
|
||||
$effect(() => {
|
||||
loadDir('~');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="path-browser">
|
||||
<!-- Header -->
|
||||
<div class="pb-header">
|
||||
<span class="pb-title">{t('wizard.step1.browse' as any)}</span>
|
||||
<button class="pb-close" onclick={onClose} aria-label={t('common.close' as any)}>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<div class="pb-shortcuts">
|
||||
{#each SHORTCUTS as sc}
|
||||
<button class="pb-shortcut" onclick={() => navigateTo(sc.path)}>{sc.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="pb-breadcrumbs">
|
||||
{#each breadcrumbs() as crumb, i}
|
||||
{#if i > 0}<span class="pb-sep">/</span>{/if}
|
||||
<button class="pb-crumb" onclick={() => navigateTo(crumb.path)}>{crumb.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<input
|
||||
class="pb-filter"
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
bind:value={filter}
|
||||
/>
|
||||
|
||||
<!-- Directory list -->
|
||||
<div class="pb-list">
|
||||
{#if loading}
|
||||
<div class="pb-loading">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="pb-error">{error}</div>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<div class="pb-empty">No directories</div>
|
||||
{:else}
|
||||
{#each filteredEntries as entry}
|
||||
<button class="pb-entry" onclick={() => selectFolder(entry.name)}>
|
||||
<span class="pb-icon">📁</span>
|
||||
<span class="pb-name">{entry.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer: current path + select -->
|
||||
<div class="pb-footer">
|
||||
<span class="pb-current" title={currentPath}>{currentPath}</span>
|
||||
<button class="pb-select-btn" onclick={confirmSelect}>Select</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.path-browser {
|
||||
position: absolute;
|
||||
top: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 20rem;
|
||||
box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
|
||||
.pb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.pb-title { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
|
||||
|
||||
.pb-close {
|
||||
background: none; border: none; color: var(--ctp-overlay1); cursor: pointer;
|
||||
font-size: 0.75rem; padding: 0.125rem 0.25rem; border-radius: 0.25rem;
|
||||
}
|
||||
.pb-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
|
||||
|
||||
.pb-shortcuts {
|
||||
display: flex; gap: 0.25rem; padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.pb-shortcut {
|
||||
padding: 0.125rem 0.375rem; font-size: 0.625rem; border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0); border: none; color: var(--ctp-subtext0);
|
||||
cursor: pointer; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-shortcut:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
|
||||
.pb-breadcrumbs {
|
||||
display: flex; align-items: center; gap: 0.125rem;
|
||||
padding: 0.25rem 0.5rem; flex-wrap: wrap;
|
||||
font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.pb-sep { color: var(--ctp-overlay0); }
|
||||
|
||||
.pb-crumb {
|
||||
background: none; border: none; color: var(--ctp-blue); cursor: pointer;
|
||||
font-size: 0.6875rem; padding: 0; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-crumb:hover { text-decoration: underline; }
|
||||
|
||||
.pb-filter {
|
||||
margin: 0.25rem 0.5rem; padding: 0.25rem 0.375rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text);
|
||||
font-size: 0.75rem; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-filter:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.pb-filter::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.pb-list {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.pb-list::-webkit-scrollbar { width: 0.25rem; }
|
||||
.pb-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.125rem; }
|
||||
|
||||
.pb-entry {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
width: 100%; padding: 0.25rem 0.5rem;
|
||||
background: none; border: none; color: var(--ctp-text);
|
||||
cursor: pointer; font-size: 0.75rem; text-align: left;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-entry:hover { background: var(--ctp-surface0); }
|
||||
|
||||
.pb-icon { font-size: 0.875rem; flex-shrink: 0; }
|
||||
.pb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pb-loading, .pb-error, .pb-empty {
|
||||
padding: 1rem; text-align: center; font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
.pb-error { color: var(--ctp-red); }
|
||||
|
||||
.pb-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem; border-top: 1px solid var(--ctp-surface0);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pb-current {
|
||||
font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
|
||||
.pb-select-btn {
|
||||
padding: 0.25rem 0.625rem; font-size: 0.75rem; border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
|
||||
border: 1px solid var(--ctp-blue); color: var(--ctp-blue);
|
||||
cursor: pointer; font-family: var(--ui-font-family); flex-shrink: 0;
|
||||
}
|
||||
.pb-select-btn:hover { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
|
||||
</style>
|
||||
626
ui-electrobun/src/mainview/ProjectWizard.svelte
Normal file
626
ui-electrobun/src/mainview/ProjectWizard.svelte
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
<script lang="ts">
|
||||
import { t } from './i18n.svelte.ts';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import PathBrowser from './PathBrowser.svelte';
|
||||
|
||||
interface ProjectResult {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
autoStart?: boolean;
|
||||
groupId?: string;
|
||||
useWorktrees?: boolean;
|
||||
shell?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (project: ProjectResult) => void;
|
||||
groupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
let { onClose, onCreated, groupId, groups }: Props = $props();
|
||||
|
||||
// ── Wizard step ─────────────────────────────────────────────
|
||||
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
|
||||
|
||||
let step = $state(1);
|
||||
|
||||
// ── Step 1: Source ──────────────────────────────────────────
|
||||
let sourceType = $state<SourceType>('local');
|
||||
let localPath = $state('');
|
||||
let repoUrl = $state('');
|
||||
let cloneTarget = $state('');
|
||||
let githubRepo = $state('');
|
||||
let selectedTemplate = $state('');
|
||||
let remoteHost = $state('');
|
||||
let remoteUser = $state('');
|
||||
let remotePath = $state('');
|
||||
|
||||
// Path validation
|
||||
let pathValid = $state<'idle' | 'checking' | 'valid' | 'invalid' | 'not-dir'>('idle');
|
||||
let isGitRepo = $state(false);
|
||||
let gitBranch = $state('');
|
||||
let showBrowser = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ── Step 2: Configure ──────────────────────────────────────
|
||||
let projectName = $state('');
|
||||
let selectedBranch = $state('');
|
||||
let branches = $state<string[]>([]);
|
||||
let useWorktrees = $state(false);
|
||||
let selectedGroupId = $state(groupId);
|
||||
let projectIcon = $state('📁');
|
||||
let shellChoice = $state('bash');
|
||||
|
||||
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
|
||||
const EMOJI_GRID = ['📁', '🚀', '⚡', '🔧', '🌐', '📦', '🎯', '💡', '🔬', '🎨', '📊', '🛡️', '🤖', '🧪', '🏗️', '📝'];
|
||||
|
||||
// ── Step 3: Agent ──────────────────────────────────────────
|
||||
let provider = $state<'claude' | 'codex' | 'ollama'>('claude');
|
||||
let model = $state('');
|
||||
let permissionMode = $state('default');
|
||||
let systemPrompt = $state('');
|
||||
let autoStart = $state(false);
|
||||
let cloning = $state(false);
|
||||
|
||||
// ── Templates ───────────────────────────────────────────────
|
||||
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
|
||||
$effect(() => {
|
||||
if (sourceType === 'template' && templates.length === 0) {
|
||||
appRpc.request['project.templates']({}).then(r => {
|
||||
if (r?.templates) templates = r.templates;
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Path validation (debounced 300ms) ──────────────────────
|
||||
function validatePath(p: string) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (!p.trim()) { pathValid = 'idle'; isGitRepo = false; return; }
|
||||
pathValid = 'checking';
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await appRpc.request['files.statEx']({ path: p });
|
||||
if (!result?.exists) {
|
||||
pathValid = 'invalid';
|
||||
isGitRepo = false;
|
||||
} else if (!result.isDirectory) {
|
||||
pathValid = 'not-dir';
|
||||
isGitRepo = false;
|
||||
} else {
|
||||
pathValid = 'valid';
|
||||
isGitRepo = result.isGitRepo;
|
||||
gitBranch = result.gitBranch ?? '';
|
||||
// Auto-populate name from folder name
|
||||
if (!projectName) {
|
||||
const parts = p.replace(/\/+$/, '').split('/');
|
||||
projectName = parts[parts.length - 1] || '';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
pathValid = 'invalid';
|
||||
isGitRepo = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (sourceType === 'local') validatePath(localPath);
|
||||
});
|
||||
|
||||
// ── Branch loading ─────────────────────────────────────────
|
||||
async function loadBranches(repoPath: string) {
|
||||
try {
|
||||
const result = await appRpc.request['git.branches']({ path: repoPath });
|
||||
if (result?.branches) {
|
||||
branches = result.branches;
|
||||
selectedBranch = result.current || '';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Step navigation ────────────────────────────────────────
|
||||
let step1Valid = $derived(() => {
|
||||
switch (sourceType) {
|
||||
case 'local': return pathValid === 'valid';
|
||||
case 'git-clone': return repoUrl.trim().length > 0 && cloneTarget.trim().length > 0;
|
||||
case 'github': return /^[\w.-]+\/[\w.-]+$/.test(githubRepo.trim());
|
||||
case 'template': return selectedTemplate !== '';
|
||||
case 'remote': return remoteHost.trim().length > 0 && remoteUser.trim().length > 0 && remotePath.trim().length > 0;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
|
||||
async function goToStep2() {
|
||||
if (sourceType === 'git-clone') {
|
||||
cloning = true;
|
||||
try {
|
||||
const result = await appRpc.request['git.clone']({
|
||||
url: repoUrl.trim(),
|
||||
target: cloneTarget.trim(),
|
||||
});
|
||||
if (!result?.ok) {
|
||||
cloning = false;
|
||||
return; // stay on step 1
|
||||
}
|
||||
localPath = cloneTarget.trim();
|
||||
isGitRepo = true;
|
||||
} catch {
|
||||
cloning = false;
|
||||
return;
|
||||
}
|
||||
cloning = false;
|
||||
} else if (sourceType === 'github') {
|
||||
const url = `https://github.com/${githubRepo.trim()}.git`;
|
||||
const target = `~/projects/${githubRepo.trim().split('/')[1] || 'project'}`;
|
||||
cloning = true;
|
||||
try {
|
||||
const result = await appRpc.request['git.clone']({ url, target });
|
||||
if (!result?.ok) { cloning = false; return; }
|
||||
localPath = target;
|
||||
isGitRepo = true;
|
||||
} catch { cloning = false; return; }
|
||||
cloning = false;
|
||||
}
|
||||
|
||||
// Auto-populate name
|
||||
if (!projectName && localPath) {
|
||||
const parts = localPath.replace(/\/+$/, '').split('/');
|
||||
projectName = parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
// Load branches if git repo
|
||||
if (isGitRepo && localPath) {
|
||||
await loadBranches(localPath);
|
||||
}
|
||||
|
||||
step = 2;
|
||||
}
|
||||
|
||||
function goToStep3() { step = 3; }
|
||||
function goBack() { step = Math.max(1, step - 1); }
|
||||
|
||||
async function createProject() {
|
||||
const cwd = sourceType === 'remote'
|
||||
? `ssh://${remoteUser}@${remoteHost}:${remotePath}`
|
||||
: localPath.trim();
|
||||
|
||||
const project: ProjectResult = {
|
||||
id: `p-${Date.now()}`,
|
||||
name: projectName.trim() || 'Untitled',
|
||||
cwd,
|
||||
provider,
|
||||
model: model || undefined,
|
||||
systemPrompt: systemPrompt || undefined,
|
||||
autoStart,
|
||||
groupId: selectedGroupId,
|
||||
useWorktrees: useWorktrees || undefined,
|
||||
shell: shellChoice,
|
||||
icon: projectIcon,
|
||||
};
|
||||
|
||||
onCreated(project);
|
||||
}
|
||||
|
||||
function handleBrowserSelect(path: string) {
|
||||
localPath = path;
|
||||
showBrowser = false;
|
||||
validatePath(path);
|
||||
}
|
||||
|
||||
// Validation indicator
|
||||
function validationIcon(state: typeof pathValid): string {
|
||||
switch (state) {
|
||||
case 'valid': return '✓';
|
||||
case 'invalid': return '✗';
|
||||
case 'not-dir': return '⚠';
|
||||
case 'checking': return '…';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function validationColor(state: typeof pathValid): string {
|
||||
switch (state) {
|
||||
case 'valid': return 'var(--ctp-green)';
|
||||
case 'invalid': return 'var(--ctp-red)';
|
||||
case 'not-dir': return 'var(--ctp-peach)';
|
||||
default: return 'var(--ctp-overlay0)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wizard-overlay" role="dialog" aria-label={t('wizard.title' as any)}>
|
||||
<div class="wizard-panel">
|
||||
<!-- Header -->
|
||||
<div class="wz-header">
|
||||
<h2 class="wz-title">{t('wizard.title' as any)}</h2>
|
||||
<div class="wz-steps">
|
||||
{#each [1, 2, 3] as s}
|
||||
<span class="wz-step-dot" class:active={step === s} class:done={step > s}>{s}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="wz-close" onclick={onClose} aria-label={t('common.close' as any)}>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Source -->
|
||||
<div class="wz-body" style:display={step === 1 ? 'flex' : 'none'}>
|
||||
<h3 class="wz-step-title">{t('wizard.step1.title' as any)}</h3>
|
||||
|
||||
<!-- Source type radios -->
|
||||
<div class="wz-radios">
|
||||
{#each [
|
||||
{ value: 'local', label: t('wizard.step1.local' as any), icon: '📁' },
|
||||
{ value: 'git-clone', label: t('wizard.step1.gitClone' as any), icon: '🔀' },
|
||||
{ value: 'github', label: t('wizard.step1.github' as any), icon: '🐙' },
|
||||
{ value: 'template', label: t('wizard.step1.template' as any), icon: '📋' },
|
||||
{ value: 'remote', label: t('wizard.step1.remote' as any), icon: '🖥️' },
|
||||
] as opt}
|
||||
<label class="wz-radio" class:selected={sourceType === opt.value}>
|
||||
<input type="radio" name="source" value={opt.value}
|
||||
checked={sourceType === opt.value}
|
||||
onchange={() => sourceType = opt.value as SourceType} />
|
||||
<span class="wz-radio-icon">{opt.icon}</span>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Source-specific inputs -->
|
||||
<div class="wz-source-fields">
|
||||
{#if sourceType === 'local'}
|
||||
<label class="wz-label">{t('wizard.step1.pathLabel' as any)}</label>
|
||||
<div class="wz-path-row" style="position: relative;">
|
||||
<input class="wz-input" type="text"
|
||||
placeholder={t('wizard.step1.pathPlaceholder' as any)}
|
||||
bind:value={localPath}
|
||||
oninput={() => validatePath(localPath)} />
|
||||
<button class="wz-browse-btn" onclick={() => showBrowser = !showBrowser}
|
||||
aria-label={t('wizard.step1.browse' as any)}>🔍</button>
|
||||
{#if pathValid !== 'idle'}
|
||||
<span class="wz-validation" style:color={validationColor(pathValid)}>
|
||||
{validationIcon(pathValid)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if showBrowser}
|
||||
<PathBrowser onSelect={handleBrowserSelect} onClose={() => showBrowser = false} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if pathValid === 'valid' && isGitRepo}
|
||||
<span class="wz-badge git-badge">{t('wizard.step1.gitDetected' as any)} ({gitBranch})</span>
|
||||
{/if}
|
||||
{#if pathValid === 'invalid'}
|
||||
<span class="wz-hint error">{t('wizard.step1.invalidPath' as any)}</span>
|
||||
{/if}
|
||||
{#if pathValid === 'not-dir'}
|
||||
<span class="wz-hint warn">{t('wizard.step1.notDir' as any)}</span>
|
||||
{/if}
|
||||
|
||||
{:else if sourceType === 'git-clone'}
|
||||
<label class="wz-label">{t('wizard.step1.repoUrl' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="https://github.com/user/repo.git" bind:value={repoUrl} />
|
||||
<label class="wz-label">{t('wizard.step1.targetDir' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="~/projects/my-repo" bind:value={cloneTarget} />
|
||||
|
||||
{:else if sourceType === 'github'}
|
||||
<label class="wz-label">{t('wizard.step1.githubRepo' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="owner/repo" bind:value={githubRepo} />
|
||||
|
||||
{:else if sourceType === 'template'}
|
||||
<div class="wz-template-grid">
|
||||
{#each templates as tmpl}
|
||||
<button class="wz-template-card" class:selected={selectedTemplate === tmpl.id}
|
||||
onclick={() => selectedTemplate = tmpl.id}>
|
||||
<span class="wz-template-icon">{tmpl.icon}</span>
|
||||
<span class="wz-template-name">{tmpl.name}</span>
|
||||
<span class="wz-template-desc">{tmpl.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if sourceType === 'remote'}
|
||||
<label class="wz-label">{t('wizard.step1.hostLabel' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="192.168.1.100" bind:value={remoteHost} />
|
||||
<label class="wz-label">{t('wizard.step1.userLabel' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="user" bind:value={remoteUser} />
|
||||
<label class="wz-label">{t('wizard.step1.pathLabel' as any)}</label>
|
||||
<input class="wz-input" type="text" placeholder="/home/user/project" bind:value={remotePath} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 1 footer -->
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={onClose}>{t('common.cancel' as any)}</button>
|
||||
<div class="wz-footer-right">
|
||||
{#if cloning}
|
||||
<span class="wz-cloning">{t('wizard.cloning' as any)}</span>
|
||||
{/if}
|
||||
<button class="wz-btn primary" disabled={!step1Valid() || cloning} onclick={goToStep2}>
|
||||
{t('wizard.next' as any)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Configure -->
|
||||
<div class="wz-body" style:display={step === 2 ? 'flex' : 'none'}>
|
||||
<h3 class="wz-step-title">{t('wizard.step2.title' as any)}</h3>
|
||||
|
||||
<label class="wz-label">{t('wizard.step2.name' as any)}</label>
|
||||
<input class="wz-input" type="text" bind:value={projectName} placeholder="my-project" />
|
||||
|
||||
{#if isGitRepo && branches.length > 0}
|
||||
<label class="wz-label">{t('wizard.step2.branch' as any)}</label>
|
||||
<select class="wz-select" bind:value={selectedBranch}>
|
||||
{#each branches as br}
|
||||
<option value={br}>{br}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<label class="wz-toggle-row">
|
||||
<input type="checkbox" bind:checked={useWorktrees} />
|
||||
<span>{t('wizard.step2.worktree' as any)}</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="wz-label">{t('wizard.step2.group' as any)}</label>
|
||||
<select class="wz-select" bind:value={selectedGroupId}>
|
||||
{#each groups as g}
|
||||
<option value={g.id}>{g.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<label class="wz-label">{t('wizard.step2.icon' as any)}</label>
|
||||
<div class="wz-emoji-grid">
|
||||
{#each EMOJI_GRID as emoji}
|
||||
<button class="wz-emoji" class:selected={projectIcon === emoji}
|
||||
onclick={() => projectIcon = emoji}>{emoji}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">{t('wizard.step2.shell' as any)}</label>
|
||||
<select class="wz-select" bind:value={shellChoice}>
|
||||
{#each SHELLS as sh}
|
||||
<option value={sh}>{sh}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={goBack}>{t('wizard.back' as any)}</button>
|
||||
<button class="wz-btn primary" onclick={goToStep3}>{t('wizard.next' as any)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Agent -->
|
||||
<div class="wz-body" style:display={step === 3 ? 'flex' : 'none'}>
|
||||
<h3 class="wz-step-title">{t('wizard.step3.title' as any)}</h3>
|
||||
|
||||
<label class="wz-label">{t('wizard.step3.provider' as any)}</label>
|
||||
<div class="wz-segmented">
|
||||
{#each (['claude', 'codex', 'ollama'] as const) as prov}
|
||||
<button class="wz-seg-btn" class:active={provider === prov}
|
||||
onclick={() => provider = prov}>{prov}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">{t('wizard.step3.model' as any)}</label>
|
||||
<input class="wz-input" type="text" bind:value={model}
|
||||
placeholder={provider === 'claude' ? 'claude-sonnet-4-20250514' : provider === 'codex' ? 'gpt-5.4' : 'qwen3:8b'} />
|
||||
|
||||
<label class="wz-label">{t('wizard.step3.permission' as any)}</label>
|
||||
<div class="wz-segmented">
|
||||
{#each ['restricted', 'default', 'bypassPermissions'] as pm}
|
||||
<button class="wz-seg-btn" class:active={permissionMode === pm}
|
||||
onclick={() => permissionMode = pm}>{pm}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">{t('wizard.step3.systemPrompt' as any)}</label>
|
||||
<textarea class="wz-textarea" bind:value={systemPrompt} rows="3"
|
||||
placeholder="Optional system instructions..."></textarea>
|
||||
|
||||
<label class="wz-toggle-row">
|
||||
<input type="checkbox" bind:checked={autoStart} />
|
||||
<span>{t('wizard.step3.autoStart' as any)}</span>
|
||||
</label>
|
||||
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={goBack}>{t('wizard.back' as any)}</button>
|
||||
<div class="wz-footer-right">
|
||||
<button class="wz-btn ghost" onclick={createProject}>{t('wizard.skip' as any)}</button>
|
||||
<button class="wz-btn primary" onclick={createProject}>{t('wizard.create' as any)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wizard-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.wizard-panel {
|
||||
width: min(32rem, 90vw);
|
||||
max-height: 85vh;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.75rem;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
|
||||
}
|
||||
|
||||
.wz-header {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.wz-title { font-size: 1rem; font-weight: 700; color: var(--ctp-text); margin: 0; flex: 1; }
|
||||
|
||||
.wz-steps { display: flex; gap: 0.375rem; }
|
||||
|
||||
.wz-step-dot {
|
||||
width: 1.5rem; height: 1.5rem; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.6875rem; font-weight: 600;
|
||||
background: var(--ctp-surface0); color: var(--ctp-subtext0);
|
||||
border: 1.5px solid var(--ctp-surface1);
|
||||
}
|
||||
.wz-step-dot.active { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.wz-step-dot.done { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border-color: var(--ctp-green); color: var(--ctp-green); }
|
||||
|
||||
.wz-close {
|
||||
background: none; border: none; color: var(--ctp-overlay1); cursor: pointer;
|
||||
font-size: 0.875rem; padding: 0.25rem; border-radius: 0.25rem;
|
||||
}
|
||||
.wz-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
|
||||
|
||||
.wz-body {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
flex-direction: column; gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.wz-step-title { font-size: 0.875rem; font-weight: 600; color: var(--ctp-text); margin: 0 0 0.25rem; }
|
||||
|
||||
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
|
||||
|
||||
.wz-input, .wz-select, .wz-textarea {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text);
|
||||
font-size: 0.8125rem; font-family: var(--ui-font-family);
|
||||
width: 100%;
|
||||
}
|
||||
.wz-input:focus, .wz-select:focus, .wz-textarea:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.wz-input::placeholder, .wz-textarea::placeholder { color: var(--ctp-overlay0); }
|
||||
.wz-textarea { resize: vertical; min-height: 3rem; }
|
||||
.wz-select { cursor: pointer; }
|
||||
|
||||
.wz-radios { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
|
||||
.wz-radio {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem; border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1); cursor: pointer;
|
||||
font-size: 0.75rem; color: var(--ctp-subtext0);
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.wz-radio:hover { border-color: var(--ctp-overlay1); color: var(--ctp-text); }
|
||||
.wz-radio.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); color: var(--ctp-text); }
|
||||
.wz-radio input[type="radio"] { display: none; }
|
||||
.wz-radio-icon { font-size: 1rem; }
|
||||
|
||||
.wz-path-row { display: flex; gap: 0.375rem; align-items: center; }
|
||||
.wz-path-row .wz-input { flex: 1; }
|
||||
|
||||
.wz-browse-btn {
|
||||
padding: 0.375rem; background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
cursor: pointer; font-size: 0.875rem; flex-shrink: 0;
|
||||
}
|
||||
.wz-browse-btn:hover { background: var(--ctp-surface1); }
|
||||
|
||||
.wz-validation { font-size: 0.875rem; font-weight: 700; flex-shrink: 0; }
|
||||
|
||||
.wz-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem; border-radius: 0.25rem;
|
||||
font-size: 0.6875rem; width: fit-content;
|
||||
}
|
||||
.git-badge {
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
color: var(--ctp-green); border: 1px solid color-mix(in srgb, var(--ctp-green) 30%, transparent);
|
||||
}
|
||||
|
||||
.wz-hint { font-size: 0.6875rem; }
|
||||
.wz-hint.error { color: var(--ctp-red); }
|
||||
.wz-hint.warn { color: var(--ctp-peach); }
|
||||
|
||||
.wz-source-fields { display: flex; flex-direction: column; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
|
||||
.wz-template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.375rem; }
|
||||
|
||||
.wz-template-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
|
||||
padding: 0.75rem 0.5rem; border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
|
||||
cursor: pointer; text-align: center;
|
||||
}
|
||||
.wz-template-card:hover { border-color: var(--ctp-overlay1); }
|
||||
.wz-template-card.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
|
||||
.wz-template-icon { font-size: 1.5rem; }
|
||||
.wz-template-name { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
|
||||
.wz-template-desc { font-size: 0.625rem; color: var(--ctp-subtext0); }
|
||||
|
||||
.wz-emoji-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
|
||||
.wz-emoji {
|
||||
width: 2rem; height: 2rem; border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
|
||||
cursor: pointer; font-size: 1rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.wz-emoji:hover { border-color: var(--ctp-overlay1); }
|
||||
.wz-emoji.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 15%, transparent); }
|
||||
|
||||
.wz-toggle-row {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.8125rem; color: var(--ctp-text); cursor: pointer;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.wz-segmented { display: flex; gap: 0; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
|
||||
.wz-seg-btn {
|
||||
flex: 1; padding: 0.375rem 0.5rem; font-size: 0.75rem;
|
||||
background: var(--ctp-surface0); border: none; color: var(--ctp-subtext0);
|
||||
cursor: pointer; font-family: var(--ui-font-family);
|
||||
border-right: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
.wz-seg-btn:last-child { border-right: none; }
|
||||
.wz-seg-btn:hover { color: var(--ctp-text); }
|
||||
.wz-seg-btn.active { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); color: var(--ctp-blue); }
|
||||
|
||||
.wz-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding-top: 0.75rem; margin-top: auto;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.wz-footer-right { display: flex; gap: 0.375rem; align-items: center; }
|
||||
|
||||
.wz-cloning { font-size: 0.75rem; color: var(--ctp-blue); font-style: italic; }
|
||||
|
||||
.wz-btn {
|
||||
padding: 0.375rem 0.75rem; border-radius: 0.25rem;
|
||||
font-size: 0.8125rem; font-weight: 500; cursor: pointer;
|
||||
font-family: var(--ui-font-family); border: 1px solid transparent;
|
||||
}
|
||||
.wz-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.wz-btn.primary {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
|
||||
border-color: var(--ctp-blue); color: var(--ctp-blue);
|
||||
}
|
||||
.wz-btn.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
|
||||
|
||||
.wz-btn.secondary {
|
||||
background: transparent; border-color: var(--ctp-surface1); color: var(--ctp-subtext0);
|
||||
}
|
||||
.wz-btn.secondary:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
|
||||
.wz-btn.ghost {
|
||||
background: transparent; border: none; color: var(--ctp-subtext0);
|
||||
}
|
||||
.wz-btn.ghost:hover { color: var(--ctp-text); }
|
||||
</style>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, never>;
|
||||
response: {
|
||||
templates: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Window control RPC ─────────────────────────────────────────────────────
|
||||
|
||||
/** Minimize the main window. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue