feat: seed mobile app vault

This commit is contained in:
lucaronin
2026-05-04 10:46:52 +02:00
parent 035adceeae
commit f7eea2d997
7 changed files with 174 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
FlatList,
Pressable,
@@ -17,7 +17,8 @@ import {
PencilSimple,
SlidersHorizontal,
} from 'phosphor-react-native'
import { MobileNote, notes, sidebarSections } from './demoData'
import { MobileNote, notes as fallbackNotes, sidebarSections } from './demoData'
import { loadDemoVaultNotes } from './mobileDemoVault'
import { MobileEditorAdapter } from './MobileEditorAdapter'
import {
createCompactNavigationState,
@@ -34,12 +35,30 @@ export function MobileApp() {
const { width } = useWindowDimensions()
const isTablet = width >= 820
const showsProperties = width >= 1120
const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(notes[0].id))
const [availableNotes, setAvailableNotes] = useState(fallbackNotes)
const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id))
const selectedNote = useMemo(
() => notes.find((note) => note.id === compactNavigation.selectedNoteId) ?? notes[0],
[compactNavigation.selectedNoteId],
() => availableNotes.find((note) => note.id === compactNavigation.selectedNoteId) ?? availableNotes[0],
[availableNotes, compactNavigation.selectedNoteId],
)
useEffect(() => {
let isActive = true
loadDemoVaultNotes()
.then((loadedNotes) => {
if (isActive && loadedNotes.length > 0) {
setAvailableNotes(loadedNotes)
setCompactNavigation((state) => selectLoadedNote(state, loadedNotes))
}
})
.catch(() => {})
return () => {
isActive = false
}
}, [])
const selectNote = (note: MobileNote) => {
setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id }))
}
@@ -50,7 +69,11 @@ export function MobileApp() {
{isTablet ? (
<View style={styles.tabletShell}>
<SidebarPanel />
<NoteListPanel selectedNoteId={compactNavigation.selectedNoteId} onSelectNote={selectNote} />
<NoteListPanel
notes={availableNotes}
selectedNoteId={compactNavigation.selectedNoteId}
onSelectNote={selectNote}
/>
<EditorPanel note={selectedNote} />
{showsProperties ? <PropertiesPanel note={selectedNote} /> : null}
</View>
@@ -58,6 +81,7 @@ export function MobileApp() {
<CompactShell
activePanel={compactNavigation.panel}
note={selectedNote}
notes={availableNotes}
selectedNoteId={compactNavigation.selectedNoteId}
onNavigate={(event) => setCompactNavigation((state) => transitionCompactNavigation(state, event))}
onSelectNote={selectNote}
@@ -71,12 +95,14 @@ export function MobileApp() {
function CompactShell({
activePanel,
note,
notes,
onNavigate,
onSelectNote,
selectedNoteId,
}: {
activePanel: CompactPanel
note: MobileNote
notes: MobileNote[]
onNavigate: (event: CompactNavigationEvent) => void
onSelectNote: (note: MobileNote) => void
selectedNoteId: string
@@ -112,6 +138,7 @@ function CompactShell({
return (
<SwipeSurface panel="list" onNavigate={onNavigate}>
<NoteListPanel
notes={notes}
selectedNoteId={selectedNoteId}
onOpenSidebar={() => onNavigate({ type: 'openSidebar' })}
onSelectNote={onSelectNote}
@@ -154,10 +181,12 @@ function SidebarPanel({ onClose }: { onClose?: () => void }) {
}
function NoteListPanel({
notes,
onOpenSidebar,
onSelectNote,
selectedNoteId,
}: {
notes: MobileNote[]
onOpenSidebar?: () => void
onSelectNote: (note: MobileNote) => void
selectedNoteId: string
@@ -205,6 +234,12 @@ function NoteListPanel({
)
}
function selectLoadedNote(state: ReturnType<typeof createCompactNavigationState>, loadedNotes: MobileNote[]) {
return loadedNotes.some((note) => note.id === state.selectedNoteId)
? state
: { ...state, selectedNoteId: loadedNotes[0].id }
}
function EditorPanel({
note,
onBack,

View File

@@ -3,7 +3,7 @@ import { createFixtureMobileVaultRepository } from './mobileVaultRepository'
export type { MobileNote } from './mobileNoteProjection'
const noteContent: MobileNoteSource[] = [
export const demoNoteSources: MobileNoteSource[] = [
{
id: 'workflow',
type: 'Essay',
@@ -66,9 +66,9 @@ const noteContent: MobileNoteSource[] = [
},
]
export const notes: MobileNote[] = projectMobileNotes(noteContent)
export const notes: MobileNote[] = projectMobileNotes(demoNoteSources)
export const demoVaultRepository = createFixtureMobileVaultRepository(noteContent)
export const demoVaultRepository = createFixtureMobileVaultRepository(demoNoteSources)
export const sidebarSections = [
{

View File

@@ -0,0 +1,31 @@
import { demoNoteSources } from './demoData'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
import type { MobileVaultFile } from './mobileVaultStorage'
const demoVault = createDemoVaultConfig()
export async function loadDemoVaultNotes() {
const storage = createNativeMobileVaultStorage()
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
}
function demoVaultFiles(): MobileVaultFile[] {
return demoNoteSources.map((source) => ({
path: source.filename,
content: source.content,
}))
}
function createDemoVaultConfig() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -35,6 +35,11 @@ type VaultFileInput = {
path: string
}
type DirectoryInput = {
fileSystem: ExpoMobileVaultFileSystem
uri: string
}
export function createExpoMobileVaultStorage(
fileSystem: ExpoMobileVaultFileSystem,
): MobileVaultStorageDriver {
@@ -120,7 +125,7 @@ async function readVaultFile(input: VaultFileInput): Promise<MobileVaultFile> {
async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
const rootUri = vaultRootUri(fileSystem, vault)
await fileSystem.makeDirectoryAsync(rootUri, { intermediates: true })
await ensureDirectory({ fileSystem, uri: rootUri })
return rootUri
}
@@ -128,10 +133,21 @@ async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: Mob
async function ensureParentDirectory(input: VaultFileInput) {
const parentPath = input.path.split('/').slice(0, -1).join('/')
if (parentPath) {
await input.fileSystem.makeDirectoryAsync(
appendUri({ root: input.rootUri, segments: [parentPath] }),
{ intermediates: true },
)
await ensureDirectory({
fileSystem: input.fileSystem,
uri: appendUri({ root: input.rootUri, segments: [parentPath] }),
})
}
}
async function ensureDirectory(input: DirectoryInput) {
const info = await input.fileSystem.getInfoAsync(input.uri)
if (info.exists && !info.isDirectory) {
throw new Error(`Mobile vault path is not a directory: ${input.uri}`)
}
if (!info.exists) {
await input.fileSystem.makeDirectoryAsync(input.uri, { intermediates: true })
}
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
const vault = createVault()
describe('mobile vault seed', () => {
it('writes starter notes when the mobile vault is empty', async () => {
const storage = createMemoryMobileVaultStorage([])
await expect(
seedMobileVaultIfEmpty({
files: [{ path: 'welcome.md', content: '# Welcome' }],
storage,
vault,
}),
).resolves.toBe('seeded')
await expect(storage.readMarkdownFile(vault, 'welcome.md')).resolves.toBe('# Welcome')
})
it('keeps existing mobile vault files intact', async () => {
const storage = createMemoryMobileVaultStorage([{ path: 'existing.md', content: '# Existing' }])
await expect(
seedMobileVaultIfEmpty({
files: [{ path: 'existing.md', content: '# Replacement' }],
storage,
vault,
}),
).resolves.toBe('alreadySeeded')
await expect(storage.readMarkdownFile(vault, 'existing.md')).resolves.toBe('# Existing')
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -0,0 +1,23 @@
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage'
export type MobileVaultSeedResult = 'alreadySeeded' | 'seeded'
export async function seedMobileVaultIfEmpty({
files,
storage,
vault,
}: {
files: MobileVaultFile[]
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}): Promise<MobileVaultSeedResult> {
const existingFiles = await storage.listMarkdownFiles(vault)
if (existingFiles.length > 0) {
return 'alreadySeeded'
}
await Promise.all(files.map((file) => storage.writeMarkdownFile(vault, file.path, file.content)))
return 'seeded'
}

View File

@@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Branch: `codex/mobile`
- Active phase: Phase 2 - Mobile Shell
- Active slice: Implement Expo FileSystem vault storage
- Active slice: Seed app-local mobile demo vault
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -53,6 +53,8 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Added a mobile vault storage driver contract plus memory implementation, and connected the mobile vault repository to app-local markdown files behind that storage interface.
- Added Expo FileSystem as the first app-local mobile vault storage implementation behind the storage driver contract.
- Created [ADR-0111](./adr/0111-expo-file-system-vault-storage.md) to record the mobile filesystem dependency and app-local vault storage path.
- Added a mobile vault seeding boundary that writes starter Markdown files only when the app-local vault is empty.
- Wired the mobile shell to load its starter notes through the native Expo FileSystem storage driver and stored repository path, with fixture notes retained as the fallback while storage initializes.
## Next Action
@@ -60,7 +62,7 @@ Continue Phase 2 with the next mobile shell slice:
1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay.
2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists.
3. Wire the app shell to the stored repository once an initial demo vault seed path exists.
3. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists.
## Verification Log
@@ -161,6 +163,12 @@ Continue Phase 2 with the next mobile shell slice:
- `pnpm --filter @tolaria/mobile typecheck` passed after Expo storage adapter extraction.
- CodeScene after Expo storage adapter extraction: `apps/mobile/src/mobileExpoVaultStorage.ts`, `apps/mobile/src/mobileExpoVaultStorage.test.ts`, and `apps/mobile/src/mobileNativeVaultStorage.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after Expo storage adapter extraction.
- `pnpm --filter @tolaria/mobile test -- src/mobileVaultSeed.test.ts` passed after app-local demo vault seeding: 12 files / 40 tests.
- `pnpm --filter @tolaria/mobile test` passed after app-local demo vault seeding: 12 files / 40 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after app-local demo vault seeding.
- CodeScene after app-local demo vault seeding: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileExpoVaultStorage.ts`, `apps/mobile/src/mobileVaultSeed.ts`, and `apps/mobile/src/mobileVaultSeed.test.ts` scored `10`; `apps/mobile/src/demoData.ts` returned no scorable code and no findings.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after app-local demo vault seeding.
- `pnpm --filter @tolaria/mobile exec expo start --ios --clear --port 8087` launched on `iPad Pro 13-inch (M4)` after app-local demo vault seeding; screenshot captured at `/tmp/tolaria-mobile-seeded-ipad.png`. The app rendered via the stored repository path behind Expo Go's first-run Tools modal with no red runtime error overlay.
## Risks / Watch Items