fix(electrobun): wizard 7 fixes — validation, GitLab, SSHFS, icons, model dropdown, keyboard nav
- Git Platform: validates repo via git.probe before enabling Next, supports GitHub + GitLab + any git URL - Template dir configurable in Advanced Settings (template_dir key) - SSHFS: checks sshfs availability, mountpoint selector when enabled - CustomDropdown: flip-up when insufficient space below - 50 Lucide icons (was 24) with categories (AI, Data, DevOps, Security, Media, Comms) - Model: CustomDropdown from live API, max_tokens as slider, effort only with adaptive thinking - Keyboard: Escape closes wizard, Tab navigation with :focus-visible rings, source cards navigable
This commit is contained in:
parent
41b8d46a19
commit
021feba3ed
11 changed files with 368 additions and 614 deletions
|
|
@ -8,6 +8,15 @@ import { execSync, spawn } from "child_process";
|
|||
|
||||
export function createGitHandlers() {
|
||||
return {
|
||||
"ssh.checkSshfs": async () => {
|
||||
try {
|
||||
const sshfsPath = execSync("which sshfs", { encoding: "utf8", timeout: 3000 }).trim();
|
||||
return { installed: true, path: sshfsPath };
|
||||
} catch {
|
||||
return { installed: false, path: null };
|
||||
}
|
||||
},
|
||||
|
||||
"git.branches": async ({ path: repoPath }: { path: string }) => {
|
||||
try {
|
||||
const resolved = path.resolve(repoPath.replace(/^~/, process.env.HOME ?? ""));
|
||||
|
|
|
|||
|
|
@ -3,303 +3,89 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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'>('disabled');
|
||||
let claudeEffort = $state('medium');
|
||||
let claudeTemp = $state<number>(1.0);
|
||||
let claudeMaxTokens = $state<number>(8192);
|
||||
let claudeEffort = $state('medium'); let claudeTemp = $state(1.0); let claudeMaxTokens = $state(8192);
|
||||
let claudeTempLocked = $derived(claudeThinking === 'enabled');
|
||||
|
||||
// ── Codex config ───────────────────────────────────────
|
||||
let codexSandbox = $state('workspace-write');
|
||||
let codexApproval = $state('on-request');
|
||||
let codexReasoning = $state('medium');
|
||||
let codexSandbox = $state('workspace-write'); let codexApproval = $state('on-request'); let codexReasoning = $state('medium');
|
||||
|
||||
// ── Ollama config ──────────────────────────────────────
|
||||
let ollamaTemp = $state<number>(0.8);
|
||||
let ollamaCtx = $state<number>(32768);
|
||||
let ollamaPredict = $state<number>(0);
|
||||
let ollamaTopK = $state<number>(40);
|
||||
let ollamaTopP = $state<number>(0.9);
|
||||
let ollamaTemp = $state(0.8); let ollamaCtx = $state(32768); let ollamaPredict = $state(0);
|
||||
let ollamaTopK = $state(40); let ollamaTopP = $state(0.9);
|
||||
let ollamaCtxWarn = $derived(ollamaCtx < 8192);
|
||||
|
||||
// ── Gemini config ──────────────────────────────────────
|
||||
let geminiTemp = $state<number>(1.0);
|
||||
let geminiThinkingMode = $state<'level' | 'budget'>('level');
|
||||
let geminiThinkingLevel = $state('medium');
|
||||
let geminiThinkingBudget = $state<number>(8192);
|
||||
let geminiMaxOutput = $state<number>(8192);
|
||||
let geminiTemp = $state(1.0); let geminiThinkingMode = $state<'level' | 'budget'>('level');
|
||||
let geminiThinkingLevel = $state('medium'); let geminiThinkingBudget = $state(8192); let geminiMaxOutput = $state(8192);
|
||||
|
||||
// Sync local state from config prop (re-runs when config changes)
|
||||
$effect(() => {
|
||||
const c = config;
|
||||
claudeThinking = (c.thinking as string as typeof claudeThinking) ?? 'disabled';
|
||||
claudeEffort = (c.effort as string) ?? 'medium';
|
||||
claudeTemp = (c.temperature as number) ?? 1.0;
|
||||
claudeMaxTokens = (c.max_tokens as number) ?? 8192;
|
||||
codexSandbox = (c.sandbox as string) ?? 'workspace-write';
|
||||
codexApproval = (c.approval as string) ?? 'on-request';
|
||||
codexReasoning = (c.reasoning as string) ?? 'medium';
|
||||
ollamaTemp = (c.temperature as number) ?? 0.8;
|
||||
ollamaCtx = (c.num_ctx as number) ?? 32768;
|
||||
ollamaPredict = (c.num_predict as number) ?? 0;
|
||||
ollamaTopK = (c.top_k as number) ?? 40;
|
||||
ollamaTopP = (c.top_p as number) ?? 0.9;
|
||||
geminiTemp = (c.temperature as number) ?? 1.0;
|
||||
geminiThinkingMode = (c.thinkingMode as string as 'level' | 'budget') ?? 'level';
|
||||
geminiThinkingLevel = (c.thinkingLevel as string) ?? 'medium';
|
||||
geminiThinkingBudget = (c.thinkingBudget as number) ?? 8192;
|
||||
geminiMaxOutput = (c.maxOutputTokens as number) ?? 8192;
|
||||
claudeThinking = (c.thinking as typeof claudeThinking) ?? 'disabled'; claudeEffort = (c.effort as string) ?? 'medium';
|
||||
claudeTemp = (c.temperature as number) ?? 1.0; claudeMaxTokens = (c.max_tokens as number) ?? 8192;
|
||||
codexSandbox = (c.sandbox as string) ?? 'workspace-write'; codexApproval = (c.approval as string) ?? 'on-request'; codexReasoning = (c.reasoning as string) ?? 'medium';
|
||||
ollamaTemp = (c.temperature as number) ?? 0.8; ollamaCtx = (c.num_ctx as number) ?? 32768; ollamaPredict = (c.num_predict as number) ?? 0; ollamaTopK = (c.top_k as number) ?? 40; ollamaTopP = (c.top_p as number) ?? 0.9;
|
||||
geminiTemp = (c.temperature as number) ?? 1.0; geminiThinkingMode = (c.thinkingMode as 'level'|'budget') ?? 'level'; geminiThinkingLevel = (c.thinkingLevel as string) ?? 'medium'; geminiThinkingBudget = (c.thinkingBudget as number) ?? 8192; geminiMaxOutput = (c.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 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 }); }
|
||||
|
||||
function emitCodex() {
|
||||
onChange({ sandbox: codexSandbox, approval: codexApproval, reasoning: codexReasoning });
|
||||
}
|
||||
function fmtT(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K` : String(n); }
|
||||
function pf(e: Event) { return parseFloat((e.target as HTMLInputElement).value); }
|
||||
function pi(e: Event, d: number) { return parseInt((e.target as HTMLInputElement).value, 10) || d; }
|
||||
|
||||
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 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' },
|
||||
];
|
||||
const GEMINI_LEVELS = [{ 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(); }}
|
||||
/>
|
||||
<CustomRadio options={THINKING_OPTIONS} selected={claudeThinking} name="claude-thinking" onChange={v => { claudeThinking = v as typeof claudeThinking; emitClaude(); }} />
|
||||
<span class="mcp-explain">{claudeThinking === 'disabled' ? 'thinking.type = disabled' : claudeThinking === 'enabled' ? 'thinking.type = enabled (always)' : 'thinking.type = adaptive (model decides)'}</span>
|
||||
</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(); }}
|
||||
/>
|
||||
<span class="mcp-label">Effort level <span class="mcp-hint">(output_config.effort)</span></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>
|
||||
<span class="mcp-label">Temperature {#if claudeTempLocked}<span class="mcp-hint" title="Locked when thinking.type=enabled">Locked at 1.0</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(); }} />
|
||||
<input type="range" class="mcp-slider" min="0" max="2" step="0.1" value={claudeTempLocked ? 1.0 : claudeTemp} disabled={claudeTempLocked} oninput={e => { claudeTemp = pf(e); 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(); }} />
|
||||
<span class="mcp-label">Max tokens <span class="mcp-slider-val-inline">{fmtT(claudeMaxTokens)}</span></span>
|
||||
<div class="mcp-slider-row">
|
||||
<input type="range" class="mcp-slider" min="256" max="200000" step="256" value={claudeMaxTokens} oninput={e => { claudeMaxTokens = pi(e, 8192); emitClaude(); }} />
|
||||
<span class="mcp-slider-val">{fmtT(claudeMaxTokens)}</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 = pf(e); emitOllama(); }} /><span class="mcp-slider-val">{ollamaTemp.toFixed(1)}</span></div></div>
|
||||
<div class="mcp-field"><span class="mcp-label">Context window {#if ollamaCtxWarn}<span class="mcp-warn">Low context</span>{/if}</span><div class="mcp-slider-row"><input type="range" class="mcp-slider" min="512" max="131072" step="1024" value={ollamaCtx} oninput={e => { ollamaCtx = pi(e, 32768); emitOllama(); }} /><span class="mcp-slider-val">{fmtT(ollamaCtx)}</span></div></div>
|
||||
<div class="mcp-field"><span class="mcp-label">Max predict (0=unlimited)</span><div class="mcp-slider-row"><input type="range" class="mcp-slider" min="0" max="131072" step="256" value={ollamaPredict} oninput={e => { ollamaPredict = pi(e, 0); emitOllama(); }} /><span class="mcp-slider-val">{ollamaPredict === 0 ? '\u221E' : fmtT(ollamaPredict)}</span></div></div>
|
||||
<div class="mcp-field"><span class="mcp-label">Top-K</span><div class="mcp-slider-row"><input type="range" class="mcp-slider" min="1" max="200" step="1" value={ollamaTopK} oninput={e => { ollamaTopK = pi(e, 40); emitOllama(); }} /><span class="mcp-slider-val">{ollamaTopK}</span></div></div>
|
||||
<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 = pf(e); 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>
|
||||
<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 = pf(e); emitGemini(); }} /><span class="mcp-slider-val">{geminiTemp.toFixed(1)}</span></div></div>
|
||||
<div class="mcp-field"><span class="mcp-label">Thinking</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>
|
||||
<div class="mcp-field" style:display={geminiThinkingMode === 'level' ? 'flex' : 'none'}><span class="mcp-label">Thinking level</span><div class="mcp-seg">{#each GEMINI_LEVELS as l}<button class="mcp-seg-btn" class:active={geminiThinkingLevel === l.value} onclick={() => { geminiThinkingLevel = l.value; emitGemini(); }}>{l.label}</button>{/each}</div></div>
|
||||
<div class="mcp-field" style:display={geminiThinkingMode === 'budget' ? 'flex' : 'none'}><span class="mcp-label">Budget (tokens)</span><div class="mcp-slider-row"><input type="range" class="mcp-slider" min="0" max="65536" step="1024" value={geminiThinkingBudget} oninput={e => { geminiThinkingBudget = pi(e, 8192); emitGemini(); }} /><span class="mcp-slider-val">{fmtT(geminiThinkingBudget)}</span></div></div>
|
||||
<div class="mcp-field"><span class="mcp-label">Max output tokens</span><div class="mcp-slider-row"><input type="range" class="mcp-slider" min="1" max="65536" step="1024" value={geminiMaxOutput} oninput={e => { geminiMaxOutput = pi(e, 8192); emitGemini(); }} /><span class="mcp-slider-val">{fmtT(geminiMaxOutput)}</span></div></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -309,16 +95,17 @@
|
|||
.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-explain { font-size: 0.625rem; color: var(--ctp-overlay0); font-style: italic; }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: 2px; }
|
||||
.mcp-slider-val { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: right; }
|
||||
.mcp-slider-val-inline { font-size: 0.6875rem; color: var(--ctp-text); font-weight: 400; }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -2px; }
|
||||
.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,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { sanitize, sanitizePath, sanitizeUrl, isValidGitUrl, isValidGithubRepo } from './sanitize.ts';
|
||||
import WizardStep1 from './WizardStep1.svelte';
|
||||
|
|
@ -11,123 +12,87 @@
|
|||
useWorktrees?: boolean; shell?: string; icon?: string; color?: string;
|
||||
modelConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (project: ProjectResult) => void;
|
||||
groupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
existingNames: string[];
|
||||
}
|
||||
|
||||
interface Props { onClose: () => void; onCreated: (project: ProjectResult) => void; groupId: string; groups: Array<{ id: string; name: string }>; existingNames: string[]; }
|
||||
let { onClose, onCreated, groupId, groups, existingNames }: Props = $props();
|
||||
|
||||
type SourceType = 'local' | 'git-clone' | 'github' | 'template' | 'remote';
|
||||
type AuthMethod = 'password' | 'key' | 'agent' | 'config';
|
||||
let step = $state(1);
|
||||
let sourceType = $state<SourceType>('local');
|
||||
type ProviderInfo = { id: string; available: boolean; hasApiKey: boolean; hasCli: boolean; cliPath: string | null; version: string | null };
|
||||
let step = $state(1); 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 githubRepo = $state(''); let selectedTemplate = $state(''); let templateTargetDir = $state('~/projects'); let templateOriginDir = $state('');
|
||||
let remoteHost = $state(''); let remoteUser = $state(''); let remotePath = $state('');
|
||||
let remoteAuthMethod = $state<AuthMethod>('agent'); let remotePassword = $state(''); let remoteKeyPath = $state('~/.ssh/id_ed25519');
|
||||
let remoteSshfs = $state(false); let isGitRepo = $state(false); let gitBranch = $state('');
|
||||
let remoteSshfs = $state(false); let remoteSshfsMountpoint = $state('');
|
||||
let isGitRepo = $state(false); let gitBranch = $state('');
|
||||
let pathValid = $state<'idle'|'checking'|'valid'|'invalid'|'not-dir'>('idle');
|
||||
let gitProbeStatus = $state<'idle' | 'probing' | 'ok' | 'error'>('idle');
|
||||
let gitProbeBranches = $state<string[]>([]); let githubLoading = $state(false); let cloning = $state(false);
|
||||
let gitProbeStatus = $state<'idle'|'probing'|'ok'|'error'>('idle'); let gitProbeBranches = $state<string[]>([]);
|
||||
let githubLoading = $state(false); let cloning = $state(false);
|
||||
let githubInfo = $state<{ stars: number; description: string; defaultBranch: string } | null>(null);
|
||||
let githubProbeStatus = $state<'idle'|'probing'|'ok'|'error'>('idle');
|
||||
let templates = $state<Array<{ id: string; name: string; description: string; icon: string }>>([]);
|
||||
let projectName = $state(''); let nameError = $state(''); let selectedBranch = $state('');
|
||||
let branches = $state<string[]>([]); let useWorktrees = $state(false); let selectedGroupId = $state(groupId);
|
||||
let projectIcon = $state('Terminal'); let projectColor = $state('var(--ctp-blue)'); let shellChoice = $state('bash');
|
||||
let provider = $state<string>('claude'); let model = $state(''); let permissionMode = $state('default');
|
||||
let systemPrompt = $state(''); let autoStart = $state(false); let modelConfig = $state<Record<string, unknown>>({});
|
||||
type ProviderInfo = { id: string; available: boolean; hasApiKey: boolean; hasCli: boolean; cliPath: string | null; version: string | null };
|
||||
let detectedProviders = $state<ProviderInfo[]>([]); let providerModels = $state<Array<{ id: string; name: string; provider: string }>>([]); let modelsLoading = $state(false);
|
||||
let pathTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let githubTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
$effect(() => {
|
||||
if (sourceType === 'template' && templates.length === 0) {
|
||||
appRpc.request['project.templates']({}).then(r => {
|
||||
if (r?.templates) templates = r.templates;
|
||||
}).catch(console.error);
|
||||
}
|
||||
let step1Ref = $state<WizardStep1 | null>(null);
|
||||
let step2Ref = $state<WizardStep2 | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try { const r = await appRpc.request['settings.get']({ key: 'template_dir' }); if (r?.value) templateOriginDir = r.value; } catch { /* default */ }
|
||||
});
|
||||
|
||||
$effect(() => { if (sourceType === 'template' && templates.length === 0) { appRpc.request['project.templates']({ templateDir: templateOriginDir || undefined }).then(r => { if (r?.templates) templates = r.templates; }).catch(console.error); } });
|
||||
$effect(() => {
|
||||
if (sourceType === 'local') {
|
||||
if (pathTimer) clearTimeout(pathTimer);
|
||||
if (!localPath.trim()) { pathValid = 'idle'; isGitRepo = false; return; }
|
||||
pathValid = 'checking';
|
||||
pathTimer = setTimeout(async () => {
|
||||
try {
|
||||
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; }
|
||||
try { 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 === 'git-clone' && repoUrl.trim()) {
|
||||
if (probeTimer) clearTimeout(probeTimer);
|
||||
gitProbeStatus = 'probing';
|
||||
if (probeTimer) clearTimeout(probeTimer); gitProbeStatus = 'probing';
|
||||
probeTimer = setTimeout(async () => {
|
||||
if (!isValidGitUrl(repoUrl)) { gitProbeStatus = 'error'; return; }
|
||||
try {
|
||||
const r = await appRpc.request['git.probe']({ url: repoUrl.trim() });
|
||||
if (r?.ok) { gitProbeStatus = 'ok'; gitProbeBranches = r.branches; } else { gitProbeStatus = 'error'; }
|
||||
} catch { gitProbeStatus = 'error'; }
|
||||
try { 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'; }
|
||||
});
|
||||
$effect(() => {
|
||||
if (sourceType === 'github' && githubRepo.trim()) {
|
||||
if (githubTimer) clearTimeout(githubTimer);
|
||||
githubInfo = null;
|
||||
if (!isValidGithubRepo(githubRepo)) return;
|
||||
githubLoading = true;
|
||||
if (githubTimer) clearTimeout(githubTimer); githubInfo = null; githubProbeStatus = 'idle';
|
||||
const input = githubRepo.trim();
|
||||
let probeUrl: string;
|
||||
if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('git@')) { probeUrl = input; }
|
||||
else if (isValidGithubRepo(input)) { probeUrl = `https://github.com/${input}.git`; } else { return; }
|
||||
githubLoading = true; githubProbeStatus = 'probing';
|
||||
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;
|
||||
const r = await appRpc.request['git.probe']({ url: probeUrl });
|
||||
if (r?.ok) { githubProbeStatus = 'ok'; if (isValidGithubRepo(input)) { try { const res = await fetch(`https://api.github.com/repos/${input}`, { signal: AbortSignal.timeout(5000) }); if (res.ok) { const d = await res.json(); githubInfo = { stars: d.stargazers_count ?? 0, description: d.description ?? '', defaultBranch: d.default_branch ?? 'main' }; } } catch { /* optional */ } } }
|
||||
else { githubProbeStatus = 'error'; }
|
||||
} catch { githubProbeStatus = 'error'; } githubLoading = false;
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const trimmed = projectName.trim().toLowerCase();
|
||||
if (!trimmed) { nameError = ''; return; }
|
||||
const lowerNames = existingNames.map(n => n.toLowerCase());
|
||||
nameError = lowerNames.includes(trimmed) ? 'A project with this name already exists' : '';
|
||||
});
|
||||
|
||||
$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);
|
||||
}
|
||||
});
|
||||
|
||||
$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; });
|
||||
}
|
||||
});
|
||||
$effect(() => { const t = projectName.trim().toLowerCase(); if (!t) { nameError = ''; return; } nameError = existingNames.map(n => n.toLowerCase()).includes(t) ? 'A project with this name already exists' : ''; });
|
||||
$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); } });
|
||||
$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; }); } });
|
||||
|
||||
let step1Valid = $derived(() => {
|
||||
switch (sourceType) {
|
||||
case 'local': return pathValid === 'valid';
|
||||
case 'git-clone': return isValidGitUrl(repoUrl) && !!sanitizePath(cloneTarget);
|
||||
case 'github': return isValidGithubRepo(githubRepo);
|
||||
case 'git-clone': return isValidGitUrl(repoUrl) && !!sanitizePath(cloneTarget) && gitProbeStatus === 'ok';
|
||||
case 'github': return githubProbeStatus === 'ok';
|
||||
case 'template': return selectedTemplate !== '' && !!sanitizePath(templateTargetDir);
|
||||
case 'remote': return !!sanitize(remoteHost) && !!sanitize(remoteUser) && !!sanitize(remotePath);
|
||||
default: return false;
|
||||
|
|
@ -135,139 +100,73 @@
|
|||
});
|
||||
|
||||
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, 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`;
|
||||
const target = `~/projects/${githubRepo.trim().split('/')[1] || 'project'}`;
|
||||
cloning = true;
|
||||
try {
|
||||
const result = await appRpc.request['git.clone']({ url, target });
|
||||
if (!result?.ok) { cloning = false; return; }
|
||||
localPath = target; isGitRepo = true;
|
||||
} catch { cloning = false; return; }
|
||||
cloning = false;
|
||||
} 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;
|
||||
}
|
||||
if (sourceType === 'git-clone') { const url = sanitizeUrl(repoUrl); const target = sanitizePath(cloneTarget); if (!url || !target) return; cloning = true; try { const r = await appRpc.request['git.clone']({ url, target }); if (!r?.ok) { cloning = false; return; } localPath = target; isGitRepo = true; } catch { cloning = false; return; } cloning = false; }
|
||||
else if (sourceType === 'github') { const input = githubRepo.trim(); const url = (input.startsWith('http') || input.startsWith('git@')) ? input : `https://github.com/${input}.git`; const name = input.includes('/') ? input.split('/').pop()?.replace(/\.git$/, '') || 'project' : 'project'; const target = `~/projects/${name}`; cloning = true; try { const r = await appRpc.request['git.clone']({ url, target }); if (!r?.ok) { cloning = false; return; } 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 r = await appRpc.request['project.createFromTemplate']({ templateId: selectedTemplate, targetDir: target, projectName: name }); if (!r?.ok) { cloning = false; return; } localPath = r.path; if (!projectName) projectName = name; } catch { cloning = false; return; } cloning = false; }
|
||||
if (!projectName && localPath) { const parts = localPath.replace(/\/+$/, '').split('/'); projectName = parts[parts.length - 1] || ''; }
|
||||
if (isGitRepo && localPath) {
|
||||
try { const r = await appRpc.request['git.branches']({ path: localPath }); if (r?.branches) { branches = r.branches; selectedBranch = r.current || ''; } } catch { /* ignore */ }
|
||||
if (isGitRepo && localPath) { try { const r = await appRpc.request['git.branches']({ path: localPath }); if (r?.branches) { branches = r.branches; selectedBranch = r.current || ''; } } catch { /* ignore */ } }
|
||||
step = 2; requestAnimationFrame(() => step2Ref?.focusFirst());
|
||||
}
|
||||
step = 2;
|
||||
}
|
||||
|
||||
function goToStep3() { if (nameError) return; step = 3; }
|
||||
function goBack() { step = Math.max(1, step - 1); }
|
||||
|
||||
async function createProject() {
|
||||
const cwd = sourceType === 'remote' ? `ssh://${sanitize(remoteUser)}@${sanitize(remoteHost)}:${sanitize(remotePath)}` : (sanitizePath(localPath) || localPath.trim());
|
||||
const project: ProjectResult = {
|
||||
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,
|
||||
modelConfig: Object.keys(modelConfig).length > 0 ? modelConfig : undefined,
|
||||
};
|
||||
onCreated(project);
|
||||
onCreated({ id: `p-${Date.now()}`, name: sanitize(projectName) || 'Untitled', cwd, provider, model: model || undefined, systemPrompt: systemPrompt || undefined, autoStart, groupId: selectedGroupId, useWorktrees: useWorktrees || undefined, shell: shellChoice, icon: projectIcon, color: projectColor, modelConfig: Object.keys(modelConfig).length > 0 ? modelConfig : undefined });
|
||||
resetState();
|
||||
}
|
||||
|
||||
function closeWizard() { resetState(); onClose(); }
|
||||
function handleOverlayKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeWizard(); } }
|
||||
|
||||
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';
|
||||
step = 1; sourceType = 'local'; localPath = ''; repoUrl = ''; cloneTarget = ''; githubRepo = ''; selectedTemplate = ''; templateTargetDir = '~/projects';
|
||||
remoteHost = ''; remoteUser = ''; remotePath = ''; remoteAuthMethod = 'agent'; remotePassword = ''; remoteKeyPath = '~/.ssh/id_ed25519'; remoteSshfs = false; remoteSshfsMountpoint = '';
|
||||
pathValid = 'idle'; isGitRepo = false; gitBranch = ''; gitProbeStatus = 'idle'; gitProbeBranches = []; githubInfo = null; githubLoading = false; githubProbeStatus = 'idle';
|
||||
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; modelConfig = {};
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const SETTERS: Record<string, (v: any) => void> = {
|
||||
sourceType: v => sourceType = v, localPath: v => localPath = v,
|
||||
repoUrl: v => repoUrl = v, cloneTarget: v => cloneTarget = v,
|
||||
githubRepo: v => githubRepo = v, selectedTemplate: v => selectedTemplate = v,
|
||||
templateTargetDir: v => templateTargetDir = v, remoteHost: v => remoteHost = v,
|
||||
remoteUser: v => remoteUser = v, remotePath: v => remotePath = v,
|
||||
remoteAuthMethod: v => remoteAuthMethod = v, remotePassword: v => remotePassword = v,
|
||||
remoteKeyPath: v => remoteKeyPath = v, remoteSshfs: v => remoteSshfs = v,
|
||||
projectName: v => projectName = v, selectedBranch: v => selectedBranch = v,
|
||||
useWorktrees: v => useWorktrees = v, selectedGroupId: v => selectedGroupId = v,
|
||||
projectIcon: v => projectIcon = v, projectColor: v => projectColor = v,
|
||||
shellChoice: v => shellChoice = v, provider: v => provider = v,
|
||||
model: v => model = v, permissionMode: v => permissionMode = v,
|
||||
systemPrompt: v => systemPrompt = v, autoStart: v => autoStart = v,
|
||||
modelConfig: v => modelConfig = v,
|
||||
sourceType: v => sourceType = v, localPath: v => localPath = v, repoUrl: v => repoUrl = v, cloneTarget: v => cloneTarget = v,
|
||||
githubRepo: v => githubRepo = v, selectedTemplate: v => selectedTemplate = v, templateTargetDir: v => templateTargetDir = v,
|
||||
remoteHost: v => remoteHost = v, remoteUser: v => remoteUser = v, remotePath: v => remotePath = v,
|
||||
remoteAuthMethod: v => remoteAuthMethod = v, remotePassword: v => remotePassword = v, remoteKeyPath: v => remoteKeyPath = v,
|
||||
remoteSshfs: v => remoteSshfs = v, remoteSshfsMountpoint: v => remoteSshfsMountpoint = v,
|
||||
projectName: v => projectName = v, selectedBranch: v => selectedBranch = v, useWorktrees: v => useWorktrees = v,
|
||||
selectedGroupId: v => selectedGroupId = v, projectIcon: v => projectIcon = v, projectColor: v => projectColor = v,
|
||||
shellChoice: v => shellChoice = v, provider: v => provider = v, model: v => model = v,
|
||||
permissionMode: v => permissionMode = v, systemPrompt: v => systemPrompt = v, autoStart: v => autoStart = v, modelConfig: v => modelConfig = v,
|
||||
};
|
||||
function handleUpdate(field: string, value: unknown) { SETTERS[field]?.(value); }
|
||||
</script>
|
||||
|
||||
<div class="wizard-overlay" role="dialog" aria-label="New Project">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="wizard-overlay" role="dialog" aria-label="New Project" tabindex={-1} onkeydown={handleOverlayKeydown}>
|
||||
<div class="wizard-panel">
|
||||
<div class="wz-header">
|
||||
<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>
|
||||
<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={closeWizard} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="wz-body" style:display={step === 1 ? 'flex' : 'none'}>
|
||||
<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} />
|
||||
<WizardStep1 bind:this={step1Ref} {sourceType} {localPath} {repoUrl} {cloneTarget} {githubRepo} {selectedTemplate} {remoteHost} {remoteUser} {remotePath} {remoteAuthMethod} {remotePassword} {remoteKeyPath} {remoteSshfs} {remoteSshfsMountpoint} {pathValid} {isGitRepo} {gitBranch} {gitProbeStatus} {gitProbeBranches} {githubInfo} {githubProbeStatus} {githubLoading} {cloning} {templates} {templateTargetDir} {templateOriginDir} onUpdate={handleUpdate} />
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={closeWizard}>Cancel</button>
|
||||
<div class="wz-footer-right">
|
||||
{#if cloning}<span class="wz-cloning">Cloning…</span>{/if}
|
||||
<button class="wz-btn primary" disabled={!step1Valid() || cloning} onclick={goToStep2}>Next</button>
|
||||
<div class="wz-footer-right">{#if cloning}<span class="wz-cloning">Cloning…</span>{/if}<button class="wz-btn primary" disabled={!step1Valid() || cloning} onclick={goToStep2}>Next</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wz-body" style:display={step === 2 ? 'flex' : 'none'}>
|
||||
<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}>Back</button>
|
||||
<button class="wz-btn primary" disabled={!!nameError} onclick={goToStep3}>Next</button>
|
||||
<WizardStep2 bind:this={step2Ref} {projectName} {nameError} {selectedBranch} {branches} {useWorktrees} {selectedGroupId} {groups} {projectIcon} {projectColor} {shellChoice} {isGitRepo} onUpdate={handleUpdate} />
|
||||
<div class="wz-footer"><button class="wz-btn secondary" onclick={goBack}>Back</button><button class="wz-btn primary" disabled={!!nameError} onclick={goToStep3}>Next</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wz-body" style:display={step === 3 ? 'flex' : 'none'}>
|
||||
<h3 class="wz-step-title">Agent</h3>
|
||||
<WizardStep3 {provider} {model} {permissionMode} {systemPrompt} {autoStart} {detectedProviders} {providerModels} {modelsLoading} {modelConfig} onUpdate={handleUpdate} />
|
||||
<div class="wz-footer">
|
||||
<button class="wz-btn secondary" onclick={goBack}>Back</button>
|
||||
<div class="wz-footer-right">
|
||||
<button class="wz-btn ghost" onclick={createProject}>Skip</button>
|
||||
<button class="wz-btn primary" onclick={createProject}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wz-footer"><button class="wz-btn secondary" onclick={goBack}>Back</button><div class="wz-footer-right"><button class="wz-btn ghost" onclick={createProject}>Skip</button><button class="wz-btn primary" onclick={createProject}>Create</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -283,12 +182,14 @@
|
|||
.wz-step-dot.done { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border-color: var(--ctp-green); color: var(--ctp-green); }
|
||||
.wz-close { background: none; border: none; color: var(--ctp-overlay1); cursor: pointer; font-size: 0.875rem; padding: 0.25rem; border-radius: 0.25rem; }
|
||||
.wz-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
|
||||
.wz-close:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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-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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: 1px; }
|
||||
.wz-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.wz-btn.primary { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.wz-btn.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
|
||||
|
|
@ -296,5 +197,4 @@
|
|||
.wz-btn.secondary:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.wz-btn.ghost { background: transparent; border: none; color: var(--ctp-subtext0); }
|
||||
.wz-btn.ghost:hover { color: var(--ctp-text); }
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,113 +1,79 @@
|
|||
<script lang="ts">
|
||||
import { t } from './i18n.svelte.ts';
|
||||
import { onMount } from 'svelte';
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { sanitize, sanitizeUrl, sanitizePath, isValidGitUrl, isValidGithubRepo } from './sanitize.ts';
|
||||
import PathBrowser from './PathBrowser.svelte';
|
||||
import CustomCheckbox from './ui/CustomCheckbox.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[];
|
||||
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; remoteSshfsMountpoint: string;
|
||||
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;
|
||||
githubProbeStatus: 'idle' | 'probing' | 'ok' | 'error'; githubLoading: boolean;
|
||||
cloning: boolean; templates: Array<{ id: string; name: string; description: string; icon: string }>;
|
||||
templateTargetDir: string; templateOriginDir: 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,
|
||||
sourceType, localPath, repoUrl, cloneTarget, githubRepo, selectedTemplate,
|
||||
remoteHost, remoteUser, remotePath, remoteAuthMethod, remotePassword,
|
||||
remoteKeyPath, remoteSshfs, remoteSshfsMountpoint = '', pathValid, isGitRepo,
|
||||
gitBranch, gitProbeStatus, gitProbeBranches, githubInfo,
|
||||
githubProbeStatus = 'idle', githubLoading, cloning, templates,
|
||||
templateTargetDir, templateOriginDir = '', onUpdate,
|
||||
}: Props = $props();
|
||||
|
||||
let showBrowser = $state(false);
|
||||
let showCloneBrowser = $state(false);
|
||||
let showTemplateBrowser = $state(false);
|
||||
let showKeyBrowser = $state(false);
|
||||
let showMountBrowser = $state(false);
|
||||
let sshfsInstalled = $state<boolean | null>(null);
|
||||
let firstInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
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 SOURCE_TYPES: Array<{ value: SourceType; label: string }> = [
|
||||
{ value: 'local', label: 'Local Folder' }, { value: 'git-clone', label: 'Git Clone' },
|
||||
{ value: 'github', label: 'Git Platform' }, { value: 'template', label: 'Template' },
|
||||
{ value: 'remote', label: 'SSH Remote' },
|
||||
];
|
||||
|
||||
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' },
|
||||
{ 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 '';
|
||||
}
|
||||
export function focusFirst() { firstInput?.focus(); }
|
||||
|
||||
onMount(async () => {
|
||||
try { const r = await appRpc.request['ssh.checkSshfs']({}); sshfsInstalled = r?.installed ?? false; }
|
||||
catch { sshfsInstalled = false; }
|
||||
});
|
||||
|
||||
function vIcon(s: PathState) { return s === 'valid' ? '\u2713' : s === 'invalid' ? '\u2717' : s === 'not-dir' ? '\u26A0' : s === 'checking' ? '\u2026' : ''; }
|
||||
function vColor(s: PathState) { return s === 'valid' ? 'var(--ctp-green)' : s === 'invalid' ? 'var(--ctp-red)' : s === 'not-dir' ? 'var(--ctp-peach)' : 'var(--ctp-overlay0)'; }
|
||||
|
||||
async function browse(target: string) {
|
||||
const starts: Record<string, string> = { local: localPath, clone: cloneTarget, template: templateTargetDir, key: remoteKeyPath, mount: remoteSshfsMountpoint };
|
||||
const fields: Record<string, string> = { local: 'localPath', clone: 'cloneTarget', template: 'templateTargetDir', key: 'remoteKeyPath', mount: 'remoteSshfsMountpoint' };
|
||||
try { const r = await appRpc.request['files.pickDirectory']({ startingFolder: starts[target] || '~/' }); if (r?.path) onUpdate(fields[target], r.path); } catch { /* no-op */ }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
function closeBrowsers() { showBrowser = false; showMountBrowser = false; }
|
||||
function selectBrowser(field: string, path: string) { onUpdate(field, path); closeBrowsers(); }
|
||||
function srcKey(e: KeyboardEvent, v: SourceType) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onUpdate('sourceType', v); } }
|
||||
</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}
|
||||
<label class="wz-radio" class:selected={sourceType === opt.value}
|
||||
tabindex={0} role="radio" aria-checked={sourceType === opt.value}
|
||||
onkeydown={(e) => srcKey(e, opt.value)}>
|
||||
<input type="radio" name="source" value={opt.value} checked={sourceType === opt.value}
|
||||
onchange={() => onUpdate('sourceType', opt.value)} />
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
|
|
@ -115,29 +81,23 @@
|
|||
</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"
|
||||
<input class="wz-input" type="text" placeholder="/home/user/project" bind:this={firstInput}
|
||||
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}
|
||||
<button class="wz-browse-btn" onclick={() => browse('local')}>📂</button>
|
||||
<button class="wz-browse-btn" onclick={() => showBrowser = !showBrowser}>🔍</button>
|
||||
{#if pathValid !== 'idle'}<span class="wz-validation" style:color={vColor(pathValid)}>{vIcon(pathValid)}</span>{/if}
|
||||
</div>
|
||||
<div style:display={showBrowser ? 'block' : 'none'} class="wz-browser-wrap">
|
||||
<PathBrowser onSelect={(p) => handleBrowserSelect('localPath', p)} onClose={() => showBrowser = false} />
|
||||
<PathBrowser onSelect={(p) => selectBrowser('localPath', p)} onClose={() => showBrowser = false} />
|
||||
</div>
|
||||
{#if pathValid === 'valid' && isGitRepo}
|
||||
<span class="wz-badge git-badge">Git repo ({gitBranch})</span>
|
||||
{/if}
|
||||
{#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">
|
||||
|
|
@ -147,23 +107,23 @@
|
|||
{#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}
|
||||
{#if gitProbeStatus === 'ok' && gitProbeBranches.length > 0}<span class="wz-hint" style="color: var(--ctp-subtext0);">{gitProbeBranches.length} branches found</span>{/if}
|
||||
{#if gitProbeStatus === 'error'}<span class="wz-hint error">Repository not found or inaccessible</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>
|
||||
<button class="wz-browse-btn" onclick={() => browse('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"
|
||||
<label class="wz-label">Repository (owner/repo or full URL)</label>
|
||||
<input class="wz-input" type="text" placeholder="owner/repo or https://gitlab.com/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 githubProbeStatus === 'ok'}<span class="wz-hint valid">Repository verified</span>{/if}
|
||||
{#if githubProbeStatus === 'error'}<span class="wz-hint error">Repository not found or inaccessible</span>{/if}
|
||||
{#if githubInfo}
|
||||
<div class="wz-github-info">
|
||||
<span class="wz-hint" style="color: var(--ctp-yellow);">★ {githubInfo.stars}</span>
|
||||
|
|
@ -173,18 +133,18 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div style:display={sourceType === 'template' ? 'flex' : 'none'} class="wz-field-col">
|
||||
{#if templateOriginDir}<span class="wz-hint" style="color: var(--ctp-overlay0);">Templates from: {templateOriginDir}</span>{/if}
|
||||
<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>
|
||||
<button class="wz-browse-btn" onclick={() => browse('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)}>
|
||||
onclick={() => onUpdate('selectedTemplate', tmpl.id)} tabindex={0}>
|
||||
<span class="wz-template-icon">{tmpl.icon}</span>
|
||||
<span class="wz-template-name">{tmpl.name}</span>
|
||||
<span class="wz-template-desc">{tmpl.description}</span>
|
||||
|
|
@ -193,80 +153,74 @@
|
|||
</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)} />
|
||||
<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)} />
|
||||
<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)} />
|
||||
|
||||
<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}
|
||||
{#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)} />
|
||||
<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>
|
||||
<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={() => browse('key')}>📂</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if sshfsInstalled === false}<span class="wz-hint warn">sshfs not installed -- mount option unavailable</span>{/if}
|
||||
<CustomCheckbox checked={remoteSshfs} label="Mount via SSHFS" disabled={sshfsInstalled === false} onChange={v => onUpdate('remoteSshfs', v)} />
|
||||
<div style:display={remoteSshfs && sshfsInstalled ? 'flex' : 'none'} class="wz-field-col">
|
||||
<label class="wz-label">Local mountpoint</label>
|
||||
<div class="wz-path-row">
|
||||
<input class="wz-input" type="text" placeholder="/mnt/remote-project" value={remoteSshfsMountpoint} oninput={(e) => onUpdate('remoteSshfsMountpoint', (e.target as HTMLInputElement).value)} />
|
||||
<button class="wz-browse-btn" onclick={() => browse('mount')}>📂</button>
|
||||
<button class="wz-browse-btn" onclick={() => showMountBrowser = !showMountBrowser}>🔍</button>
|
||||
</div>
|
||||
<div style:display={showMountBrowser ? 'block' : 'none'} class="wz-browser-wrap">
|
||||
<PathBrowser onSelect={(p) => selectBrowser('remoteSshfsMountpoint', p)} onClose={() => showMountBrowser = false} />
|
||||
</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 { 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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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:focus { outline: 2px solid var(--ctp-blue); outline-offset: -1px; 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-browse-btn:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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-hint.valid { color: var(--ctp-green); }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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); }
|
||||
|
|
@ -275,10 +229,7 @@
|
|||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -2px; }
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
|
||||
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
|
||||
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
|
||||
Brain, BrainCircuit, Wand2, Table, BarChart3, Container, Activity,
|
||||
Settings, Cog, Key, Fingerprint, ShieldCheck, Image, Video, Music,
|
||||
Camera, Palette, MessageCircle, Mail, Phone, Radio, Send,
|
||||
Gamepad2, BookOpen, Blocks, Leaf,
|
||||
} from 'lucide-svelte';
|
||||
import CustomDropdown from './ui/CustomDropdown.svelte';
|
||||
import CustomCheckbox from './ui/CustomCheckbox.svelte';
|
||||
|
|
@ -35,11 +39,22 @@
|
|||
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
|
||||
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
|
||||
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
|
||||
Brain, BrainCircuit, Wand2, Table, BarChart3, Container, Activity,
|
||||
Settings, Cog, Key, Fingerprint, ShieldCheck, Image, Video, Music,
|
||||
Camera, Palette, MessageCircle, Mail, Phone, Radio, Send,
|
||||
Gamepad2, BookOpen, Blocks, Leaf,
|
||||
};
|
||||
|
||||
let firstInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
export function focusFirst() {
|
||||
firstInput?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="wz-label">Project name</label>
|
||||
<input class="wz-input" class:error={!!nameError} type="text"
|
||||
bind:this={firstInput}
|
||||
value={projectName} oninput={(e) => onUpdate('projectName', (e.target as HTMLInputElement).value)}
|
||||
placeholder="my-project" />
|
||||
{#if nameError}<span class="wz-hint error">{nameError}</span>{/if}
|
||||
|
|
@ -71,7 +86,8 @@
|
|||
<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}>
|
||||
onclick={() => onUpdate('projectIcon', ic.name)} title={ic.label}
|
||||
tabindex={0}>
|
||||
{#if ICON_MAP[ic.name]}
|
||||
<svelte:component this={ICON_MAP[ic.name]} size={16} />
|
||||
{/if}
|
||||
|
|
@ -85,7 +101,8 @@
|
|||
<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>
|
||||
title={c.name}
|
||||
tabindex={0}></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
@ -99,17 +116,19 @@
|
|||
<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:focus { outline: 2px solid var(--ctp-blue); outline-offset: -1px; 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-icon-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.wz-icon-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; max-height: 8rem; overflow-y: auto; }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: 2px; }
|
||||
.wz-color-dot.selected { border-color: var(--ctp-text); box-shadow: 0 0 0 1px var(--ctp-surface0); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from './rpc.ts';
|
||||
import CustomDropdown from './ui/CustomDropdown.svelte';
|
||||
import CustomCheckbox from './ui/CustomCheckbox.svelte';
|
||||
import ModelConfigPanel from './ModelConfigPanel.svelte';
|
||||
|
|
@ -65,6 +64,14 @@
|
|||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderKeydown(e: KeyboardEvent, pid: string) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onUpdate('provider', pid);
|
||||
onUpdate('modelConfig', {});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="wz-label">AI Provider</label>
|
||||
|
|
@ -74,7 +81,9 @@
|
|||
<div class="wz-provider-grid">
|
||||
{#each availableProviders as p}
|
||||
<button class="wz-provider-card" class:active={provider === p.id}
|
||||
onclick={() => { onUpdate('provider', p.id); onUpdate('modelConfig', {}); }}>
|
||||
onclick={() => { onUpdate('provider', p.id); onUpdate('modelConfig', {}); }}
|
||||
onkeydown={(e) => handleProviderKeydown(e, p.id)}
|
||||
tabindex={0}>
|
||||
<span class="wz-provider-name">{p.id}</span>
|
||||
{#if 'hasApiKey' in p}
|
||||
<span class="wz-provider-badge">{providerBadge(p as ProviderInfo)}</span>
|
||||
|
|
@ -92,10 +101,13 @@
|
|||
onSelect={v => onUpdate('model', v)}
|
||||
/>
|
||||
{: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}
|
||||
<CustomDropdown
|
||||
items={[{ value: '', label: modelsLoading ? 'Loading models...' : defaultPlaceholder() }]}
|
||||
selected={model}
|
||||
placeholder={modelsLoading ? 'Loading...' : defaultPlaceholder()}
|
||||
onSelect={v => onUpdate('model', v)}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Per-model configuration -->
|
||||
|
|
@ -110,7 +122,8 @@
|
|||
<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>
|
||||
onclick={() => onUpdate('permissionMode', pm)}
|
||||
tabindex={0}>{pm}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
|
@ -127,15 +140,15 @@
|
|||
|
||||
<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-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%; resize: vertical; min-height: 3rem; }
|
||||
.wz-textarea:focus { outline: 2px solid var(--ctp-blue); outline-offset: -1px; border-color: var(--ctp-blue); }
|
||||
.wz-textarea::placeholder { color: var(--ctp-overlay0); }
|
||||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.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); }
|
||||
|
|
@ -143,5 +156,6 @@
|
|||
.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:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -2px; }
|
||||
.wz-seg-btn.active { background: color-mix(in srgb, var(--ctp-blue) 20%, transparent); color: var(--ctp-blue); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
let otlpEndpoint = $state('');
|
||||
let relayUrls = $state('');
|
||||
let connTimeout = $state(30);
|
||||
let templateDir = $state('~/.config/agor/templates/');
|
||||
|
||||
let appVersion = $state('...');
|
||||
let updateChecking = $state(false);
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
function setOtlp(v: string) { otlpEndpoint = v; persist('otlp_endpoint', v); }
|
||||
function setRelayUrls(v: string) { relayUrls = v; persist('relay_urls', v); }
|
||||
function setConnTimeout(v: number) { connTimeout = v; persist('connection_timeout', String(v)); }
|
||||
function setTemplateDir(v: string) { templateDir = v; persist('template_dir', v); }
|
||||
|
||||
function togglePlugin(id: string) {
|
||||
plugins = plugins.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p);
|
||||
|
|
@ -107,6 +109,7 @@
|
|||
if (settings['otlp_endpoint']) otlpEndpoint = settings['otlp_endpoint'];
|
||||
if (settings['relay_urls']) relayUrls = settings['relay_urls'];
|
||||
if (settings['connection_timeout']) connTimeout = parseInt(settings['connection_timeout'], 10) || 30;
|
||||
if (settings['template_dir']) templateDir = settings['template_dir'];
|
||||
if (settings['plugin_states']) {
|
||||
try {
|
||||
const states: Record<string, boolean> = JSON.parse(settings['plugin_states']);
|
||||
|
|
@ -153,6 +156,14 @@
|
|||
<span class="unit">seconds</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Templates</h3>
|
||||
<div class="field">
|
||||
<label class="lbl" for="adv-tpldir">Template directory</label>
|
||||
<input id="adv-tpldir" class="text-in" value={templateDir} placeholder="~/.config/agor/templates/"
|
||||
onchange={e => setTemplateDir((e.target as HTMLInputElement).value)} />
|
||||
<span class="unit">Where project templates are stored</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.75rem;">Plugins</h3>
|
||||
<div class="plugin-list">
|
||||
{#each plugins as plug}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
let open = $state(false);
|
||||
let focusIndex = $state(-1);
|
||||
let flipUp = $state(false);
|
||||
let menuRef = $state<HTMLDivElement | null>(null);
|
||||
let triggerRef = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
|
|
@ -37,11 +38,20 @@
|
|||
|
||||
let flatItems = $derived(items);
|
||||
|
||||
function computeFlip() {
|
||||
if (!triggerRef) return;
|
||||
const rect = triggerRef.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const menuHeight = Math.min(items.length * 2.25 * 16, 14 * 16); // estimate
|
||||
flipUp = spaceBelow < menuHeight && rect.top > spaceBelow;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
open = !open;
|
||||
if (open) {
|
||||
focusIndex = flatItems.findIndex(i => i.value === selected);
|
||||
computeFlip();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +123,7 @@
|
|||
<!-- svelte-ignore a11y_no_static_element_interactions a11y-no-static-element-interactions -->
|
||||
<div class="dd-backdrop" style:display={open ? 'block' : 'none'} onclick={handleBackdropClick} onkeydown={e => e.key === 'Escape' && close()}></div>
|
||||
|
||||
<div class="dd-menu" style:display={open ? 'flex' : 'none'} bind:this={menuRef} role="listbox">
|
||||
<div class="dd-menu" class:flip-up={flipUp} 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>
|
||||
|
|
@ -168,6 +178,7 @@
|
|||
transition: border-color 0.12s;
|
||||
}
|
||||
.dd-trigger:hover:not(.disabled) { border-color: var(--ctp-surface2); }
|
||||
.dd-trigger:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.dd-trigger.open { border-color: var(--ctp-blue); }
|
||||
.dd-trigger.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
|
|
@ -191,6 +202,11 @@
|
|||
max-height: 14rem; overflow-y: auto;
|
||||
box-shadow: 0 0.5rem 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
.dd-menu.flip-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.125rem);
|
||||
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;
|
||||
|
|
@ -206,6 +222,7 @@
|
|||
transition: background 0.08s;
|
||||
}
|
||||
.dd-option:hover, .dd-option.focused { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.dd-option:focus-visible { outline: 2px solid var(--ctp-blue); outline-offset: -1px; }
|
||||
.dd-option.selected {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-blue); font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -6,31 +6,65 @@
|
|||
*/
|
||||
|
||||
/** 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' },
|
||||
export const PROJECT_ICONS: Array<{ name: string; label: string; category: string }> = [
|
||||
// General
|
||||
{ name: 'Terminal', label: 'Terminal', category: 'General' },
|
||||
{ name: 'Server', label: 'Server', category: 'General' },
|
||||
{ name: 'Globe', label: 'Web', category: 'General' },
|
||||
{ name: 'Code', label: 'Code', category: 'General' },
|
||||
{ name: 'Cpu', label: 'CPU', category: 'General' },
|
||||
{ name: 'Zap', label: 'Zap', category: 'General' },
|
||||
{ name: 'Rocket', label: 'Rocket', category: 'General' },
|
||||
{ name: 'Bug', label: 'Bug', category: 'General' },
|
||||
{ name: 'Puzzle', label: 'Plugin', category: 'General' },
|
||||
{ name: 'Box', label: 'Package', category: 'General' },
|
||||
{ name: 'Layers', label: 'Layers', category: 'General' },
|
||||
{ name: 'GitBranch', label: 'Branch', category: 'General' },
|
||||
{ name: 'FileCode', label: 'Script', category: 'General' },
|
||||
{ name: 'Wrench', label: 'Tools', category: 'General' },
|
||||
{ name: 'Folder', label: 'Folder', category: 'General' },
|
||||
{ name: 'FlaskConical', label: 'Lab', category: 'General' },
|
||||
// AI / ML
|
||||
{ name: 'Brain', label: 'Brain', category: 'AI / ML' },
|
||||
{ name: 'BrainCircuit', label: 'Neural Net', category: 'AI / ML' },
|
||||
{ name: 'Sparkles', label: 'AI', category: 'AI / ML' },
|
||||
{ name: 'Wand2', label: 'Magic', category: 'AI / ML' },
|
||||
{ name: 'Bot', label: 'Bot', category: 'AI / ML' },
|
||||
// Data
|
||||
{ name: 'Database', label: 'Database', category: 'Data' },
|
||||
{ name: 'HardDrive', label: 'Storage', category: 'Data' },
|
||||
{ name: 'Table', label: 'Table', category: 'Data' },
|
||||
{ name: 'BarChart3', label: 'Chart', category: 'Data' },
|
||||
// DevOps
|
||||
{ name: 'Container', label: 'Container', category: 'DevOps' },
|
||||
{ name: 'Cloud', label: 'Cloud', category: 'DevOps' },
|
||||
{ name: 'Wifi', label: 'Network', category: 'DevOps' },
|
||||
{ name: 'Activity', label: 'Monitor', category: 'DevOps' },
|
||||
{ name: 'Settings', label: 'Settings', category: 'DevOps' },
|
||||
{ name: 'Cog', label: 'Config', category: 'DevOps' },
|
||||
// Security
|
||||
{ name: 'Shield', label: 'Shield', category: 'Security' },
|
||||
{ name: 'Lock', label: 'Lock', category: 'Security' },
|
||||
{ name: 'Key', label: 'Key', category: 'Security' },
|
||||
{ name: 'Fingerprint', label: 'Fingerprint', category: 'Security' },
|
||||
{ name: 'ShieldCheck', label: 'Verified', category: 'Security' },
|
||||
// Media
|
||||
{ name: 'Image', label: 'Image', category: 'Media' },
|
||||
{ name: 'Video', label: 'Video', category: 'Media' },
|
||||
{ name: 'Music', label: 'Music', category: 'Media' },
|
||||
{ name: 'Camera', label: 'Camera', category: 'Media' },
|
||||
{ name: 'Palette', label: 'Design', category: 'Media' },
|
||||
// Communication
|
||||
{ name: 'MessageCircle', label: 'Chat', category: 'Communication' },
|
||||
{ name: 'Mail', label: 'Mail', category: 'Communication' },
|
||||
{ name: 'Phone', label: 'Phone', category: 'Communication' },
|
||||
{ name: 'Radio', label: 'Radio', category: 'Communication' },
|
||||
{ name: 'Send', label: 'Send', category: 'Communication' },
|
||||
// Misc
|
||||
{ name: 'Gamepad2', label: 'Game', category: 'Misc' },
|
||||
{ name: 'BookOpen', label: 'Docs', category: 'Misc' },
|
||||
{ name: 'Blocks', label: 'Blocks', category: 'Misc' },
|
||||
{ name: 'Leaf', label: 'Eco', category: 'Misc' },
|
||||
];
|
||||
|
||||
/** Catppuccin accent colors for project color selection. */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface WizardState {
|
|||
githubRepo: string;
|
||||
selectedTemplate: string;
|
||||
templateTargetDir: string;
|
||||
templateOriginDir: string;
|
||||
remoteHost: string;
|
||||
remoteUser: string;
|
||||
remotePath: string;
|
||||
|
|
@ -25,12 +26,14 @@ export interface WizardState {
|
|||
remotePassword: string;
|
||||
remoteKeyPath: string;
|
||||
remoteSshfs: boolean;
|
||||
remoteSshfsMountpoint: string;
|
||||
pathValid: PathState;
|
||||
isGitRepo: boolean;
|
||||
gitBranch: string;
|
||||
gitProbeStatus: ProbeState;
|
||||
gitProbeBranches: string[];
|
||||
githubInfo: { stars: number; description: string; defaultBranch: string } | null;
|
||||
githubProbeStatus: ProbeState;
|
||||
githubLoading: boolean;
|
||||
cloning: boolean;
|
||||
projectName: string;
|
||||
|
|
@ -55,10 +58,13 @@ export function getDefaults(groupId: string): WizardState {
|
|||
return {
|
||||
step: 1, sourceType: 'local', localPath: '', repoUrl: '', cloneTarget: '',
|
||||
githubRepo: '', selectedTemplate: '', templateTargetDir: '~/projects',
|
||||
templateOriginDir: '',
|
||||
remoteHost: '', remoteUser: '', remotePath: '',
|
||||
remoteAuthMethod: 'agent', remotePassword: '', remoteKeyPath: '~/.ssh/id_ed25519',
|
||||
remoteSshfs: false, pathValid: 'idle', isGitRepo: false, gitBranch: '',
|
||||
gitProbeStatus: 'idle', gitProbeBranches: [], githubInfo: null, githubLoading: false,
|
||||
remoteSshfs: false, remoteSshfsMountpoint: '',
|
||||
pathValid: 'idle', isGitRepo: false, gitBranch: '',
|
||||
gitProbeStatus: 'idle', gitProbeBranches: [], githubInfo: null,
|
||||
githubProbeStatus: 'idle', githubLoading: false,
|
||||
cloning: false, projectName: '', nameError: '', selectedBranch: '',
|
||||
branches: [], useWorktrees: false, selectedGroupId: groupId,
|
||||
projectIcon: 'Terminal', projectColor: 'var(--ctp-blue)', shellChoice: 'bash',
|
||||
|
|
|
|||
|
|
@ -242,11 +242,17 @@ export type PtyRPCRequests = {
|
|||
response: { ok: boolean; branches: string[]; defaultBranch: string; error?: string };
|
||||
};
|
||||
|
||||
/** Check if sshfs is installed. */
|
||||
"ssh.checkSshfs": {
|
||||
params: Record<string, never>;
|
||||
response: { installed: boolean; path: string | null };
|
||||
};
|
||||
|
||||
// ── Project templates RPC ───────────────────────────────────────────────────
|
||||
|
||||
/** Return available project templates. */
|
||||
/** Return available project templates. Optionally pass custom template dir. */
|
||||
"project.templates": {
|
||||
params: Record<string, never>;
|
||||
params: { templateDir?: string };
|
||||
response: {
|
||||
templates: Array<{
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue