fix: preserve native welcome vault creation
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user