fix: preserve native welcome vault creation

This commit is contained in:
lucaronin
2026-04-19 02:37:29 +02:00
parent 3556d934ee
commit 2e2bd2f3b3
3 changed files with 189 additions and 8 deletions

View File

@@ -442,7 +442,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
### Remote Clone & Auth Model
@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |

View File

@@ -63,6 +63,28 @@ describe('WelcomeScreen', () => {
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Enter', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: 'Enter' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Space', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: ' ' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {
const onCreateVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateVault={onCreateVault} />)
@@ -77,6 +99,16 @@ describe('WelcomeScreen', () => {
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creatingAction="template" />)
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -26,6 +27,63 @@ interface WelcomeScreenPresentation {
title: string
}
type WelcomeActionButtonRef = React.RefObject<HTMLButtonElement | null>
interface WelcomeAction {
disabled: boolean
run: () => void
}
function isWelcomeActivationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Enter' || event.key === ' '
}
function isWelcomeNavigationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp'
}
function nextWelcomeActionIndex(
currentIndex: number,
event: globalThis.KeyboardEvent,
actionCount: number,
): number {
const direction = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1
return (currentIndex + direction + actionCount) % actionCount
}
function focusBelongsToWelcomeActions(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): boolean {
return activeElement === document.body
|| actionButtonRefs.some(({ current }) => current === activeElement)
}
function getFocusedWelcomeActionIndex(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): number {
return Math.max(
0,
actionButtonRefs.findIndex(({ current }) => current === activeElement),
)
}
function focusWelcomeAction(
actionButtonRefs: WelcomeActionButtonRef[],
actionIndex: number,
): void {
actionButtonRefs[actionIndex]?.current?.focus()
}
function triggerWelcomeAction(
actionIndex: number,
actions: WelcomeAction[],
): void {
const action = actions[actionIndex]
if (!action?.disabled) action.run()
}
const CARD_STYLE: React.CSSProperties = {
width: 'min(520px, 100%)',
background: 'var(--background)',
@@ -153,6 +211,7 @@ interface OptionButtonProps {
loading?: boolean
testId: string
autoFocus?: boolean
buttonRef?: React.RefObject<HTMLButtonElement | null>
}
function OptionButton({
@@ -167,11 +226,15 @@ function OptionButton({
loading,
testId,
autoFocus = false,
buttonRef,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
<button
<Button
type="button"
variant="outline"
size="lg"
style={{
...OPTION_BTN_STYLE,
background: hover ? 'var(--sidebar)' : 'var(--background)',
@@ -183,6 +246,8 @@ function OptionButton({
onMouseLeave={() => setHover(false)}
data-testid={testId}
autoFocus={autoFocus}
className="h-auto justify-start shadow-none"
ref={buttonRef}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
@@ -191,7 +256,7 @@ function OptionButton({
<p style={OPTION_LABEL_STYLE}>{loading ? (loadingLabel ?? label) : label}</p>
<p style={OPTION_DESC_STYLE}>{loading ? (loadingDescription ?? description) : description}</p>
</div>
</button>
</Button>
)
}
@@ -225,6 +290,77 @@ function getWelcomeScreenPresentation(
}
}
function useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
}: Pick<
WelcomeScreenProps,
'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
useEffect(() => {
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
useEffect(() => {
if (busy) return
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
const activeElement = document.activeElement
if (!focusBelongsToWelcomeActions(activeElement, actionButtonRefs)) return
const actionIndex = getFocusedWelcomeActionIndex(activeElement, actionButtonRefs)
if (isWelcomeNavigationKey(event)) {
event.preventDefault()
focusWelcomeAction(
actionButtonRefs,
nextWelcomeActionIndex(actionIndex, event, actionButtonRefs.length),
)
return
}
if (!isWelcomeActivationKey(event)) return
event.preventDefault()
triggerWelcomeAction(actionIndex, actions)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
}
}
export function WelcomeScreen({
mode,
defaultVaultPath,
@@ -239,6 +375,14 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
})
return (
<OnboardingShell
@@ -278,6 +422,7 @@ export function WelcomeScreen({
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
/>
<OptionButton
@@ -288,6 +433,7 @@ export function WelcomeScreen({
onClick={onOpenFolder}
disabled={busy}
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
@@ -301,6 +447,7 @@ export function WelcomeScreen({
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
@@ -316,14 +463,16 @@ export function WelcomeScreen({
{error}
</p>
{canRetryTemplate && (
<button
<Button
type="button"
variant="outline"
style={RETRY_BUTTON_STYLE}
onClick={onRetryCreateVault}
data-testid="welcome-retry-template"
className="shadow-none"
>
Retry download
</button>
</Button>
)}
</div>
)}