feat(electrobun): project wizard phases 1-5 (WIP)
- sanitize.ts: input sanitization (trim, control chars, path traversal) - provider-scanner.ts: detect Claude/Codex/Ollama/Gemini availability - model-fetcher.ts: live model lists from 4 provider APIs - ModelConfigPanel.svelte: per-provider config (thinking, effort, sandbox, temperature) - WizardStep1-3.svelte: split wizard into composable steps - CustomDropdown/Checkbox/Radio: themed UI components - provider-handlers.ts: provider.scan + provider.models RPC - Wire providers into wizard step 3 (live detection + model lists) - Replace native selects in 5 settings panels with CustomDropdown
This commit is contained in:
parent
b7fc3a0f9b
commit
d4014a193d
25 changed files with 2112 additions and 759 deletions
10
ui-electrobun/package-lock.json
generated
10
ui-electrobun/package-lock.json
generated
|
|
@ -30,6 +30,7 @@
|
|||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"electrobun": "latest",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -2168,6 +2169,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-svelte": {
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
||||
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"dev": true,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"electrobun": "latest",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Git RPC handlers — branch listing, clone operations.
|
||||
* Git RPC handlers — branch listing, clone, probe, and template scaffolding.
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
|
@ -98,5 +98,118 @@ export function createGitHandlers() {
|
|||
});
|
||||
});
|
||||
},
|
||||
|
||||
"git.probe": async ({ url }: { url: string }) => {
|
||||
if (!url || (!url.includes("/") && !url.includes(":"))) {
|
||||
return { ok: false, branches: [], defaultBranch: '', error: "Invalid URL" };
|
||||
}
|
||||
|
||||
return new Promise<{
|
||||
ok: boolean; branches: string[]; defaultBranch: string; error?: string;
|
||||
}>((resolve) => {
|
||||
const proc = spawn("git", ["ls-remote", "--heads", "--symref", url], {
|
||||
stdio: "pipe",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({ ok: false, branches: [], defaultBranch: '', error: stderr.trim() || 'Probe failed' });
|
||||
return;
|
||||
}
|
||||
const branches: string[] = [];
|
||||
let defaultBranch = 'main';
|
||||
|
||||
for (const line of stdout.split("\n")) {
|
||||
// Parse symref for HEAD
|
||||
const symMatch = line.match(/^ref: refs\/heads\/(\S+)\s+HEAD/);
|
||||
if (symMatch) { defaultBranch = symMatch[1]; continue; }
|
||||
|
||||
// Parse branch refs
|
||||
const refMatch = line.match(/\trefs\/heads\/(.+)$/);
|
||||
if (refMatch && !branches.includes(refMatch[1])) {
|
||||
branches.push(refMatch[1]);
|
||||
}
|
||||
}
|
||||
resolve({ ok: true, branches, defaultBranch });
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({ ok: false, branches: [], defaultBranch: '', error: err.message });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
"project.createFromTemplate": async ({
|
||||
templateId,
|
||||
targetDir,
|
||||
projectName,
|
||||
}: {
|
||||
templateId: string;
|
||||
targetDir: string;
|
||||
projectName: string;
|
||||
}) => {
|
||||
const resolved = path.resolve(targetDir.replace(/^~/, process.env.HOME ?? ""));
|
||||
const projectDir = path.join(resolved, projectName);
|
||||
|
||||
// Don't overwrite existing directory
|
||||
try {
|
||||
fs.statSync(projectDir);
|
||||
return { ok: false, path: '', error: "Directory already exists" };
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const scaffolds: Record<string, Record<string, string>> = {
|
||||
blank: {
|
||||
'README.md': `# ${projectName}\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
'web-app': {
|
||||
'index.html': `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>${projectName}</title>\n <link rel="stylesheet" href="style.css" />\n</head>\n<body>\n <h1>${projectName}</h1>\n <script src="main.js"></script>\n</body>\n</html>\n`,
|
||||
'style.css': `body {\n font-family: system-ui, sans-serif;\n margin: 2rem;\n}\n`,
|
||||
'main.js': `console.log('${projectName} loaded');\n`,
|
||||
'README.md': `# ${projectName}\n\nA web application.\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
'api-server': {
|
||||
'index.ts': `const server = Bun.serve({\n port: 3000,\n fetch(req) {\n const url = new URL(req.url);\n if (url.pathname === '/health') {\n return Response.json({ status: 'ok' });\n }\n return Response.json({ message: 'Hello from ${projectName}' });\n },\n});\nconsole.log(\`Server running on \${server.url}\`);\n`,
|
||||
'README.md': `# ${projectName}\n\nA Bun HTTP API server.\n\n## Run\n\n\`\`\`bash\nbun run index.ts\n\`\`\`\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
'package.json': `{\n "name": "${projectName}",\n "version": "0.1.0",\n "scripts": {\n "start": "bun run index.ts",\n "dev": "bun --watch index.ts"\n }\n}\n`,
|
||||
},
|
||||
'cli-tool': {
|
||||
'cli.ts': `#!/usr/bin/env bun\nconst args = process.argv.slice(2);\nif (args.includes('--help') || args.includes('-h')) {\n console.log('Usage: ${projectName} [options]');\n console.log(' --help, -h Show this help');\n console.log(' --version Show version');\n process.exit(0);\n}\nif (args.includes('--version')) {\n console.log('${projectName} 0.1.0');\n process.exit(0);\n}\nconsole.log('Hello from ${projectName}!');\n`,
|
||||
'README.md': `# ${projectName}\n\nA command-line tool.\n\n## Run\n\n\`\`\`bash\nbun run cli.ts --help\n\`\`\`\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
};
|
||||
|
||||
const files = scaffolds[templateId] ?? scaffolds['blank'];
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(projectDir, name), content);
|
||||
}
|
||||
|
||||
// Initialize git repo
|
||||
try {
|
||||
execSync('git init', { cwd: projectDir, timeout: 5000 });
|
||||
} catch {
|
||||
// Non-fatal — project created without git
|
||||
}
|
||||
|
||||
return { ok: true, path: projectDir };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, path: '', error };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
ui-electrobun/src/bun/handlers/provider-handlers.ts
Normal file
30
ui-electrobun/src/bun/handlers/provider-handlers.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Provider RPC handlers — scanning + model fetching.
|
||||
*/
|
||||
|
||||
import { scanAllProviders } from '../provider-scanner.ts';
|
||||
import { fetchModelsForProvider } from '../model-fetcher.ts';
|
||||
|
||||
export function createProviderHandlers() {
|
||||
return {
|
||||
'provider.scan': async () => {
|
||||
try {
|
||||
const providers = await scanAllProviders();
|
||||
return { providers };
|
||||
} catch (err) {
|
||||
console.error('[provider.scan]', err);
|
||||
return { providers: [] };
|
||||
}
|
||||
},
|
||||
|
||||
'provider.models': async ({ provider }: { provider: string }) => {
|
||||
try {
|
||||
const models = await fetchModelsForProvider(provider);
|
||||
return { models };
|
||||
} catch (err) {
|
||||
console.error('[provider.models]', err);
|
||||
return { models: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ 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";
|
||||
import { createProviderHandlers } from "./handlers/provider-handlers.ts";
|
||||
|
||||
/** Current app version — sourced from electrobun.config.ts at build time. */
|
||||
const APP_VERSION = "0.0.1";
|
||||
|
|
@ -101,6 +102,7 @@ const searchHandlers = createSearchHandlers(searchDb);
|
|||
const pluginHandlers = createPluginHandlers();
|
||||
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
|
||||
const gitHandlers = createGitHandlers();
|
||||
const providerHandlers = createProviderHandlers();
|
||||
|
||||
// ── RPC definition ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -127,6 +129,8 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
...remoteHandlers,
|
||||
// Git
|
||||
...gitHandlers,
|
||||
// Providers
|
||||
...providerHandlers,
|
||||
|
||||
// Native folder picker dialog via zenity (proper GTK folder chooser)
|
||||
"files.pickDirectory": async ({ startingFolder }) => {
|
||||
|
|
|
|||
107
ui-electrobun/src/bun/model-fetcher.ts
Normal file
107
ui-electrobun/src/bun/model-fetcher.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Model fetcher — retrieves available models from each provider's API.
|
||||
*
|
||||
* Each function returns a sorted list of model IDs.
|
||||
* Network errors return empty arrays (non-fatal).
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const TIMEOUT = 8000;
|
||||
|
||||
export async function fetchClaudeModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch('https://api.anthropic.com/v1/models', {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { data?: Array<{ id: string; display_name?: string }> };
|
||||
return (data.data ?? [])
|
||||
.map(m => ({ id: m.id, name: m.display_name ?? m.id, provider: 'claude' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCodexModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { data?: Array<{ id: string }> };
|
||||
return (data.data ?? [])
|
||||
.map(m => ({ id: m.id, name: m.id, provider: 'codex' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOllamaModels(): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/tags', {
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { models?: Array<{ name: string; model?: string }> };
|
||||
return (data.models ?? [])
|
||||
.map(m => ({ id: m.name, name: m.name, provider: 'ollama' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGeminiModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT) },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as {
|
||||
models?: Array<{ name: string; displayName?: string }>;
|
||||
};
|
||||
return (data.models ?? [])
|
||||
.map(m => ({
|
||||
id: m.name.replace('models/', ''),
|
||||
name: m.displayName ?? m.name,
|
||||
provider: 'gemini',
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models for a specific provider.
|
||||
*/
|
||||
export async function fetchModelsForProvider(
|
||||
provider: string,
|
||||
): Promise<ModelInfo[]> {
|
||||
switch (provider) {
|
||||
case 'claude': return fetchClaudeModels();
|
||||
case 'codex': return fetchCodexModels();
|
||||
case 'ollama': return fetchOllamaModels();
|
||||
case 'gemini': return fetchGeminiModels();
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
107
ui-electrobun/src/bun/provider-scanner.ts
Normal file
107
ui-electrobun/src/bun/provider-scanner.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Provider scanner — detects which AI providers are available on this machine.
|
||||
*
|
||||
* Checks environment variables and CLI tool availability.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export interface ProviderScanResult {
|
||||
id: string;
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
hasCli: boolean;
|
||||
cliPath: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
function whichSync(bin: string): string | null {
|
||||
try {
|
||||
return execSync(`which ${bin}`, { encoding: 'utf8', timeout: 3000 }).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVersion(bin: string): string | null {
|
||||
try {
|
||||
const out = execSync(`${bin} --version`, { encoding: 'utf8', timeout: 5000 }).trim();
|
||||
// Extract first version-like string
|
||||
const match = out.match(/(\d+\.\d+[\w.-]*)/);
|
||||
return match ? match[1] : out.slice(0, 40);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanClaude(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const cliPath = whichSync('claude');
|
||||
return {
|
||||
id: 'claude',
|
||||
available: hasApiKey || !!cliPath,
|
||||
hasApiKey,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('claude') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanCodex(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
const cliPath = whichSync('codex');
|
||||
return {
|
||||
id: 'codex',
|
||||
available: hasApiKey || !!cliPath,
|
||||
hasApiKey,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('codex') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanOllama(): Promise<ProviderScanResult> {
|
||||
const cliPath = whichSync('ollama');
|
||||
let serverUp = false;
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/version', {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
serverUp = res.ok;
|
||||
} catch {
|
||||
// ECONNREFUSED or timeout — server not running
|
||||
}
|
||||
return {
|
||||
id: 'ollama',
|
||||
available: serverUp || !!cliPath,
|
||||
hasApiKey: false,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('ollama') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanGemini(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
||||
return {
|
||||
id: 'gemini',
|
||||
available: hasApiKey,
|
||||
hasApiKey,
|
||||
hasCli: false,
|
||||
cliPath: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all known providers. Returns results for each.
|
||||
*/
|
||||
export async function scanAllProviders(): Promise<ProviderScanResult[]> {
|
||||
const [claude, codex, ollama, gemini] = await Promise.all([
|
||||
scanClaude(),
|
||||
scanCodex(),
|
||||
scanOllama(),
|
||||
scanGemini(),
|
||||
]);
|
||||
return [claude, codex, ollama, gemini];
|
||||
}
|
||||
|
|
@ -78,9 +78,9 @@
|
|||
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;
|
||||
useWorktrees?: boolean; shell?: string; icon?: string; color?: string;
|
||||
}) {
|
||||
const accent = ACCENTS[PROJECTS.length % ACCENTS.length];
|
||||
const accent = result.color || ACCENTS[PROJECTS.length % ACCENTS.length];
|
||||
const project: Project = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
|
|
@ -512,6 +512,7 @@
|
|||
onCreated={handleWizardCreated}
|
||||
groupId={activeGroupId}
|
||||
groups={groups.map(g => ({ id: g.id, name: g.name }))}
|
||||
existingNames={PROJECTS.map(p => p.name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
332
ui-electrobun/src/mainview/ModelConfigPanel.svelte
Normal file
332
ui-electrobun/src/mainview/ModelConfigPanel.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<script lang="ts">
|
||||
import type { ProviderId } from './provider-capabilities';
|
||||
import CustomDropdown from './ui/CustomDropdown.svelte';
|
||||
import CustomRadio from './ui/CustomRadio.svelte';
|
||||
|
||||
interface Props {
|
||||
provider: ProviderId;
|
||||
model: string;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
let { provider, model, config, onChange }: Props = $props();
|
||||
|
||||
// ── Claude config ──────────────────────────────────────
|
||||
let claudeThinking = $state<'disabled' | 'enabled' | 'adaptive'>((config.thinking as string) ?? 'disabled');
|
||||
let claudeEffort = $state((config.effort as string) ?? 'medium');
|
||||
let claudeTemp = $state<number>((config.temperature as number) ?? 1.0);
|
||||
let claudeMaxTokens = $state<number>((config.max_tokens as number) ?? 8192);
|
||||
|
||||
// Temperature is locked to 1.0 when thinking=enabled
|
||||
let claudeTempLocked = $derived(claudeThinking === 'enabled');
|
||||
|
||||
// ── Codex config ───────────────────────────────────────
|
||||
let codexSandbox = $state((config.sandbox as string) ?? 'workspace-write');
|
||||
let codexApproval = $state((config.approval as string) ?? 'on-request');
|
||||
let codexReasoning = $state((config.reasoning as string) ?? 'medium');
|
||||
|
||||
// ── Ollama config ──────────────────────────────────────
|
||||
let ollamaTemp = $state<number>((config.temperature as number) ?? 0.8);
|
||||
let ollamaCtx = $state<number>((config.num_ctx as number) ?? 32768);
|
||||
let ollamaPredict = $state<number>((config.num_predict as number) ?? 0);
|
||||
let ollamaTopK = $state<number>((config.top_k as number) ?? 40);
|
||||
let ollamaTopP = $state<number>((config.top_p as number) ?? 0.9);
|
||||
let ollamaCtxWarn = $derived(ollamaCtx < 8192);
|
||||
|
||||
// ── Gemini config ──────────────────────────────────────
|
||||
let geminiTemp = $state<number>((config.temperature as number) ?? 1.0);
|
||||
let geminiThinkingMode = $state<'level' | 'budget'>((config.thinkingMode as string as 'level' | 'budget') ?? 'level');
|
||||
let geminiThinkingLevel = $state((config.thinkingLevel as string) ?? 'medium');
|
||||
let geminiThinkingBudget = $state<number>((config.thinkingBudget as number) ?? 8192);
|
||||
let geminiMaxOutput = $state<number>((config.maxOutputTokens as number) ?? 8192);
|
||||
|
||||
// ── Emit changes ───────────────────────────────────────
|
||||
function emitClaude() {
|
||||
onChange({
|
||||
thinking: claudeThinking,
|
||||
effort: claudeThinking === 'adaptive' ? claudeEffort : undefined,
|
||||
temperature: claudeTempLocked ? 1.0 : claudeTemp,
|
||||
max_tokens: claudeMaxTokens,
|
||||
});
|
||||
}
|
||||
|
||||
function emitCodex() {
|
||||
onChange({ sandbox: codexSandbox, approval: codexApproval, reasoning: codexReasoning });
|
||||
}
|
||||
|
||||
function emitOllama() {
|
||||
onChange({
|
||||
temperature: ollamaTemp,
|
||||
num_ctx: ollamaCtx,
|
||||
num_predict: ollamaPredict || undefined,
|
||||
top_k: ollamaTopK,
|
||||
top_p: ollamaTopP,
|
||||
});
|
||||
}
|
||||
|
||||
function emitGemini() {
|
||||
onChange({
|
||||
temperature: geminiTemp,
|
||||
thinkingMode: geminiThinkingMode,
|
||||
thinkingLevel: geminiThinkingMode === 'level' ? geminiThinkingLevel : undefined,
|
||||
thinkingBudget: geminiThinkingMode === 'budget' ? geminiThinkingBudget : undefined,
|
||||
maxOutputTokens: geminiMaxOutput,
|
||||
});
|
||||
}
|
||||
|
||||
const THINKING_OPTIONS = [
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
{ value: 'enabled', label: 'Enabled' },
|
||||
{ value: 'adaptive', label: 'Adaptive' },
|
||||
];
|
||||
const EFFORT_ITEMS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'max', label: 'Max' },
|
||||
];
|
||||
const SANDBOX_ITEMS = ['read-only', 'workspace-write', 'danger-full-access'];
|
||||
const APPROVAL_ITEMS = ['untrusted', 'on-request', 'never'];
|
||||
const REASONING_ITEMS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
|
||||
const GEMINI_LEVEL_ITEMS = [
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="mcp-root">
|
||||
{#if provider === 'claude'}
|
||||
<!-- Claude: thinking mode -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Thinking mode</span>
|
||||
<CustomRadio
|
||||
options={THINKING_OPTIONS}
|
||||
selected={claudeThinking}
|
||||
name="claude-thinking"
|
||||
onChange={v => { claudeThinking = v as typeof claudeThinking; emitClaude(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Effort (only when adaptive) -->
|
||||
<div class="mcp-field" style:display={claudeThinking === 'adaptive' ? 'flex' : 'none'}>
|
||||
<span class="mcp-label">Effort level</span>
|
||||
<CustomDropdown
|
||||
items={EFFORT_ITEMS}
|
||||
selected={claudeEffort}
|
||||
onSelect={v => { claudeEffort = v; emitClaude(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Temperature -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">
|
||||
Temperature
|
||||
{#if claudeTempLocked}
|
||||
<span class="mcp-hint" title="Temperature is locked to 1.0 when thinking is enabled">(locked)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="mcp-slider-row">
|
||||
<input type="range" class="mcp-slider" min="0" max="2" step="0.1"
|
||||
value={claudeTempLocked ? 1.0 : claudeTemp}
|
||||
disabled={claudeTempLocked}
|
||||
oninput={e => { claudeTemp = parseFloat((e.target as HTMLInputElement).value); emitClaude(); }} />
|
||||
<span class="mcp-slider-val">{claudeTempLocked ? '1.0' : claudeTemp.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max tokens -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Max tokens</span>
|
||||
<input class="mcp-input" type="number" min="1" max="200000" step="1024"
|
||||
value={claudeMaxTokens}
|
||||
onchange={e => { claudeMaxTokens = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitClaude(); }} />
|
||||
</div>
|
||||
|
||||
{:else if provider === 'codex'}
|
||||
<!-- Codex: sandbox mode -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Sandbox mode</span>
|
||||
<div class="mcp-seg">
|
||||
{#each SANDBOX_ITEMS as s}
|
||||
<button class="mcp-seg-btn" class:active={codexSandbox === s}
|
||||
onclick={() => { codexSandbox = s; emitCodex(); }}>{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex: approval policy -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Approval policy</span>
|
||||
<div class="mcp-seg">
|
||||
{#each APPROVAL_ITEMS as a}
|
||||
<button class="mcp-seg-btn" class:active={codexApproval === a}
|
||||
onclick={() => { codexApproval = a; emitCodex(); }}>{a}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex: reasoning effort -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Reasoning effort</span>
|
||||
<div class="mcp-seg">
|
||||
{#each REASONING_ITEMS as r}
|
||||
<button class="mcp-seg-btn" class:active={codexReasoning === r}
|
||||
onclick={() => { codexReasoning = r; emitCodex(); }}>{r}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if provider === 'ollama'}
|
||||
<!-- Ollama: temperature -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Temperature</span>
|
||||
<div class="mcp-slider-row">
|
||||
<input type="range" class="mcp-slider" min="0" max="2" step="0.1"
|
||||
value={ollamaTemp}
|
||||
oninput={e => { ollamaTemp = parseFloat((e.target as HTMLInputElement).value); emitOllama(); }} />
|
||||
<span class="mcp-slider-val">{ollamaTemp.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ollama: num_ctx -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">
|
||||
Context window (num_ctx)
|
||||
{#if ollamaCtxWarn}
|
||||
<span class="mcp-warn">Low context may cause truncation</span>
|
||||
{/if}
|
||||
</span>
|
||||
<input class="mcp-input" type="number" min="512" max="131072" step="1024"
|
||||
value={ollamaCtx}
|
||||
onchange={e => { ollamaCtx = parseInt((e.target as HTMLInputElement).value, 10) || 32768; emitOllama(); }} />
|
||||
</div>
|
||||
|
||||
<!-- Ollama: num_predict -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Max predict (num_predict, 0 = unlimited)</span>
|
||||
<input class="mcp-input" type="number" min="0" max="131072" step="256"
|
||||
value={ollamaPredict}
|
||||
onchange={e => { ollamaPredict = parseInt((e.target as HTMLInputElement).value, 10) || 0; emitOllama(); }} />
|
||||
</div>
|
||||
|
||||
<!-- Ollama: top_k -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Top-K</span>
|
||||
<input class="mcp-input" type="number" min="1" max="200" step="1"
|
||||
value={ollamaTopK}
|
||||
onchange={e => { ollamaTopK = parseInt((e.target as HTMLInputElement).value, 10) || 40; emitOllama(); }} />
|
||||
</div>
|
||||
|
||||
<!-- Ollama: top_p -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Top-P</span>
|
||||
<div class="mcp-slider-row">
|
||||
<input type="range" class="mcp-slider" min="0" max="1" step="0.05"
|
||||
value={ollamaTopP}
|
||||
oninput={e => { ollamaTopP = parseFloat((e.target as HTMLInputElement).value); emitOllama(); }} />
|
||||
<span class="mcp-slider-val">{ollamaTopP.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if provider === 'gemini'}
|
||||
<!-- Gemini: temperature -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Temperature</span>
|
||||
<div class="mcp-slider-row">
|
||||
<input type="range" class="mcp-slider" min="0" max="2" step="0.1"
|
||||
value={geminiTemp}
|
||||
oninput={e => { geminiTemp = parseFloat((e.target as HTMLInputElement).value); emitGemini(); }} />
|
||||
<span class="mcp-slider-val">{geminiTemp.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini: thinking mode toggle -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Thinking configuration</span>
|
||||
<div class="mcp-seg" style="max-width: 14rem;">
|
||||
<button class="mcp-seg-btn" class:active={geminiThinkingMode === 'level'}
|
||||
onclick={() => { geminiThinkingMode = 'level'; emitGemini(); }}>Level</button>
|
||||
<button class="mcp-seg-btn" class:active={geminiThinkingMode === 'budget'}
|
||||
onclick={() => { geminiThinkingMode = 'budget'; emitGemini(); }}>Budget</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level mode -->
|
||||
<div class="mcp-field" style:display={geminiThinkingMode === 'level' ? 'flex' : 'none'}>
|
||||
<span class="mcp-label">Thinking level</span>
|
||||
<div class="mcp-seg">
|
||||
{#each GEMINI_LEVEL_ITEMS as lvl}
|
||||
<button class="mcp-seg-btn" class:active={geminiThinkingLevel === lvl.value}
|
||||
onclick={() => { geminiThinkingLevel = lvl.value; emitGemini(); }}>{lvl.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget mode -->
|
||||
<div class="mcp-field" style:display={geminiThinkingMode === 'budget' ? 'flex' : 'none'}>
|
||||
<span class="mcp-label">Thinking budget (tokens)</span>
|
||||
<input class="mcp-input" type="number" min="0" max="65536" step="1024"
|
||||
value={geminiThinkingBudget}
|
||||
onchange={e => { geminiThinkingBudget = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitGemini(); }} />
|
||||
</div>
|
||||
|
||||
<!-- Gemini: max output tokens -->
|
||||
<div class="mcp-field">
|
||||
<span class="mcp-label">Max output tokens</span>
|
||||
<input class="mcp-input" type="number" min="1" max="65536" step="1024"
|
||||
value={geminiMaxOutput}
|
||||
onchange={e => { geminiMaxOutput = parseInt((e.target as HTMLInputElement).value, 10) || 8192; emitGemini(); }} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mcp-root { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.375rem; }
|
||||
|
||||
.mcp-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.mcp-label {
|
||||
font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0);
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mcp-hint {
|
||||
font-size: 0.6875rem; color: var(--ctp-overlay0); font-weight: 400; font-style: italic;
|
||||
}
|
||||
|
||||
.mcp-warn {
|
||||
font-size: 0.6875rem; color: var(--ctp-peach); font-weight: 400;
|
||||
}
|
||||
|
||||
.mcp-input {
|
||||
padding: 0.375rem 0.5rem; width: 100%;
|
||||
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);
|
||||
appearance: textfield; -moz-appearance: textfield;
|
||||
}
|
||||
.mcp-input::-webkit-inner-spin-button,
|
||||
.mcp-input::-webkit-outer-spin-button { -webkit-appearance: none; appearance: none; }
|
||||
.mcp-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
||||
.mcp-slider-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.mcp-slider { flex: 1; accent-color: var(--ctp-blue); }
|
||||
.mcp-slider:disabled { opacity: 0.4; }
|
||||
.mcp-slider-val { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: right; }
|
||||
|
||||
.mcp-seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||||
.mcp-seg-btn {
|
||||
flex: 1; padding: 0.25rem 0.375rem; background: var(--ctp-surface0); border: none;
|
||||
color: var(--ctp-overlay1); font-size: 0.6875rem; cursor: pointer;
|
||||
font-family: var(--ui-font-family);
|
||||
border-right: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
.mcp-seg-btn:last-child { border-right: none; }
|
||||
.mcp-seg-btn:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||||
.mcp-seg-btn.active {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
|
||||
color: var(--ctp-blue); font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,20 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { t } from './i18n.svelte.ts';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import PathBrowser from './PathBrowser.svelte';
|
||||
import { sanitize, sanitizePath, sanitizeUrl, isValidGitUrl, isValidGithubRepo } from './sanitize.ts';
|
||||
import { getProjects } from './workspace-store.svelte.ts';
|
||||
import WizardStep1 from './WizardStep1.svelte';
|
||||
import WizardStep2 from './WizardStep2.svelte';
|
||||
import WizardStep3 from './WizardStep3.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;
|
||||
id: string; name: string; cwd: string; provider?: string; model?: string;
|
||||
systemPrompt?: string; autoStart?: boolean; groupId?: string;
|
||||
useWorktrees?: boolean; shell?: string; icon?: string; color?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -28,55 +23,62 @@
|
|||
|
||||
// ── Wizard step ─────────────────────────────────────────────
|
||||
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
|
||||
type AuthMethod = 'password' | 'key' | 'agent' | 'config';
|
||||
|
||||
let step = $state(1);
|
||||
|
||||
// ── Step 1: Source ──────────────────────────────────────────
|
||||
// ── Step 1 state ────────────────────────────────────────────
|
||||
let sourceType = $state<SourceType>('local');
|
||||
let localPath = $state('');
|
||||
let repoUrl = $state('');
|
||||
let cloneTarget = $state('');
|
||||
let githubRepo = $state('');
|
||||
let selectedTemplate = $state('');
|
||||
let templateTargetDir = $state('~/projects');
|
||||
let remoteHost = $state('');
|
||||
let remoteUser = $state('');
|
||||
let remotePath = $state('');
|
||||
|
||||
// Path validation
|
||||
let remoteAuthMethod = $state<AuthMethod>('agent');
|
||||
let remotePassword = $state('');
|
||||
let remoteKeyPath = $state('~/.ssh/id_ed25519');
|
||||
let remoteSshfs = $state(false);
|
||||
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;
|
||||
let gitProbeStatus = $state<'idle' | 'probing' | 'ok' | 'error'>('idle');
|
||||
let gitProbeBranches = $state<string[]>([]);
|
||||
let githubInfo = $state<{ stars: number; description: string; defaultBranch: string } | null>(null);
|
||||
let githubLoading = $state(false);
|
||||
let cloning = $state(false);
|
||||
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
|
||||
|
||||
// ── Step 2: Configure ──────────────────────────────────────
|
||||
// ── Step 2 state ────────────────────────────────────────────
|
||||
let projectName = $state('');
|
||||
let nameError = $state('');
|
||||
let selectedBranch = $state('');
|
||||
let branches = $state<string[]>([]);
|
||||
let useWorktrees = $state(false);
|
||||
let selectedGroupId = $state(groupId);
|
||||
let projectIcon = $state('📁');
|
||||
let projectIcon = $state('Terminal');
|
||||
let projectColor = $state('var(--ctp-blue)');
|
||||
let shellChoice = $state('bash');
|
||||
|
||||
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
|
||||
let branchDDOpen = $state(false);
|
||||
let groupDDOpen = $state(false);
|
||||
let shellDDOpen = $state(false);
|
||||
let branchLabel = $derived(selectedBranch || 'main');
|
||||
let groupLabel = $derived(groups.find(g => g.id === selectedGroupId)?.name ?? 'Select');
|
||||
function closeAllDD() { branchDDOpen = false; groupDDOpen = false; shellDDOpen = false; }
|
||||
const EMOJI_GRID = ['📁', '🚀', '⚡', '🔧', '🌐', '📦', '🎯', '💡', '🔬', '🎨', '📊', '🛡️', '🤖', '🧪', '🏗️', '📝'];
|
||||
|
||||
// ── Step 3: Agent ──────────────────────────────────────────
|
||||
let provider = $state<'claude' | 'codex' | 'ollama'>('claude');
|
||||
// ── Step 3 state ────────────────────────────────────────────
|
||||
let provider = $state<string>('claude');
|
||||
let model = $state('');
|
||||
let permissionMode = $state('default');
|
||||
let systemPrompt = $state('');
|
||||
let autoStart = $state(false);
|
||||
let cloning = $state(false);
|
||||
let detectedProviders = $state<Array<{ id: string; available: boolean; hasApiKey: boolean; hasCli: boolean; cliPath: string | null; version: string | null }>>([]);
|
||||
let providerModels = $state<Array<{ id: string; name: string; provider: string }>>([]);
|
||||
let modelsLoading = $state(false);
|
||||
|
||||
// ── Templates ───────────────────────────────────────────────
|
||||
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
|
||||
// ── Debounce timers ─────────────────────────────────────────
|
||||
let pathTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let githubTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ── Templates loading ───────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (sourceType === 'template' && templates.length === 0) {
|
||||
appRpc.request['project.templates']({}).then(r => {
|
||||
|
|
@ -85,82 +87,108 @@
|
|||
}
|
||||
});
|
||||
|
||||
// ── Path validation (debounced 300ms) ──────────────────────
|
||||
function validatePath(p: string) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (!p.trim()) { pathValid = 'idle'; isGitRepo = false; return; }
|
||||
// ── Path validation (debounced) ─────────────────────────────
|
||||
$effect(() => {
|
||||
if (sourceType === 'local') {
|
||||
if (pathTimer) clearTimeout(pathTimer);
|
||||
if (!localPath.trim()) { pathValid = 'idle'; isGitRepo = false; return; }
|
||||
pathValid = 'checking';
|
||||
debounceTimer = setTimeout(async () => {
|
||||
pathTimer = 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;
|
||||
}
|
||||
const result = await appRpc.request['files.statEx']({ path: localPath });
|
||||
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 ?? ''; if (!projectName) { const parts = localPath.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) {
|
||||
// ── Git probe (debounced) ───────────────────────────────────
|
||||
$effect(() => {
|
||||
if (sourceType === 'git-clone' && repoUrl.trim()) {
|
||||
if (probeTimer) clearTimeout(probeTimer);
|
||||
gitProbeStatus = 'probing';
|
||||
probeTimer = setTimeout(async () => {
|
||||
if (!isValidGitUrl(repoUrl)) { gitProbeStatus = 'error'; return; }
|
||||
try {
|
||||
const result = await appRpc.request['git.branches']({ path: repoPath });
|
||||
if (result?.branches) {
|
||||
branches = result.branches;
|
||||
selectedBranch = result.current || '';
|
||||
const r = await appRpc.request['git.probe']({ url: repoUrl.trim() });
|
||||
if (r?.ok) { gitProbeStatus = 'ok'; gitProbeBranches = r.branches; } else { gitProbeStatus = 'error'; }
|
||||
} catch { gitProbeStatus = 'error'; }
|
||||
}, 600);
|
||||
} else if (sourceType === 'git-clone') { gitProbeStatus = 'idle'; }
|
||||
});
|
||||
|
||||
// ── GitHub validation (debounced) ───────────────────────────
|
||||
$effect(() => {
|
||||
if (sourceType === 'github' && githubRepo.trim()) {
|
||||
if (githubTimer) clearTimeout(githubTimer);
|
||||
githubInfo = null;
|
||||
if (!isValidGithubRepo(githubRepo)) return;
|
||||
githubLoading = true;
|
||||
githubTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${githubRepo.trim()}`, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
githubInfo = { stars: data.stargazers_count ?? 0, description: data.description ?? '', defaultBranch: data.default_branch ?? 'main' };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
githubLoading = false;
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step navigation ────────────────────────────────────────
|
||||
// ── Unique name validation ──────────────────────────────────
|
||||
$effect(() => {
|
||||
const trimmed = projectName.trim().toLowerCase();
|
||||
if (!trimmed) { nameError = ''; return; }
|
||||
const existing = getProjects().map(p => p.name.toLowerCase());
|
||||
nameError = existing.includes(trimmed) ? 'A project with this name already exists' : '';
|
||||
});
|
||||
|
||||
// ── Provider scan on step 3 entry ───────────────────────────
|
||||
$effect(() => {
|
||||
if (step === 3 && detectedProviders.length === 0) {
|
||||
appRpc.request['provider.scan']({}).then(r => {
|
||||
if (r?.providers) { detectedProviders = r.providers; const first = r.providers.find(p => p.available); if (first) provider = first.id; }
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Model list on provider change ───────────────────────────
|
||||
$effect(() => {
|
||||
if (step === 3 && provider) {
|
||||
providerModels = []; modelsLoading = true;
|
||||
appRpc.request['provider.models']({ provider }).then(r => {
|
||||
if (r?.models) providerModels = r.models;
|
||||
}).catch(console.error).finally(() => { modelsLoading = false; });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 1 validation ──────────────────────────────────────
|
||||
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;
|
||||
case 'git-clone': return isValidGitUrl(repoUrl) && !!sanitizePath(cloneTarget);
|
||||
case 'github': return isValidGithubRepo(githubRepo);
|
||||
case 'template': return selectedTemplate !== '' && !!sanitizePath(templateTargetDir);
|
||||
case 'remote': return !!sanitize(remoteHost) && !!sanitize(remoteUser) && !!sanitize(remotePath);
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step navigation ────────────────────────────────────────
|
||||
async function goToStep2() {
|
||||
if (sourceType === 'git-clone') {
|
||||
const url = sanitizeUrl(repoUrl); if (!url) return;
|
||||
const target = sanitizePath(cloneTarget); if (!target) return;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
} else if (sourceType === 'github') {
|
||||
const url = `https://github.com/${githubRepo.trim()}.git`;
|
||||
|
|
@ -169,312 +197,133 @@
|
|||
try {
|
||||
const result = await appRpc.request['git.clone']({ url, target });
|
||||
if (!result?.ok) { cloning = false; return; }
|
||||
localPath = target;
|
||||
isGitRepo = true;
|
||||
localPath = target; isGitRepo = true;
|
||||
} catch { cloning = false; return; }
|
||||
cloning = false;
|
||||
} else if (sourceType === 'template') {
|
||||
const target = sanitizePath(templateTargetDir); if (!target) return;
|
||||
const name = sanitize(projectName) || selectedTemplate;
|
||||
cloning = true;
|
||||
try {
|
||||
const result = await appRpc.request['project.createFromTemplate']({ templateId: selectedTemplate, targetDir: target, projectName: name });
|
||||
if (!result?.ok) { cloning = false; return; }
|
||||
localPath = result.path; if (!projectName) projectName = name;
|
||||
} 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 (!projectName && localPath) { const parts = localPath.replace(/\/+$/, '').split('/'); projectName = parts[parts.length - 1] || ''; }
|
||||
if (isGitRepo && localPath) {
|
||||
await loadBranches(localPath);
|
||||
try { const r = await appRpc.request['git.branches']({ path: localPath }); if (r?.branches) { branches = r.branches; selectedBranch = r.current || ''; } } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
step = 2;
|
||||
}
|
||||
|
||||
function goToStep3() { step = 3; }
|
||||
function goToStep3() { if (nameError) return; step = 3; }
|
||||
function goBack() { step = Math.max(1, step - 1); }
|
||||
|
||||
async function createProject() {
|
||||
const cwd = sourceType === 'remote'
|
||||
? `ssh://${remoteUser}@${remoteHost}:${remotePath}`
|
||||
: localPath.trim();
|
||||
|
||||
const cwd = sourceType === 'remote' ? `ssh://${sanitize(remoteUser)}@${sanitize(remoteHost)}:${sanitize(remotePath)}` : (sanitizePath(localPath) || 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,
|
||||
id: `p-${Date.now()}`, name: sanitize(projectName) || 'Untitled', cwd,
|
||||
provider: provider as string, model: model || undefined, systemPrompt: systemPrompt || undefined,
|
||||
autoStart, groupId: selectedGroupId, useWorktrees: useWorktrees || undefined,
|
||||
shell: shellChoice, icon: projectIcon, color: projectColor,
|
||||
};
|
||||
|
||||
onCreated(project);
|
||||
resetState();
|
||||
}
|
||||
|
||||
function handleBrowserSelect(path: string) {
|
||||
localPath = path;
|
||||
showBrowser = false;
|
||||
validatePath(path);
|
||||
function closeWizard() { resetState(); onClose(); }
|
||||
|
||||
function resetState() {
|
||||
step = 1; sourceType = 'local'; localPath = ''; repoUrl = ''; cloneTarget = '';
|
||||
githubRepo = ''; selectedTemplate = ''; templateTargetDir = '~/projects';
|
||||
remoteHost = ''; remoteUser = ''; remotePath = '';
|
||||
remoteAuthMethod = 'agent'; remotePassword = ''; remoteKeyPath = '~/.ssh/id_ed25519'; remoteSshfs = false;
|
||||
pathValid = 'idle'; isGitRepo = false; gitBranch = '';
|
||||
gitProbeStatus = 'idle'; gitProbeBranches = []; githubInfo = null; githubLoading = false;
|
||||
cloning = false; projectName = ''; nameError = ''; selectedBranch = '';
|
||||
branches = []; useWorktrees = false; selectedGroupId = groupId;
|
||||
projectIcon = 'Terminal'; projectColor = 'var(--ctp-blue)'; shellChoice = 'bash';
|
||||
provider = 'claude'; model = ''; permissionMode = 'default';
|
||||
systemPrompt = ''; autoStart = false; providerModels = []; modelsLoading = false;
|
||||
}
|
||||
|
||||
async function handleNativeBrowse() {
|
||||
try {
|
||||
const result = await appRpc.request['files.pickDirectory']({ startingFolder: localPath || '~/' });
|
||||
if (result?.path) {
|
||||
localPath = result.path;
|
||||
validatePath(result.path);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: native dialog not available, user types path manually
|
||||
console.warn('[wizard] Native folder picker not available');
|
||||
}
|
||||
}
|
||||
|
||||
// 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)';
|
||||
// ── Field updater (passed to sub-components) ────────────────
|
||||
function handleUpdate(field: string, value: unknown) {
|
||||
const v = value as string & boolean;
|
||||
switch (field) {
|
||||
case 'sourceType': sourceType = v as SourceType; break;
|
||||
case 'localPath': localPath = v; break;
|
||||
case 'repoUrl': repoUrl = v; break;
|
||||
case 'cloneTarget': cloneTarget = v; break;
|
||||
case 'githubRepo': githubRepo = v; break;
|
||||
case 'selectedTemplate': selectedTemplate = v; break;
|
||||
case 'templateTargetDir': templateTargetDir = v; break;
|
||||
case 'remoteHost': remoteHost = v; break;
|
||||
case 'remoteUser': remoteUser = v; break;
|
||||
case 'remotePath': remotePath = v; break;
|
||||
case 'remoteAuthMethod': remoteAuthMethod = v as AuthMethod; break;
|
||||
case 'remotePassword': remotePassword = v; break;
|
||||
case 'remoteKeyPath': remoteKeyPath = v; break;
|
||||
case 'remoteSshfs': remoteSshfs = v as unknown as boolean; break;
|
||||
case 'projectName': projectName = v; break;
|
||||
case 'selectedBranch': selectedBranch = v; break;
|
||||
case 'useWorktrees': useWorktrees = v as unknown as boolean; break;
|
||||
case 'selectedGroupId': selectedGroupId = v; break;
|
||||
case 'projectIcon': projectIcon = v; break;
|
||||
case 'projectColor': projectColor = v; break;
|
||||
case 'shellChoice': shellChoice = v; break;
|
||||
case 'provider': provider = v; break;
|
||||
case 'model': model = v; break;
|
||||
case 'permissionMode': permissionMode = v; break;
|
||||
case 'systemPrompt': systemPrompt = v; break;
|
||||
case 'autoStart': autoStart = v as unknown as boolean; break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wizard-overlay" role="dialog" aria-label={t('wizard.title' as any)}>
|
||||
<div class="wizard-overlay" role="dialog" aria-label="New Project">
|
||||
<div class="wizard-panel">
|
||||
<!-- Header -->
|
||||
<div class="wz-header">
|
||||
<h2 class="wz-title">{t('wizard.title' as any)}</h2>
|
||||
<h2 class="wz-title">New Project</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>
|
||||
<button class="wz-close" onclick={closeWizard} aria-label="Close">×</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={handleNativeBrowse}
|
||||
title="Open system folder picker"
|
||||
aria-label={t('wizard.step1.browse' as any)}>📂</button>
|
||||
<button class="wz-browse-btn" onclick={() => showBrowser = !showBrowser}
|
||||
title="In-app folder browser"
|
||||
aria-label="Browse">🔍</button>
|
||||
{#if pathValid !== 'idle'}
|
||||
<span class="wz-validation" style:color={validationColor(pathValid)}>
|
||||
{validationIcon(pathValid)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style:display={showBrowser ? 'block' : 'none'} style="max-height: 16rem; max-width: 32rem; overflow-y: auto; border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; margin-top: 0.5rem;">
|
||||
<PathBrowser onSelect={handleBrowserSelect} onClose={() => showBrowser = false} />
|
||||
</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 -->
|
||||
<h3 class="wz-step-title">Source</h3>
|
||||
<WizardStep1 {sourceType} {localPath} {repoUrl} {cloneTarget} {githubRepo} {selectedTemplate} {remoteHost} {remoteUser} {remotePath} {remoteAuthMethod} {remotePassword} {remoteKeyPath} {remoteSshfs} {pathValid} {isGitRepo} {gitBranch} {gitProbeStatus} {gitProbeBranches} {githubInfo} {githubLoading} {cloning} {templates} {templateTargetDir} onUpdate={handleUpdate} />
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={onClose}>{t('common.cancel' as any)}</button>
|
||||
<button class="wz-btn secondary" onclick={closeWizard}>Cancel</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>
|
||||
{#if cloning}<span class="wz-cloning">Cloning…</span>{/if}
|
||||
<button class="wz-btn primary" disabled={!step1Valid() || cloning} onclick={goToStep2}>Next</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>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); branchDDOpen = !branchDDOpen; }}>
|
||||
<span>{branchLabel}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={branchDDOpen ? 'block' : 'none'}>
|
||||
{#each branches as br}
|
||||
<button class="wz-dd-item" class:active={selectedBranch === br}
|
||||
onclick={() => { selectedBranch = br; branchDDOpen = false; }}>{br}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); groupDDOpen = !groupDDOpen; }}>
|
||||
<span>{groupLabel}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={groupDDOpen ? 'block' : 'none'}>
|
||||
{#each groups as g}
|
||||
<button class="wz-dd-item" class:active={selectedGroupId === g.id}
|
||||
onclick={() => { selectedGroupId = g.id; groupDDOpen = false; }}>{g.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); shellDDOpen = !shellDDOpen; }}>
|
||||
<span>{shellChoice}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={shellDDOpen ? 'block' : 'none'}>
|
||||
{#each SHELLS as sh}
|
||||
<button class="wz-dd-item" class:active={shellChoice === sh}
|
||||
onclick={() => { shellChoice = sh; shellDDOpen = false; }}>{sh}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="wz-step-title">Configure</h3>
|
||||
<WizardStep2 {projectName} {nameError} {selectedBranch} {branches} {useWorktrees} {selectedGroupId} {groups} {projectIcon} {projectColor} {shellChoice} {isGitRepo} onUpdate={handleUpdate} />
|
||||
<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>
|
||||
<button class="wz-btn secondary" onclick={goBack}>Back</button>
|
||||
<button class="wz-btn primary" disabled={!!nameError} onclick={goToStep3}>Next</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>
|
||||
|
||||
<h3 class="wz-step-title">Agent</h3>
|
||||
<WizardStep3 {provider} {model} {permissionMode} {systemPrompt} {autoStart} {detectedProviders} {providerModels} {modelsLoading} onUpdate={handleUpdate} />
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={goBack}>{t('wizard.back' as any)}</button>
|
||||
<button class="wz-btn secondary" onclick={goBack}>Back</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>
|
||||
<button class="wz-btn ghost" onclick={createProject}>Skip</button>
|
||||
<button class="wz-btn primary" onclick={createProject}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -482,241 +331,27 @@
|
|||
</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);
|
||||
}
|
||||
|
||||
.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(34rem, 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 { 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 { 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-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; }
|
||||
/* Custom themed dropdown */
|
||||
.wz-dd { width: 100%; }
|
||||
.wz-dd-btn {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between;
|
||||
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);
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
|
||||
.wz-dd-menu {
|
||||
position: absolute; top: 100%; left: 0; right: 0; z-index: 20;
|
||||
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; margin-top: 0.125rem;
|
||||
max-height: 10rem; overflow-y: auto;
|
||||
box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
|
||||
}
|
||||
.wz-dd-item {
|
||||
width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none;
|
||||
color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family);
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.wz-dd-item:hover { background: var(--ctp-surface0); }
|
||||
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
|
||||
|
||||
/* Themed checkboxes */
|
||||
input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 1rem; height: 1rem;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 0.1875rem;
|
||||
background: var(--ctp-surface0);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--ctp-blue);
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.25rem; top: 0.0625rem;
|
||||
width: 0.3125rem; height: 0.5625rem;
|
||||
border: solid var(--ctp-base);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--ctp-blue);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.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 { 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 { 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 { 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 { 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 { background: transparent; border: none; color: var(--ctp-subtext0); }
|
||||
.wz-btn.ghost:hover { color: var(--ctp-text); }
|
||||
</style>
|
||||
|
|
|
|||
284
ui-electrobun/src/mainview/WizardStep1.svelte
Normal file
284
ui-electrobun/src/mainview/WizardStep1.svelte
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<script lang="ts">
|
||||
import { t } from './i18n.svelte.ts';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { sanitize, sanitizeUrl, sanitizePath, isValidGitUrl, isValidGithubRepo } from './sanitize.ts';
|
||||
import PathBrowser from './PathBrowser.svelte';
|
||||
|
||||
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
|
||||
type AuthMethod = 'password' | 'key' | 'agent' | 'config';
|
||||
type PathState = 'idle' | 'checking' | 'valid' | 'invalid' | 'not-dir';
|
||||
|
||||
interface Props {
|
||||
sourceType: SourceType;
|
||||
localPath: string;
|
||||
repoUrl: string;
|
||||
cloneTarget: string;
|
||||
githubRepo: string;
|
||||
selectedTemplate: string;
|
||||
remoteHost: string;
|
||||
remoteUser: string;
|
||||
remotePath: string;
|
||||
remoteAuthMethod: AuthMethod;
|
||||
remotePassword: string;
|
||||
remoteKeyPath: string;
|
||||
remoteSshfs: boolean;
|
||||
pathValid: PathState;
|
||||
isGitRepo: boolean;
|
||||
gitBranch: string;
|
||||
gitProbeStatus: 'idle' | 'probing' | 'ok' | 'error';
|
||||
gitProbeBranches: string[];
|
||||
githubInfo: { stars: number; description: string; defaultBranch: string } | null;
|
||||
githubLoading: boolean;
|
||||
cloning: boolean;
|
||||
templates: Array<{ id: string; name: string; description: string; icon: string }>;
|
||||
templateTargetDir: string;
|
||||
onUpdate: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
sourceType, localPath, repoUrl, cloneTarget, githubRepo,
|
||||
selectedTemplate, remoteHost, remoteUser, remotePath,
|
||||
remoteAuthMethod, remotePassword, remoteKeyPath, remoteSshfs,
|
||||
pathValid, isGitRepo, gitBranch,
|
||||
gitProbeStatus, gitProbeBranches, githubInfo, githubLoading,
|
||||
cloning, templates, templateTargetDir, onUpdate,
|
||||
}: Props = $props();
|
||||
|
||||
let showBrowser = $state(false);
|
||||
let showCloneBrowser = $state(false);
|
||||
let showTemplateBrowser = $state(false);
|
||||
let showKeyBrowser = $state(false);
|
||||
|
||||
const SOURCE_TYPES: Array<{ value: SourceType; label: string; icon: string }> = [
|
||||
{ value: 'local', label: 'Local Folder', icon: 'folder' },
|
||||
{ value: 'git-clone', label: 'Git Clone', icon: 'git-branch' },
|
||||
{ value: 'github', label: 'GitHub Repo', icon: 'github' },
|
||||
{ value: 'template', label: 'Template', icon: 'layout-template' },
|
||||
{ value: 'remote', label: 'SSH Remote', icon: 'monitor' },
|
||||
];
|
||||
|
||||
const AUTH_METHODS: Array<{ value: AuthMethod; label: string }> = [
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'key', label: 'SSH Key' },
|
||||
{ value: 'agent', label: 'SSH Agent' },
|
||||
{ value: 'config', label: 'SSH Config' },
|
||||
];
|
||||
|
||||
function validationIcon(state: PathState): string {
|
||||
switch (state) {
|
||||
case 'valid': return '\u2713';
|
||||
case 'invalid': return '\u2717';
|
||||
case 'not-dir': return '\u26A0';
|
||||
case 'checking': return '\u2026';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function validationColor(state: PathState): 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)';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNativeBrowse(target: 'local' | 'clone' | 'template' | 'key') {
|
||||
try {
|
||||
const start = target === 'local' ? localPath : target === 'clone' ? cloneTarget : target === 'template' ? templateTargetDir : remoteKeyPath;
|
||||
const result = await appRpc.request['files.pickDirectory']({ startingFolder: start || '~/' });
|
||||
if (result?.path) {
|
||||
const field = target === 'local' ? 'localPath' : target === 'clone' ? 'cloneTarget' : target === 'template' ? 'templateTargetDir' : 'remoteKeyPath';
|
||||
onUpdate(field, result.path);
|
||||
}
|
||||
} catch { /* native dialog not available */ }
|
||||
}
|
||||
|
||||
function handleBrowserSelect(field: string, path: string) {
|
||||
onUpdate(field, path);
|
||||
showBrowser = false;
|
||||
showCloneBrowser = false;
|
||||
showTemplateBrowser = false;
|
||||
showKeyBrowser = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wz-radios">
|
||||
{#each SOURCE_TYPES as opt}
|
||||
<label class="wz-radio" class:selected={sourceType === opt.value}>
|
||||
<input type="radio" name="source" value={opt.value}
|
||||
checked={sourceType === opt.value}
|
||||
onchange={() => onUpdate('sourceType', opt.value)} />
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="wz-source-fields">
|
||||
<!-- Local Folder -->
|
||||
<div style:display={sourceType === 'local' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Project directory</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="/home/user/project"
|
||||
value={localPath} oninput={(e) => onUpdate('localPath', (e.target as HTMLInputElement).value)} />
|
||||
<button class="wz-browse-btn" onclick={() => handleNativeBrowse('local')} title="System picker">📂</button>
|
||||
<button class="wz-browse-btn" onclick={() => showBrowser = !showBrowser} title="In-app browser">🔍</button>
|
||||
{#if pathValid !== 'idle'}
|
||||
<span class="wz-validation" style:color={validationColor(pathValid)}>{validationIcon(pathValid)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style:display={showBrowser ? 'block' : 'none'} class="wz-browser-wrap">
|
||||
<PathBrowser onSelect={(p) => handleBrowserSelect('localPath', p)} onClose={() => showBrowser = false} />
|
||||
</div>
|
||||
{#if pathValid === 'valid' && isGitRepo}
|
||||
<span class="wz-badge git-badge">Git repo ({gitBranch})</span>
|
||||
{/if}
|
||||
{#if pathValid === 'invalid'}<span class="wz-hint error">Path does not exist</span>{/if}
|
||||
{#if pathValid === 'not-dir'}<span class="wz-hint warn">Not a directory</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Git Clone -->
|
||||
<div style:display={sourceType === 'git-clone' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Repository URL</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="https://github.com/user/repo.git"
|
||||
value={repoUrl} oninput={(e) => onUpdate('repoUrl', (e.target as HTMLInputElement).value)} />
|
||||
{#if gitProbeStatus === 'probing'}<span class="wz-validation" style:color="var(--ctp-blue)">…</span>{/if}
|
||||
{#if gitProbeStatus === 'ok'}<span class="wz-validation" style:color="var(--ctp-green)">✓</span>{/if}
|
||||
{#if gitProbeStatus === 'error'}<span class="wz-validation" style:color="var(--ctp-red)">✗</span>{/if}
|
||||
</div>
|
||||
{#if gitProbeStatus === 'ok' && gitProbeBranches.length > 0}
|
||||
<span class="wz-hint" style="color: var(--ctp-subtext0);">{gitProbeBranches.length} branches found</span>
|
||||
{/if}
|
||||
<label class="wz-label">Target directory</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="~/projects/my-repo"
|
||||
value={cloneTarget} oninput={(e) => onUpdate('cloneTarget', (e.target as HTMLInputElement).value)} />
|
||||
<button class="wz-browse-btn" onclick={() => handleNativeBrowse('clone')}>📂</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub -->
|
||||
<div style:display={sourceType === 'github' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">GitHub repository (owner/repo)</label>
|
||||
<input class="wz-input" type="text" placeholder="owner/repo"
|
||||
value={githubRepo} oninput={(e) => onUpdate('githubRepo', (e.target as HTMLInputElement).value)} />
|
||||
{#if githubLoading}<span class="wz-hint" style="color: var(--ctp-blue);">Checking…</span>{/if}
|
||||
{#if githubInfo}
|
||||
<div class="wz-github-info">
|
||||
<span class="wz-hint" style="color: var(--ctp-yellow);">★ {githubInfo.stars}</span>
|
||||
<span class="wz-hint" style="color: var(--ctp-subtext0);">{githubInfo.description}</span>
|
||||
<span class="wz-hint" style="color: var(--ctp-green);">Default: {githubInfo.defaultBranch}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div style:display={sourceType === 'template' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Target directory</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="~/projects"
|
||||
value={templateTargetDir} oninput={(e) => onUpdate('templateTargetDir', (e.target as HTMLInputElement).value)} />
|
||||
<button class="wz-browse-btn" onclick={() => handleNativeBrowse('template')}>📂</button>
|
||||
</div>
|
||||
<div class="wz-template-grid">
|
||||
{#each templates as tmpl}
|
||||
<button class="wz-template-card" class:selected={selectedTemplate === tmpl.id}
|
||||
onclick={() => onUpdate('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>
|
||||
</div>
|
||||
|
||||
<!-- SSH Remote -->
|
||||
<div style:display={sourceType === 'remote' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Host</label>
|
||||
<input class="wz-input" type="text" placeholder="192.168.1.100"
|
||||
value={remoteHost} oninput={(e) => onUpdate('remoteHost', (e.target as HTMLInputElement).value)} />
|
||||
<label class="wz-label">User</label>
|
||||
<input class="wz-input" type="text" placeholder="user"
|
||||
value={remoteUser} oninput={(e) => onUpdate('remoteUser', (e.target as HTMLInputElement).value)} />
|
||||
<label class="wz-label">Remote path</label>
|
||||
<input class="wz-input" type="text" placeholder="/home/user/project"
|
||||
value={remotePath} oninput={(e) => onUpdate('remotePath', (e.target as HTMLInputElement).value)} />
|
||||
|
||||
<label class="wz-label">Auth method</label>
|
||||
<div class="wz-segmented">
|
||||
{#each AUTH_METHODS as m}
|
||||
<button class="wz-seg-btn" class:active={remoteAuthMethod === m.value}
|
||||
onclick={() => onUpdate('remoteAuthMethod', m.value)}>{m.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div style:display={remoteAuthMethod === 'password' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Password</label>
|
||||
<input class="wz-input" type="password" placeholder="••••••"
|
||||
value={remotePassword} oninput={(e) => onUpdate('remotePassword', (e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div style:display={remoteAuthMethod === 'key' ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Key file path</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="~/.ssh/id_ed25519"
|
||||
value={remoteKeyPath} oninput={(e) => onUpdate('remoteKeyPath', (e.target as HTMLInputElement).value)} />
|
||||
<button class="wz-browse-btn" onclick={() => handleNativeBrowse('key')}>📂</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="wz-toggle-row">
|
||||
<input type="checkbox" checked={remoteSshfs}
|
||||
onchange={(e) => onUpdate('remoteSshfs', (e.target as HTMLInputElement).checked)} />
|
||||
<span>Mount via SSHFS</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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-source-fields { display: flex; flex-direction: column; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
.wz-field-col { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); }
|
||||
.wz-input { 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 { outline: none; border-color: var(--ctp-blue); }
|
||||
.wz-input::placeholder { color: var(--ctp-overlay0); }
|
||||
.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-browser-wrap { max-height: 16rem; max-width: 32rem; overflow-y: auto; border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; margin-top: 0.5rem; }
|
||||
.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-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-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-github-info { display: flex; flex-direction: column; gap: 0.125rem; margin-top: 0.25rem; }
|
||||
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
|
||||
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
|
||||
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
</style>
|
||||
149
ui-electrobun/src/mainview/WizardStep2.svelte
Normal file
149
ui-electrobun/src/mainview/WizardStep2.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { PROJECT_ICONS, ACCENT_COLORS } from './wizard-icons.ts';
|
||||
import {
|
||||
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
|
||||
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
|
||||
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
nameError: string;
|
||||
selectedBranch: string;
|
||||
branches: string[];
|
||||
useWorktrees: boolean;
|
||||
selectedGroupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
projectIcon: string;
|
||||
projectColor: string;
|
||||
shellChoice: string;
|
||||
isGitRepo: boolean;
|
||||
onUpdate: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
projectName, nameError, selectedBranch, branches, useWorktrees,
|
||||
selectedGroupId, groups, projectIcon, projectColor, shellChoice,
|
||||
isGitRepo, onUpdate,
|
||||
}: Props = $props();
|
||||
|
||||
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
|
||||
let branchDDOpen = $state(false);
|
||||
let groupDDOpen = $state(false);
|
||||
let shellDDOpen = $state(false);
|
||||
|
||||
function closeAllDD() { branchDDOpen = false; groupDDOpen = false; shellDDOpen = false; }
|
||||
|
||||
let branchLabel = $derived(selectedBranch || 'main');
|
||||
let groupLabel = $derived(groups.find(g => g.id === selectedGroupId)?.name ?? 'Select');
|
||||
|
||||
const ICON_MAP: Record<string, typeof Terminal> = {
|
||||
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
|
||||
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
|
||||
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
|
||||
};
|
||||
</script>
|
||||
|
||||
<label class="wz-label">Project name</label>
|
||||
<input class="wz-input" class:error={!!nameError} type="text"
|
||||
value={projectName} oninput={(e) => onUpdate('projectName', (e.target as HTMLInputElement).value)}
|
||||
placeholder="my-project" />
|
||||
{#if nameError}<span class="wz-hint error">{nameError}</span>{/if}
|
||||
|
||||
{#if isGitRepo && branches.length > 0}
|
||||
<label class="wz-label">Branch</label>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); branchDDOpen = !branchDDOpen; }}>
|
||||
<span>{branchLabel}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={branchDDOpen ? 'block' : 'none'}>
|
||||
{#each branches as br}
|
||||
<button class="wz-dd-item" class:active={selectedBranch === br}
|
||||
onclick={() => { onUpdate('selectedBranch', br); branchDDOpen = false; }}>{br}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label class="wz-toggle-row">
|
||||
<input type="checkbox" checked={useWorktrees}
|
||||
onchange={(e) => onUpdate('useWorktrees', (e.target as HTMLInputElement).checked)} />
|
||||
<span>Use worktrees</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="wz-label">Group</label>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); groupDDOpen = !groupDDOpen; }}>
|
||||
<span>{groupLabel}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={groupDDOpen ? 'block' : 'none'}>
|
||||
{#each groups as g}
|
||||
<button class="wz-dd-item" class:active={selectedGroupId === g.id}
|
||||
onclick={() => { onUpdate('selectedGroupId', g.id); groupDDOpen = false; }}>{g.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="wz-label">Icon</label>
|
||||
<div class="wz-icon-grid">
|
||||
{#each PROJECT_ICONS as ic}
|
||||
<button class="wz-icon-btn" class:selected={projectIcon === ic.name}
|
||||
onclick={() => onUpdate('projectIcon', ic.name)} title={ic.label}>
|
||||
{#if ICON_MAP[ic.name]}
|
||||
<svelte:component this={ICON_MAP[ic.name]} size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">Color</label>
|
||||
<div class="wz-color-grid">
|
||||
{#each ACCENT_COLORS as c}
|
||||
<button class="wz-color-dot" class:selected={projectColor === `var(${c.var})`}
|
||||
style:background={`var(${c.var})`}
|
||||
onclick={() => onUpdate('projectColor', `var(${c.var})`)}
|
||||
title={c.name}></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">Shell</label>
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => { closeAllDD(); shellDDOpen = !shellDDOpen; }}>
|
||||
<span>{shellChoice}</span><span class="wz-dd-chev">▾</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={shellDDOpen ? 'block' : 'none'}>
|
||||
{#each SHELLS as sh}
|
||||
<button class="wz-dd-item" class:active={shellChoice === sh}
|
||||
onclick={() => { onUpdate('shellChoice', sh); shellDDOpen = false; }}>{sh}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
|
||||
.wz-input { 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 { outline: none; border-color: var(--ctp-blue); }
|
||||
.wz-input.error { border-color: var(--ctp-red); }
|
||||
.wz-input::placeholder { color: var(--ctp-overlay0); }
|
||||
.wz-hint { font-size: 0.6875rem; }
|
||||
.wz-hint.error { color: var(--ctp-red); }
|
||||
.wz-dd { width: 100%; }
|
||||
.wz-dd-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; 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); cursor: pointer; text-align: left; }
|
||||
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
|
||||
.wz-dd-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 20; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; margin-top: 0.125rem; max-height: 10rem; overflow-y: auto; box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent); }
|
||||
.wz-dd-item { width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
|
||||
.wz-dd-item:hover { background: var(--ctp-surface0); }
|
||||
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, 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-icon-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.wz-icon-btn { width: 2rem; height: 2rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0); cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--ctp-subtext0); }
|
||||
.wz-icon-btn:hover { border-color: var(--ctp-overlay1); color: var(--ctp-text); }
|
||||
.wz-icon-btn.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 15%, transparent); color: var(--ctp-blue); }
|
||||
.wz-color-grid { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.wz-color-dot { width: 1.5rem; height: 1.5rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color 0.12s, transform 0.12s; }
|
||||
.wz-color-dot:hover { transform: scale(1.15); }
|
||||
.wz-color-dot.selected { border-color: var(--ctp-text); box-shadow: 0 0 0 1px var(--ctp-surface0); }
|
||||
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
|
||||
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
|
||||
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
</style>
|
||||
154
ui-electrobun/src/mainview/WizardStep3.svelte
Normal file
154
ui-electrobun/src/mainview/WizardStep3.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
hasCli: boolean;
|
||||
cliPath: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
provider: string;
|
||||
model: string;
|
||||
permissionMode: string;
|
||||
systemPrompt: string;
|
||||
autoStart: boolean;
|
||||
detectedProviders: ProviderInfo[];
|
||||
providerModels: ModelInfo[];
|
||||
modelsLoading: boolean;
|
||||
onUpdate: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
provider, model, permissionMode, systemPrompt, autoStart,
|
||||
detectedProviders, providerModels, modelsLoading, onUpdate,
|
||||
}: Props = $props();
|
||||
|
||||
let modelDDOpen = $state(false);
|
||||
|
||||
let availableProviders = $derived(
|
||||
detectedProviders.length > 0
|
||||
? detectedProviders.filter(p => p.available)
|
||||
: [{ id: 'claude' }, { id: 'codex' }, { id: 'ollama' }] as ProviderInfo[]
|
||||
);
|
||||
|
||||
let modelLabel = $derived(model || 'Select model...');
|
||||
|
||||
function providerBadge(p: ProviderInfo): string {
|
||||
const parts: string[] = [];
|
||||
if (p.hasApiKey) parts.push('API Key');
|
||||
if (p.hasCli) parts.push('CLI');
|
||||
if (p.version) parts.push(`v${p.version}`);
|
||||
return parts.join(' \u00b7 ') || '';
|
||||
}
|
||||
|
||||
function defaultPlaceholder(): string {
|
||||
switch (provider) {
|
||||
case 'claude': return 'claude-sonnet-4-20250514';
|
||||
case 'codex': return 'gpt-5.4';
|
||||
case 'ollama': return 'qwen3:8b';
|
||||
case 'gemini': return 'gemini-2.5-flash';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="wz-label">AI Provider</label>
|
||||
{#if detectedProviders.length > 0 && availableProviders.length === 0}
|
||||
<div class="wz-hint warn">No providers detected. Install Claude CLI, set OPENAI_API_KEY, or start Ollama.</div>
|
||||
{/if}
|
||||
<div class="wz-provider-grid">
|
||||
{#each availableProviders as p}
|
||||
<button class="wz-provider-card" class:active={provider === p.id}
|
||||
onclick={() => onUpdate('provider', p.id)}>
|
||||
<span class="wz-provider-name">{p.id}</span>
|
||||
{#if 'hasApiKey' in p}
|
||||
<span class="wz-provider-badge">{providerBadge(p as ProviderInfo)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">Model</label>
|
||||
{#if providerModels.length > 0}
|
||||
<div class="wz-dd" style="position:relative;">
|
||||
<button class="wz-dd-btn" onclick={() => modelDDOpen = !modelDDOpen}>
|
||||
<span>{modelLabel}</span>
|
||||
<span class="wz-dd-chev">{modelsLoading ? '\u2026' : '\u25BE'}</span>
|
||||
</button>
|
||||
<div class="wz-dd-menu" style:display={modelDDOpen ? 'block' : 'none'}>
|
||||
{#each providerModels as m}
|
||||
<button class="wz-dd-item" class:active={model === m.id}
|
||||
onclick={() => { onUpdate('model', m.id); modelDDOpen = false; }}>
|
||||
{m.name || m.id}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<input class="wz-input" type="text" value={model}
|
||||
oninput={(e) => onUpdate('model', (e.target as HTMLInputElement).value)}
|
||||
placeholder={defaultPlaceholder()} />
|
||||
{#if modelsLoading}<span class="wz-hint" style="color: var(--ctp-blue);">Loading models…</span>{/if}
|
||||
{/if}
|
||||
|
||||
<label class="wz-label">Permission mode</label>
|
||||
<div class="wz-segmented">
|
||||
{#each ['restricted', 'default', 'bypassPermissions'] as pm}
|
||||
<button class="wz-seg-btn" class:active={permissionMode === pm}
|
||||
onclick={() => onUpdate('permissionMode', pm)}>{pm}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="wz-label">System prompt</label>
|
||||
<textarea class="wz-textarea" value={systemPrompt}
|
||||
oninput={(e) => onUpdate('systemPrompt', (e.target as HTMLTextAreaElement).value)}
|
||||
rows="3" placeholder="Optional system instructions..."></textarea>
|
||||
|
||||
<label class="wz-toggle-row">
|
||||
<input type="checkbox" checked={autoStart}
|
||||
onchange={(e) => onUpdate('autoStart', (e.target as HTMLInputElement).checked)} />
|
||||
<span>Auto-start agent on create</span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
|
||||
.wz-input, .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-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-hint { font-size: 0.6875rem; }
|
||||
.wz-hint.warn { color: var(--ctp-peach); }
|
||||
.wz-provider-grid { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.wz-provider-card { display: flex; flex-direction: column; align-items: center; gap: 0.125rem; padding: 0.5rem 0.75rem; border-radius: 0.375rem; border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0); cursor: pointer; min-width: 5rem; }
|
||||
.wz-provider-card:hover { border-color: var(--ctp-overlay1); }
|
||||
.wz-provider-card.active { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
|
||||
.wz-provider-name { font-size: 0.8125rem; font-weight: 600; color: var(--ctp-text); text-transform: capitalize; }
|
||||
.wz-provider-badge { font-size: 0.5625rem; color: var(--ctp-subtext0); }
|
||||
.wz-dd { width: 100%; }
|
||||
.wz-dd-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; 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); cursor: pointer; text-align: left; }
|
||||
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
|
||||
.wz-dd-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 20; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; margin-top: 0.125rem; max-height: 12rem; overflow-y: auto; box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent); }
|
||||
.wz-dd-item { width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
|
||||
.wz-dd-item:hover { background: var(--ctp-surface0); }
|
||||
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
|
||||
.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-toggle-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--ctp-text); cursor: pointer; margin-top: 0.25rem; }
|
||||
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
|
||||
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
|
||||
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export type ProviderId = 'claude' | 'codex' | 'ollama';
|
||||
export type ProviderId = 'claude' | 'codex' | 'ollama' | 'gemini';
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
upload: boolean;
|
||||
|
|
@ -38,6 +38,15 @@ export const PROVIDER_CAPABILITIES: Record<ProviderId, ProviderCapabilities> = {
|
|||
defaultModel: 'qwen3:8b',
|
||||
label: 'Ollama',
|
||||
},
|
||||
gemini: {
|
||||
upload: true,
|
||||
context: true,
|
||||
web: true,
|
||||
slash: false,
|
||||
images: true,
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
label: 'Gemini',
|
||||
},
|
||||
};
|
||||
|
||||
export function getCapabilities(provider: string): ProviderCapabilities {
|
||||
|
|
|
|||
62
ui-electrobun/src/mainview/sanitize.ts
Normal file
62
ui-electrobun/src/mainview/sanitize.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Input sanitization utilities for the Project Wizard.
|
||||
*
|
||||
* All user-supplied strings pass through these before use.
|
||||
*/
|
||||
|
||||
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/g;
|
||||
const PATH_TRAVERSAL_RE = /\.\.\//;
|
||||
const GIT_URL_RE = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/).+/;
|
||||
const GITHUB_REPO_RE = /^[\w][\w.-]*\/[\w][\w.-]*$/;
|
||||
|
||||
/**
|
||||
* General string sanitizer: trim, strip control characters, reject path traversal.
|
||||
* Returns null if the input is rejected (contains `../`).
|
||||
*/
|
||||
export function sanitize(str: string): string | null {
|
||||
const trimmed = str.trim().replace(CONTROL_CHAR_RE, '');
|
||||
if (PATH_TRAVERSAL_RE.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a URL string. Returns null if the URL is malformed or contains traversal.
|
||||
*/
|
||||
export function sanitizeUrl(url: string): string | null {
|
||||
const clean = sanitize(url);
|
||||
if (!clean) return null;
|
||||
try {
|
||||
// Allow git@ SSH URLs and standard HTTP(S)
|
||||
if (GIT_URL_RE.test(clean)) return clean;
|
||||
new URL(clean);
|
||||
return clean;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filesystem path. Rejects `../` traversal attempts.
|
||||
* Allows `~` prefix for home directory expansion (done server-side).
|
||||
*/
|
||||
export function sanitizePath(path: string): string | null {
|
||||
const clean = sanitize(path);
|
||||
if (!clean) return null;
|
||||
// Reject null bytes (extra safety)
|
||||
if (clean.includes('\0')) return null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a GitHub `owner/repo` string.
|
||||
*/
|
||||
export function isValidGithubRepo(input: string): boolean {
|
||||
return GITHUB_REPO_RE.test(input.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Git clone URL (http(s), git@, ssh://, git://).
|
||||
*/
|
||||
export function isValidGitUrl(input: string): boolean {
|
||||
return GIT_URL_RE.test(input.trim());
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
{ id: 'claude', label: 'Claude', desc: 'Anthropic — claude-opus/sonnet/haiku' },
|
||||
{ id: 'codex', label: 'Codex', desc: 'OpenAI — gpt-5.4' },
|
||||
{ id: 'ollama', label: 'Ollama', desc: 'Local — qwen3, llama3, etc.' },
|
||||
{ id: 'gemini', label: 'Gemini', desc: 'Google — gemini-2.5-pro' },
|
||||
];
|
||||
|
||||
let defaultShell = $state('/bin/bash');
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
claude: { enabled: true, model: 'claude-opus-4-5' },
|
||||
codex: { enabled: false, model: 'gpt-5.4' },
|
||||
ollama: { enabled: false, model: 'qwen3:8b' },
|
||||
gemini: { enabled: false, model: 'gemini-2.5-pro' },
|
||||
});
|
||||
|
||||
let expandedProvider = $state<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { fontStore } from '../font-store.svelte.ts';
|
||||
import { t, getLocale, setLocale, AVAILABLE_LOCALES } from '../i18n.svelte.ts';
|
||||
import ThemeEditor from './ThemeEditor.svelte';
|
||||
import CustomDropdown from '../ui/CustomDropdown.svelte';
|
||||
|
||||
const UI_FONTS = [
|
||||
{ value: '', label: 'System Default' },
|
||||
|
|
@ -47,18 +48,13 @@
|
|||
$effect(() => { termFont = fontStore.termFontFamily; });
|
||||
$effect(() => { termFontSize = fontStore.termFontSize; });
|
||||
|
||||
// ── Dropdown open state ────────────────────────────────────────────────────
|
||||
let themeOpen = $state(false);
|
||||
let uiFontOpen = $state(false);
|
||||
let termFontOpen = $state(false);
|
||||
let langOpen = $state(false);
|
||||
// ── Dropdown open state (managed by CustomDropdown now) ────────────────────
|
||||
|
||||
// ── Language ──────────────────────────────────────────────────────────────
|
||||
let currentLocale = $derived(getLocale());
|
||||
let langLabel = $derived(AVAILABLE_LOCALES.find(l => l.tag === currentLocale)?.nativeLabel ?? 'English');
|
||||
|
||||
function selectLang(tag: string): void {
|
||||
langOpen = false;
|
||||
setLocale(tag);
|
||||
}
|
||||
|
||||
|
|
@ -69,26 +65,27 @@
|
|||
]);
|
||||
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
|
||||
|
||||
// ── Derived labels ─────────────────────────────────────────────────────────
|
||||
let themeLabel = $derived(allThemes.find(t => t.id === themeId)?.label ?? 'Catppuccin Mocha');
|
||||
let uiFontLabel = $derived(UI_FONTS.find(f => f.value === uiFont)?.label ?? 'System Default');
|
||||
let termFontLabel = $derived(TERM_FONTS.find(f => f.value === termFont)?.label ?? 'Default (JetBrains Mono)');
|
||||
// ── Dropdown items for CustomDropdown ──────────────────────────────────────
|
||||
let themeItems = $derived(allThemes.map(t => ({ value: t.id, label: t.label, group: t.group })));
|
||||
let uiFontItems = UI_FONTS.map(f => ({ value: f.value, label: f.label }));
|
||||
let termFontItems = TERM_FONTS.map(f => ({ value: f.value, label: f.label }));
|
||||
let langItems = AVAILABLE_LOCALES.map(l => ({ value: l.tag, label: l.nativeLabel }));
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function selectTheme(id: ThemeId): void {
|
||||
themeId = id; themeOpen = false;
|
||||
themeId = id;
|
||||
themeStore.setTheme(id);
|
||||
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
|
||||
}
|
||||
|
||||
function selectUIFont(value: string): void {
|
||||
uiFont = value; uiFontOpen = false;
|
||||
uiFont = value;
|
||||
fontStore.setUIFont(value, uiFontSize);
|
||||
}
|
||||
|
||||
function selectTermFont(value: string): void {
|
||||
termFont = value; termFontOpen = false;
|
||||
termFont = value;
|
||||
fontStore.setTermFont(value, termFontSize);
|
||||
}
|
||||
|
||||
|
|
@ -117,10 +114,7 @@
|
|||
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
|
||||
}
|
||||
|
||||
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; langOpen = false; }
|
||||
function handleOutsideClick(e: MouseEvent): void {
|
||||
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
|
||||
}
|
||||
// CustomDropdown handles its own open/close state
|
||||
|
||||
async function deleteCustomTheme(id: string) {
|
||||
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
|
||||
|
|
@ -148,8 +142,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && closeAll()}>
|
||||
<div class="section">
|
||||
|
||||
{#if showEditor}
|
||||
<ThemeEditor
|
||||
|
|
@ -162,58 +155,27 @@
|
|||
|
||||
<h3 class="sh">{t('settings.theme')}</h3>
|
||||
<div class="field">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn" onclick={() => { themeOpen = !themeOpen; uiFontOpen = false; termFontOpen = false; }}>
|
||||
{themeLabel}
|
||||
<svg class="chev" class:open={themeOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if themeOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each allGroups as group}
|
||||
<li class="dd-group-label" role="presentation">{group}</li>
|
||||
{#each allThemes.filter(t => t.group === group) as t}
|
||||
<li class="dd-item" class:sel={themeId === t.id}
|
||||
role="option" aria-selected={themeId === t.id} tabindex="0"
|
||||
onclick={() => selectTheme(t.id)}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTheme(t.id)}
|
||||
>
|
||||
<span class="dd-item-label">{t.label}</span>
|
||||
{#if t.group === 'Custom'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="del-btn" title="Delete theme"
|
||||
onclick={e => { e.stopPropagation(); deleteCustomTheme(t.id); }}
|
||||
onkeydown={e => e.key === 'Enter' && (e.stopPropagation(), deleteCustomTheme(t.id))}
|
||||
role="button" tabindex="0" aria-label="Delete {t.label}">✕</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<CustomDropdown
|
||||
items={themeItems}
|
||||
selected={themeId}
|
||||
onSelect={v => selectTheme(v as ThemeId)}
|
||||
groupBy={true}
|
||||
/>
|
||||
<div class="theme-actions">
|
||||
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>Edit Theme</button>
|
||||
<button class="theme-action-btn" onclick={() => { themeOpen = false; showEditor = true; }}>+ Custom</button>
|
||||
<button class="theme-action-btn" onclick={() => showEditor = true}>Edit Theme</button>
|
||||
<button class="theme-action-btn" onclick={() => showEditor = true}>+ Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sh">{t('settings.uiFont')}</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { uiFontOpen = !uiFontOpen; themeOpen = false; termFontOpen = false; }}>
|
||||
{uiFontLabel}
|
||||
<svg class="chev" class:open={uiFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if uiFontOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each UI_FONTS as f}
|
||||
<li class="dd-item" class:sel={uiFont === f.value} role="option" aria-selected={uiFont === f.value}
|
||||
tabindex="0" onclick={() => selectUIFont(f.value)}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectUIFont(f.value)}
|
||||
>{f.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="flex1">
|
||||
<CustomDropdown
|
||||
items={uiFontItems}
|
||||
selected={uiFont}
|
||||
onSelect={v => selectUIFont(v)}
|
||||
placeholder="System Default"
|
||||
/>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button onclick={() => adjustUISize(-1)} aria-label="Decrease UI font size">−</button>
|
||||
|
|
@ -224,21 +186,13 @@
|
|||
|
||||
<h3 class="sh">{t('settings.termFont')}</h3>
|
||||
<div class="field row">
|
||||
<div class="dd-wrap flex1">
|
||||
<button class="dd-btn" onclick={() => { termFontOpen = !termFontOpen; themeOpen = false; uiFontOpen = false; }}>
|
||||
{termFontLabel}
|
||||
<svg class="chev" class:open={termFontOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if termFontOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each TERM_FONTS as f}
|
||||
<li class="dd-item" class:sel={termFont === f.value} role="option" aria-selected={termFont === f.value}
|
||||
tabindex="0" onclick={() => selectTermFont(f.value)}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectTermFont(f.value)}
|
||||
>{f.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="flex1">
|
||||
<CustomDropdown
|
||||
items={termFontItems}
|
||||
selected={termFont}
|
||||
onSelect={v => selectTermFont(v)}
|
||||
placeholder="Default (JetBrains Mono)"
|
||||
/>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button onclick={() => adjustTermSize(-1)} aria-label="Decrease terminal font size">−</button>
|
||||
|
|
@ -270,25 +224,11 @@
|
|||
|
||||
<h3 class="sh">{t('settings.language')}</h3>
|
||||
<div class="field">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn" onclick={() => { langOpen = !langOpen; themeOpen = false; uiFontOpen = false; termFontOpen = false; }}>
|
||||
{langLabel}
|
||||
<svg class="chev" class:open={langOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if langOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each AVAILABLE_LOCALES as loc}
|
||||
<li class="dd-item" class:sel={currentLocale === loc.tag} role="option" aria-selected={currentLocale === loc.tag}
|
||||
tabindex="0" onclick={() => selectLang(loc.tag)}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectLang(loc.tag)}
|
||||
>
|
||||
<span class="dd-item-label">{loc.nativeLabel}</span>
|
||||
<span style="color: var(--ctp-overlay0); font-size: 0.6875rem;">{loc.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<CustomDropdown
|
||||
items={langItems}
|
||||
selected={currentLocale}
|
||||
onSelect={v => selectLang(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
|
@ -301,39 +241,6 @@
|
|||
.row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.flex1 { flex: 1; min-width: 0; }
|
||||
|
||||
.dd-wrap { position: relative; }
|
||||
.dd-btn {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 0.375rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
padding: 0.3rem 0.5rem; color: var(--ctp-text); font-family: var(--ui-font-family);
|
||||
font-size: 0.8125rem; cursor: pointer; text-align: left;
|
||||
}
|
||||
.dd-btn:hover { border-color: var(--ctp-surface2); }
|
||||
.chev { width: 0.75rem; height: 0.75rem; color: var(--ctp-overlay1); transition: transform 0.15s; flex-shrink: 0; }
|
||||
.chev.open { transform: rotate(180deg); }
|
||||
.dd-list {
|
||||
position: absolute; top: calc(100% + 0.125rem); left: 0; right: 0; z-index: 50;
|
||||
list-style: none; margin: 0; padding: 0.25rem;
|
||||
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.3rem;
|
||||
max-height: 14rem; overflow-y: auto;
|
||||
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
.dd-group-label {
|
||||
padding: 0.25rem 0.5rem 0.125rem; font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--ctp-overlay0); border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
.dd-group-label:first-child { border-top: none; }
|
||||
.dd-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8125rem; color: var(--ctp-subtext1);
|
||||
cursor: pointer; outline: none; list-style: none;
|
||||
}
|
||||
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.dd-item.sel { background: color-mix(in srgb, var(--ctp-mauve) 15%, transparent); color: var(--ctp-mauve); font-weight: 500; }
|
||||
.dd-item-label { flex: 1; }
|
||||
.del-btn { font-size: 0.7rem; color: var(--ctp-overlay0); padding: 0.1rem 0.2rem; border-radius: 0.15rem; }
|
||||
.del-btn:hover { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 10%, transparent); }
|
||||
|
||||
.theme-actions { display: flex; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
.theme-action-btn {
|
||||
padding: 0.2rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from '../rpc.ts';
|
||||
import CustomCheckbox from '../ui/CustomCheckbox.svelte';
|
||||
|
||||
type WakeStrategy = 'persistent' | 'on-demand' | 'smart';
|
||||
type AnchorScale = 'small' | 'medium' | 'large' | 'full';
|
||||
|
|
@ -135,11 +136,12 @@
|
|||
</label>
|
||||
|
||||
<div class="notif-types" style="margin-top: 0.375rem;">
|
||||
{#each NOTIF_TYPES as t}
|
||||
<label class="notif-chip" class:active={notifTypes.has(t)}>
|
||||
<input type="checkbox" checked={notifTypes.has(t)} onchange={() => toggleNotif(t)} aria-label="Notify on {t}" />
|
||||
{t}
|
||||
</label>
|
||||
{#each NOTIF_TYPES as nt}
|
||||
<CustomCheckbox
|
||||
checked={notifTypes.has(nt)}
|
||||
label={nt}
|
||||
onChange={() => toggleNotif(nt)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -167,8 +169,4 @@
|
|||
.toggle.on .thumb { transform: translateX(0.875rem); }
|
||||
|
||||
.notif-types { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.notif-chip { display: flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; cursor: pointer; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); transition: all 0.12s; }
|
||||
.notif-chip input { display: none; }
|
||||
.notif-chip.active { background: color-mix(in srgb, var(--ctp-blue) 15%, var(--ctp-surface0)); border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.notif-chip:hover { border-color: var(--ctp-surface2); color: var(--ctp-subtext1); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { appRpc } from '../rpc.ts';
|
||||
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
|
||||
import { setRetentionConfig } from '../agent-store.svelte.ts';
|
||||
import CustomCheckbox from '../ui/CustomCheckbox.svelte';
|
||||
|
||||
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
|
||||
type AnchorScale = typeof ANCHOR_SCALES[number];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from '../rpc.ts';
|
||||
import CustomDropdown from '../ui/CustomDropdown.svelte';
|
||||
|
||||
const KNOWN_KEYS: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: 'Anthropic API Key',
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let keyDropOpen = $state(false);
|
||||
// Dropdown state managed by CustomDropdown
|
||||
let saving = $state(false);
|
||||
|
||||
interface BranchPolicy { pattern: string; action: 'block' | 'warn'; }
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
let newAction = $state<'block' | 'warn'>('warn');
|
||||
|
||||
let availableKeys = $derived(Object.keys(KNOWN_KEYS).filter(k => !storedKeys.includes(k)));
|
||||
let newKeyLabel = $derived(newKey ? (KNOWN_KEYS[newKey] ?? newKey) : 'Select key...');
|
||||
let keyDropItems = $derived(availableKeys.map(k => ({ value: k, label: KNOWN_KEYS[k] ?? k })));
|
||||
|
||||
function persistPolicies() {
|
||||
appRpc?.request['settings.set']({ key: 'branch_policies', value: JSON.stringify(branchPolicies) }).catch(console.error);
|
||||
|
|
@ -61,9 +62,7 @@
|
|||
persistPolicies();
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest('.dd-wrap')) keyDropOpen = false;
|
||||
}
|
||||
// CustomDropdown handles its own outside click
|
||||
|
||||
onMount(async () => {
|
||||
if (!appRpc) return;
|
||||
|
|
@ -74,8 +73,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" onclick={handleOutsideClick} onkeydown={e => e.key === 'Escape' && (keyDropOpen = false)}>
|
||||
<div class="section">
|
||||
|
||||
<div class="prototype-notice">
|
||||
Prototype — secrets are stored locally in plain SQLite, not in the system keyring.
|
||||
|
|
@ -107,24 +105,13 @@
|
|||
{/if}
|
||||
|
||||
<div class="add-secret">
|
||||
<div class="dd-wrap">
|
||||
<button class="dd-btn small" onclick={() => keyDropOpen = !keyDropOpen}>
|
||||
{newKeyLabel}
|
||||
<svg class="chev" class:open={keyDropOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
{#if keyDropOpen}
|
||||
<ul class="dd-list" role="listbox">
|
||||
{#each availableKeys as k}
|
||||
<li class="dd-item" role="option" aria-selected={newKey === k} tabindex="0"
|
||||
onclick={() => { newKey = k; keyDropOpen = false; }}
|
||||
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && (newKey = k, keyDropOpen = false)}
|
||||
>{KNOWN_KEYS[k]}</li>
|
||||
{/each}
|
||||
{#if availableKeys.length === 0}
|
||||
<li class="dd-item disabled-item">All known keys stored</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="dd-key-wrap">
|
||||
<CustomDropdown
|
||||
items={keyDropItems}
|
||||
selected={newKey}
|
||||
placeholder="Select key..."
|
||||
onSelect={v => newKey = v}
|
||||
/>
|
||||
</div>
|
||||
<input class="text-in flex1" type="password" bind:value={newValue} placeholder="Value" aria-label="Secret value" />
|
||||
<button class="save-btn" onclick={handleSaveSecret} disabled={!newKey || !newValue || saving}>
|
||||
|
|
@ -183,15 +170,7 @@
|
|||
.add-policy { display: flex; align-items: center; gap: 0.375rem; margin-top: 0.25rem; }
|
||||
.flex1 { flex: 1; min-width: 0; }
|
||||
|
||||
.dd-wrap { position: relative; flex-shrink: 0; }
|
||||
.dd-btn { display: flex; align-items: center; justify-content: space-between; gap: 0.25rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-subtext1); font-family: var(--ui-font-family); cursor: pointer; white-space: nowrap; }
|
||||
.dd-btn.small { padding: 0.275rem 0.5rem; font-size: 0.75rem; min-width: 8rem; }
|
||||
.chev { width: 0.625rem; height: 0.625rem; color: var(--ctp-overlay1); transition: transform 0.15s; }
|
||||
.chev.open { transform: rotate(180deg); }
|
||||
.dd-list { position: absolute; top: calc(100% + 0.125rem); left: 0; z-index: 50; list-style: none; margin: 0; padding: 0.2rem; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; min-width: 10rem; box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent); }
|
||||
.dd-item { padding: 0.3rem 0.5rem; border-radius: 0.2rem; font-size: 0.8rem; color: var(--ctp-subtext1); cursor: pointer; outline: none; }
|
||||
.dd-item:hover, .dd-item:focus { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.disabled-item { opacity: 0.4; cursor: not-allowed; }
|
||||
.dd-key-wrap { flex-shrink: 0; min-width: 10rem; }
|
||||
|
||||
.text-in { padding: 0.275rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8rem; font-family: var(--ui-font-family); }
|
||||
.text-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
|
|
|
|||
80
ui-electrobun/src/mainview/ui/CustomCheckbox.svelte
Normal file
80
ui-electrobun/src/mainview/ui/CustomCheckbox.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked, label = '', disabled = false, onChange }: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
onChange(!checked);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="cb-root" class:disabled class:checked>
|
||||
<button
|
||||
class="cb-box"
|
||||
class:checked
|
||||
class:disabled
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="cb-check" class:visible={checked}>
|
||||
<Check size={12} strokeWidth={3} />
|
||||
</span>
|
||||
</button>
|
||||
{#if label}
|
||||
<span class="cb-label">{label}</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.cb-root {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.cb-root.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.cb-box {
|
||||
width: 1rem; height: 1rem; flex-shrink: 0;
|
||||
border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem;
|
||||
background: var(--ctp-surface0); padding: 0;
|
||||
cursor: pointer; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.cb-box:hover:not(.disabled) { border-color: var(--ctp-blue); }
|
||||
.cb-box:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: 1px; }
|
||||
.cb-box.checked {
|
||||
background: var(--ctp-blue); border-color: var(--ctp-blue);
|
||||
}
|
||||
.cb-box.disabled { cursor: not-allowed; }
|
||||
|
||||
.cb-check {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--ctp-base);
|
||||
opacity: 0; transform: scale(0.6);
|
||||
transition: opacity 0.12s, transform 0.12s;
|
||||
}
|
||||
.cb-check.visible { opacity: 1; transform: scale(1); }
|
||||
|
||||
.cb-label {
|
||||
font-size: 0.8125rem; color: var(--ctp-text);
|
||||
}
|
||||
</style>
|
||||
212
ui-electrobun/src/mainview/ui/CustomDropdown.svelte
Normal file
212
ui-electrobun/src/mainview/ui/CustomDropdown.svelte
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
|
||||
interface DropdownItem {
|
||||
value: string;
|
||||
label: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[];
|
||||
selected: string;
|
||||
placeholder?: string;
|
||||
onSelect: (value: string) => void;
|
||||
groupBy?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { items, selected, placeholder = 'Select...', onSelect, groupBy = false, disabled = false }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let focusIndex = $state(-1);
|
||||
let menuRef = $state<HTMLDivElement | null>(null);
|
||||
let triggerRef = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
let selectedLabel = $derived(items.find(i => i.value === selected)?.label ?? placeholder);
|
||||
|
||||
// Group items by group field
|
||||
let groups = $derived<string[]>(() => {
|
||||
if (!groupBy) return [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.group) seen.add(item.group);
|
||||
}
|
||||
return [...seen];
|
||||
});
|
||||
|
||||
let flatItems = $derived(items);
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
open = !open;
|
||||
if (open) {
|
||||
focusIndex = flatItems.findIndex(i => i.value === selected);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
focusIndex = -1;
|
||||
}
|
||||
|
||||
function select(value: string) {
|
||||
onSelect(value);
|
||||
close();
|
||||
triggerRef?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
focusIndex = Math.min(focusIndex + 1, flatItems.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
focusIndex = Math.max(focusIndex - 1, 0);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (focusIndex >= 0 && focusIndex < flatItems.length) {
|
||||
select(flatItems[focusIndex].value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
close();
|
||||
triggerRef?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dd-root" onkeydown={handleKeydown}>
|
||||
<button
|
||||
class="dd-trigger"
|
||||
class:open
|
||||
class:disabled
|
||||
bind:this={triggerRef}
|
||||
onclick={toggle}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
{disabled}
|
||||
>
|
||||
<span class="dd-trigger-label">{selectedLabel}</span>
|
||||
<ChevronDown size={14} class="dd-chev {open ? 'dd-chev-open' : ''}" />
|
||||
</button>
|
||||
|
||||
<div class="dd-backdrop" style:display={open ? 'block' : 'none'} onclick={handleBackdropClick}></div>
|
||||
|
||||
<div class="dd-menu" style:display={open ? 'flex' : 'none'} bind:this={menuRef} role="listbox">
|
||||
{#if groupBy && groups().length > 0}
|
||||
{#each groups() as group}
|
||||
<div class="dd-group-header">{group}</div>
|
||||
{#each flatItems.filter(i => i.group === group) as item, idx}
|
||||
{@const globalIdx = flatItems.indexOf(item)}
|
||||
<button
|
||||
class="dd-option"
|
||||
class:selected={item.value === selected}
|
||||
class:focused={globalIdx === focusIndex}
|
||||
role="option"
|
||||
aria-selected={item.value === selected}
|
||||
onclick={() => select(item.value)}
|
||||
>{item.label}</button>
|
||||
{/each}
|
||||
{/each}
|
||||
<!-- Ungrouped items -->
|
||||
{#each flatItems.filter(i => !i.group) as item}
|
||||
{@const globalIdx = flatItems.indexOf(item)}
|
||||
<button
|
||||
class="dd-option"
|
||||
class:selected={item.value === selected}
|
||||
class:focused={globalIdx === focusIndex}
|
||||
role="option"
|
||||
aria-selected={item.value === selected}
|
||||
onclick={() => select(item.value)}
|
||||
>{item.label}</button>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each flatItems as item, idx}
|
||||
<button
|
||||
class="dd-option"
|
||||
class:selected={item.value === selected}
|
||||
class:focused={idx === focusIndex}
|
||||
role="option"
|
||||
aria-selected={item.value === selected}
|
||||
onclick={() => select(item.value)}
|
||||
>{item.label}</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dd-root { position: relative; width: 100%; }
|
||||
|
||||
.dd-trigger {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 0.375rem;
|
||||
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);
|
||||
cursor: pointer; text-align: left;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.dd-trigger:hover:not(.disabled) { border-color: var(--ctp-surface2); }
|
||||
.dd-trigger.open { border-color: var(--ctp-blue); }
|
||||
.dd-trigger.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.dd-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dd-trigger :global(.dd-chev) {
|
||||
color: var(--ctp-overlay1); flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.dd-trigger :global(.dd-chev-open) { transform: rotate(180deg); }
|
||||
|
||||
.dd-backdrop {
|
||||
position: fixed; inset: 0; z-index: 49; background: transparent;
|
||||
}
|
||||
|
||||
.dd-menu {
|
||||
position: absolute; top: calc(100% + 0.125rem); left: 0; right: 0; z-index: 50;
|
||||
flex-direction: column;
|
||||
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
max-height: 14rem; overflow-y: auto;
|
||||
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
|
||||
.dd-group-header {
|
||||
padding: 0.25rem 0.5rem 0.125rem; font-size: 0.625rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0);
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
.dd-group-header:first-child { border-top: none; }
|
||||
|
||||
.dd-option {
|
||||
width: 100%; padding: 0.3rem 0.5rem; background: none; border: none;
|
||||
border-radius: 0.2rem; color: var(--ctp-subtext1); font-size: 0.8125rem;
|
||||
font-family: var(--ui-font-family); cursor: pointer; text-align: left;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
.dd-option:hover, .dd-option.focused { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.dd-option.selected {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-blue); font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
86
ui-electrobun/src/mainview/ui/CustomRadio.svelte
Normal file
86
ui-electrobun/src/mainview/ui/CustomRadio.svelte
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: RadioOption[];
|
||||
selected: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { options, selected, name, onChange, disabled = false }: Props = $props();
|
||||
|
||||
function select(value: string) {
|
||||
if (disabled) return;
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, value: string) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
select(value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="radio-group" role="radiogroup" aria-label={name}>
|
||||
{#each options as opt}
|
||||
<label class="radio-item" class:selected={selected === opt.value} class:disabled>
|
||||
<button
|
||||
class="radio-dot-outer"
|
||||
class:selected={selected === opt.value}
|
||||
class:disabled
|
||||
role="radio"
|
||||
aria-checked={selected === opt.value}
|
||||
aria-label={opt.label}
|
||||
{disabled}
|
||||
onclick={() => select(opt.value)}
|
||||
onkeydown={e => handleKeydown(e, opt.value)}
|
||||
>
|
||||
<span class="radio-dot-inner" class:visible={selected === opt.value}></span>
|
||||
</button>
|
||||
<span class="radio-label">{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.radio-group {
|
||||
display: flex; flex-direction: column; gap: 0.375rem;
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.radio-item.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.radio-dot-outer {
|
||||
width: 1rem; height: 1rem; flex-shrink: 0;
|
||||
border: 1px solid var(--ctp-surface2); border-radius: 50%;
|
||||
background: var(--ctp-surface0); padding: 0;
|
||||
cursor: pointer; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.radio-dot-outer:hover:not(.disabled) { border-color: var(--ctp-blue); }
|
||||
.radio-dot-outer:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: 1px; }
|
||||
.radio-dot-outer.selected { border-color: var(--ctp-blue); }
|
||||
.radio-dot-outer.disabled { cursor: not-allowed; }
|
||||
|
||||
.radio-dot-inner {
|
||||
width: 0.5rem; height: 0.5rem; border-radius: 50%;
|
||||
background: var(--ctp-blue);
|
||||
opacity: 0; transform: scale(0);
|
||||
transition: opacity 0.12s, transform 0.12s;
|
||||
}
|
||||
.radio-dot-inner.visible { opacity: 1; transform: scale(1); }
|
||||
|
||||
.radio-label {
|
||||
font-size: 0.8125rem; color: var(--ctp-text);
|
||||
}
|
||||
</style>
|
||||
52
ui-electrobun/src/mainview/wizard-icons.ts
Normal file
52
ui-electrobun/src/mainview/wizard-icons.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Icon and color data for the Project Wizard.
|
||||
*
|
||||
* Lucide icon names mapped to display labels.
|
||||
* Catppuccin accent color list for project color selection.
|
||||
*/
|
||||
|
||||
/** Lucide icon choices for projects. Key = Lucide component name. */
|
||||
export const PROJECT_ICONS: Array<{ name: string; label: string }> = [
|
||||
{ name: 'Terminal', label: 'Terminal' },
|
||||
{ name: 'Server', label: 'Server' },
|
||||
{ name: 'Globe', label: 'Web' },
|
||||
{ name: 'Code', label: 'Code' },
|
||||
{ name: 'Database', label: 'Database' },
|
||||
{ name: 'Cpu', label: 'CPU' },
|
||||
{ name: 'Zap', label: 'Zap' },
|
||||
{ name: 'Shield', label: 'Shield' },
|
||||
{ name: 'Rocket', label: 'Rocket' },
|
||||
{ name: 'Bug', label: 'Bug' },
|
||||
{ name: 'Puzzle', label: 'Plugin' },
|
||||
{ name: 'Box', label: 'Package' },
|
||||
{ name: 'Layers', label: 'Layers' },
|
||||
{ name: 'GitBranch', label: 'Branch' },
|
||||
{ name: 'Wifi', label: 'Network' },
|
||||
{ name: 'Lock', label: 'Security' },
|
||||
{ name: 'FlaskConical', label: 'Lab' },
|
||||
{ name: 'Sparkles', label: 'AI' },
|
||||
{ name: 'FileCode', label: 'Script' },
|
||||
{ name: 'Wrench', label: 'Tools' },
|
||||
{ name: 'Folder', label: 'Folder' },
|
||||
{ name: 'Bot', label: 'Bot' },
|
||||
{ name: 'Cloud', label: 'Cloud' },
|
||||
{ name: 'HardDrive', label: 'Storage' },
|
||||
];
|
||||
|
||||
/** Catppuccin accent colors for project color selection. */
|
||||
export const ACCENT_COLORS: Array<{ name: string; var: string }> = [
|
||||
{ name: 'Rosewater', var: '--ctp-rosewater' },
|
||||
{ name: 'Flamingo', var: '--ctp-flamingo' },
|
||||
{ name: 'Pink', var: '--ctp-pink' },
|
||||
{ name: 'Mauve', var: '--ctp-mauve' },
|
||||
{ name: 'Red', var: '--ctp-red' },
|
||||
{ name: 'Maroon', var: '--ctp-maroon' },
|
||||
{ name: 'Peach', var: '--ctp-peach' },
|
||||
{ name: 'Yellow', var: '--ctp-yellow' },
|
||||
{ name: 'Green', var: '--ctp-green' },
|
||||
{ name: 'Teal', var: '--ctp-teal' },
|
||||
{ name: 'Sky', var: '--ctp-sky' },
|
||||
{ name: 'Sapphire', var: '--ctp-sapphire' },
|
||||
{ name: 'Blue', var: '--ctp-blue' },
|
||||
{ name: 'Lavender', var: '--ctp-lavender' },
|
||||
];
|
||||
|
|
@ -236,6 +236,11 @@ export type PtyRPCRequests = {
|
|||
params: { url: string; target: string; branch?: string };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** Probe a git remote URL (ls-remote). Returns branches on success. */
|
||||
"git.probe": {
|
||||
params: { url: string };
|
||||
response: { ok: boolean; branches: string[]; defaultBranch: string; error?: string };
|
||||
};
|
||||
|
||||
// ── Project templates RPC ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -251,6 +256,39 @@ export type PtyRPCRequests = {
|
|||
}>;
|
||||
};
|
||||
};
|
||||
/** Create a project from a template with real scaffold files. */
|
||||
"project.createFromTemplate": {
|
||||
params: { templateId: string; targetDir: string; projectName: string };
|
||||
response: { ok: boolean; path: string; error?: string };
|
||||
};
|
||||
|
||||
// ── Provider RPC ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Scan for available AI providers on this machine. */
|
||||
"provider.scan": {
|
||||
params: Record<string, never>;
|
||||
response: {
|
||||
providers: Array<{
|
||||
id: string;
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
hasCli: boolean;
|
||||
cliPath: string | null;
|
||||
version: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
/** Fetch model list for a specific provider. */
|
||||
"provider.models": {
|
||||
params: { provider: string };
|
||||
response: {
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Window control RPC ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue