feat: seed mobile app vault
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
31
apps/mobile/src/mobileDemoVault.ts
Normal file
31
apps/mobile/src/mobileDemoVault.ts
Normal 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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
apps/mobile/src/mobileVaultSeed.test.ts
Normal file
45
apps/mobile/src/mobileVaultSeed.test.ts
Normal 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
|
||||
}
|
||||
23
apps/mobile/src/mobileVaultSeed.ts
Normal file
23
apps/mobile/src/mobileVaultSeed.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user