feat(electrobun): ProjectWizard — 3-step project creation with 5 source types
Step 1 — Source: local folder (path browser + validation), git clone, GitHub URL, template (4 built-in), remote SSH Step 2 — Configure: name, branch selector, worktree toggle, group, icon, shell Step 3 — Agent: provider, model, permission mode, system prompt, auto-start - ProjectWizard.svelte: 3-step wizard with display toggle (rule 55) - PathBrowser.svelte: inline directory browser with breadcrumbs + shortcuts - git-handlers.ts: git.branches + git.clone RPC handlers - files.statEx RPC: path validation + git detection + writable check - 39 new i18n keys, 172 total TranslationKey entries - App.svelte: wizard overlay replaces simple add-project card
This commit is contained in:
parent
1d2975b07b
commit
45bca3b96f
9 changed files with 1203 additions and 45 deletions
252
ui-electrobun/src/mainview/PathBrowser.svelte
Normal file
252
ui-electrobun/src/mainview/PathBrowser.svelte
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from './rpc.ts';
|
||||
import { t } from './i18n.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onSelect, onClose }: Props = $props();
|
||||
|
||||
const HOME = '/home/' + (typeof window !== 'undefined' ? '' : '');
|
||||
const SHORTCUTS = [
|
||||
{ label: 'Home', path: '~' },
|
||||
{ label: 'Desktop', path: '~/Desktop' },
|
||||
{ label: 'Documents', path: '~/Documents' },
|
||||
{ label: '/tmp', path: '/tmp' },
|
||||
];
|
||||
|
||||
let currentPath = $state('~');
|
||||
let entries = $state<Array<{ name: string; type: 'file' | 'dir'; size: number }>>([]);
|
||||
let loading = $state(false);
|
||||
let filter = $state('');
|
||||
let error = $state('');
|
||||
|
||||
// Breadcrumb segments
|
||||
let breadcrumbs = $derived(() => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const crumbs: Array<{ label: string; path: string }> = [];
|
||||
let acc = '';
|
||||
for (const part of parts) {
|
||||
acc += (acc === '' && currentPath.startsWith('~') && crumbs.length === 0) ? part : '/' + part;
|
||||
if (crumbs.length === 0 && currentPath.startsWith('~')) acc = part;
|
||||
crumbs.push({ label: part, path: acc.startsWith('~') ? acc : '/' + acc });
|
||||
}
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
let filteredEntries = $derived(
|
||||
entries.filter(e => e.type === 'dir' && (filter === '' || e.name.toLowerCase().includes(filter.toLowerCase())))
|
||||
);
|
||||
|
||||
async function loadDir(dirPath: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const expandedPath = dirPath.replace(/^~/, process.env.HOME ?? '/home');
|
||||
const result = await appRpc.request['files.list']({ path: expandedPath });
|
||||
if (result?.error) {
|
||||
error = result.error;
|
||||
entries = [];
|
||||
} else {
|
||||
entries = result?.entries ?? [];
|
||||
}
|
||||
currentPath = dirPath;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
entries = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(dirPath: string) {
|
||||
filter = '';
|
||||
loadDir(dirPath);
|
||||
}
|
||||
|
||||
function selectFolder(name: string) {
|
||||
const sep = currentPath.endsWith('/') ? '' : '/';
|
||||
const newPath = currentPath + sep + name;
|
||||
navigateTo(newPath);
|
||||
}
|
||||
|
||||
function confirmSelect() {
|
||||
onSelect(currentPath);
|
||||
}
|
||||
|
||||
// Load initial directory
|
||||
$effect(() => {
|
||||
loadDir('~');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="path-browser">
|
||||
<!-- Header -->
|
||||
<div class="pb-header">
|
||||
<span class="pb-title">{t('wizard.step1.browse' as any)}</span>
|
||||
<button class="pb-close" onclick={onClose} aria-label={t('common.close' as any)}>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<div class="pb-shortcuts">
|
||||
{#each SHORTCUTS as sc}
|
||||
<button class="pb-shortcut" onclick={() => navigateTo(sc.path)}>{sc.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="pb-breadcrumbs">
|
||||
{#each breadcrumbs() as crumb, i}
|
||||
{#if i > 0}<span class="pb-sep">/</span>{/if}
|
||||
<button class="pb-crumb" onclick={() => navigateTo(crumb.path)}>{crumb.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<input
|
||||
class="pb-filter"
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
bind:value={filter}
|
||||
/>
|
||||
|
||||
<!-- Directory list -->
|
||||
<div class="pb-list">
|
||||
{#if loading}
|
||||
<div class="pb-loading">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="pb-error">{error}</div>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<div class="pb-empty">No directories</div>
|
||||
{:else}
|
||||
{#each filteredEntries as entry}
|
||||
<button class="pb-entry" onclick={() => selectFolder(entry.name)}>
|
||||
<span class="pb-icon">📁</span>
|
||||
<span class="pb-name">{entry.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer: current path + select -->
|
||||
<div class="pb-footer">
|
||||
<span class="pb-current" title={currentPath}>{currentPath}</span>
|
||||
<button class="pb-select-btn" onclick={confirmSelect}>Select</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.path-browser {
|
||||
position: absolute;
|
||||
top: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 20rem;
|
||||
box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||
}
|
||||
|
||||
.pb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.pb-title { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
|
||||
|
||||
.pb-close {
|
||||
background: none; border: none; color: var(--ctp-overlay1); cursor: pointer;
|
||||
font-size: 0.75rem; padding: 0.125rem 0.25rem; border-radius: 0.25rem;
|
||||
}
|
||||
.pb-close:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
|
||||
|
||||
.pb-shortcuts {
|
||||
display: flex; gap: 0.25rem; padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.pb-shortcut {
|
||||
padding: 0.125rem 0.375rem; font-size: 0.625rem; border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0); border: none; color: var(--ctp-subtext0);
|
||||
cursor: pointer; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-shortcut:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
|
||||
.pb-breadcrumbs {
|
||||
display: flex; align-items: center; gap: 0.125rem;
|
||||
padding: 0.25rem 0.5rem; flex-wrap: wrap;
|
||||
font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.pb-sep { color: var(--ctp-overlay0); }
|
||||
|
||||
.pb-crumb {
|
||||
background: none; border: none; color: var(--ctp-blue); cursor: pointer;
|
||||
font-size: 0.6875rem; padding: 0; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-crumb:hover { text-decoration: underline; }
|
||||
|
||||
.pb-filter {
|
||||
margin: 0.25rem 0.5rem; padding: 0.25rem 0.375rem;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem; color: var(--ctp-text);
|
||||
font-size: 0.75rem; font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-filter:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.pb-filter::placeholder { color: var(--ctp-overlay0); }
|
||||
|
||||
.pb-list {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.pb-list::-webkit-scrollbar { width: 0.25rem; }
|
||||
.pb-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.125rem; }
|
||||
|
||||
.pb-entry {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
width: 100%; padding: 0.25rem 0.5rem;
|
||||
background: none; border: none; color: var(--ctp-text);
|
||||
cursor: pointer; font-size: 0.75rem; text-align: left;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
.pb-entry:hover { background: var(--ctp-surface0); }
|
||||
|
||||
.pb-icon { font-size: 0.875rem; flex-shrink: 0; }
|
||||
.pb-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pb-loading, .pb-error, .pb-empty {
|
||||
padding: 1rem; text-align: center; font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
.pb-error { color: var(--ctp-red); }
|
||||
|
||||
.pb-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem; border-top: 1px solid var(--ctp-surface0);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pb-current {
|
||||
font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
|
||||
.pb-select-btn {
|
||||
padding: 0.25rem 0.625rem; font-size: 0.75rem; border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
|
||||
border: 1px solid var(--ctp-blue); color: var(--ctp-blue);
|
||||
cursor: pointer; font-family: var(--ui-font-family); flex-shrink: 0;
|
||||
}
|
||||
.pb-select-btn:hover { background: color-mix(in srgb, var(--ctp-blue) 35%, transparent); }
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue