add saf directory display fix

This commit is contained in:
m
2026-09-10 23:08:17 +02:00
parent 367073ed89
commit 76ed3aa66b
9 changed files with 323 additions and 51 deletions
+60 -38
View File
@@ -1,6 +1,16 @@
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import i18n from '../i18n'; import i18n from '../i18n';
@@ -46,49 +56,61 @@ export default function Login() {
}; };
return ( return (
<View style={styles.container}> <KeyboardAvoidingView
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text> style={styles.flex}
<TextInput behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.input} >
placeholder={i18n.t('login_username')} <ScrollView
autoCapitalize="none" contentContainerStyle={styles.container}
autoCorrect={false} keyboardShouldPersistTaps="handled"
value={username} keyboardDismissMode="on-drag"
onChangeText={setUsername}
/>
<TextInput
style={styles.input}
placeholder={i18n.t('login_password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
> >
<Text style={styles.buttonText}> <Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')} <TextInput
</Text> style={styles.input}
</Pressable> placeholder={i18n.t('login_username')}
<Pressable autoCapitalize="none"
style={styles.skipButton} autoCorrect={false}
onPress={() => { value={username}
continueWithoutAccount(); onChangeText={setUsername}
router.replace('/'); />
}} <TextInput
> style={styles.input}
<Text style={styles.skipButtonText}>{i18n.t('login_skip')}</Text> placeholder={i18n.t('login_password')}
</Pressable> secureTextEntry
</View> value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.buttonText}>
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
</Text>
</Pressable>
<Pressable
style={styles.skipButton}
onPress={() => {
continueWithoutAccount();
router.replace('/');
}}
>
<Text style={styles.skipButtonText}>{i18n.t('login_skip')}</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: {
flex: 1, flex: 1,
},
container: {
flexGrow: 1,
backgroundColor: '#fff', backgroundColor: '#fff',
padding: 24, padding: 24,
justifyContent: 'center', justifyContent: 'center',
+3 -1
View File
@@ -60,10 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
bootstrap(); bootstrap();
useSyncDevice(); // Boucle de sync en arrière-plan (décalée, annulée au unmount).
const stopSync = useSyncDevice();
return () => { return () => {
active = false; active = false;
stopSync();
}; };
}, []); }, []);
+37 -10
View File
@@ -1,6 +1,7 @@
import { listDirectory, listFolders } from '../services/safDirectory'; import { listDirectory, listFoldersChunked, yieldToMainThread } from '../services/safDirectory';
import type { FileEntry } from '../services/safDirectory.types'; import type { FileEntry } from '../services/safDirectory.types';
import { import {
checkpointDatabase,
getFiles, getFiles,
getFolder, getFolder,
getFolders, getFolders,
@@ -47,9 +48,9 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
return withTransaction(async () => { return withTransaction(async () => {
const seen = new Set<string>(); const seen = new Set<string>();
const folders = listFolders(root.uri as string, { recursive: true, includeRoot: true }).sort( const folders = (
(a, b) => uriDepth(a.uri) - uriDepth(b.uri), await listFoldersChunked(root.uri as string, { recursive: true, includeRoot: true })
); ).sort((a, b) => uriDepth(a.uri) - uriDepth(b.uri));
const resourceIdByUri = new Map<string, string>(); const resourceIdByUri = new Map<string, string>();
const savedFolders: StoredFolder[] = []; const savedFolders: StoredFolder[] = [];
@@ -66,9 +67,14 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
savedFolders.push(saved); savedFolders.push(saved);
} }
let lastYield = Date.now();
let files = 0; let files = 0;
for (const folder of savedFolders) { for (const folder of savedFolders) {
if (!folder.uri) continue; if (!folder.uri) continue;
if (Date.now() - lastYield >= 16) {
lastYield = Date.now();
await yieldToMainThread();
}
for (const entry of listDirectory(folder.uri)) { for (const entry of listDirectory(folder.uri)) {
if (entry.isDirectory) continue; if (entry.isDirectory) continue;
seen.add(entry.uri); seen.add(entry.uri);
@@ -109,15 +115,18 @@ export async function syncDevice(): Promise<SyncResult[]> {
for (const root of roots) { for (const root of roots) {
results.push(await syncRoot(root.resource_id)); results.push(await syncRoot(root.resource_id));
} }
await checkpointDatabase();
return results; return results;
} }
export async function useSyncDevice(intervalMs = 30_000): Promise<void> { export function useSyncDevice(intervalMs = 30_000): () => void {
const preferences = await getUserPreferences(); let stopped = false;
if (preferences.syncMode === 'none') return;
while (true) { const tick = async () => {
try { try {
const preferences = await getUserPreferences();
if (preferences.syncMode === 'none') return;
const results = await syncDevice(); const results = await syncDevice();
console.info('syncDevice', JSON.stringify(results)); console.info('syncDevice', JSON.stringify(results));
// Puis pousser l'outbox (si un token est disponible) et rafraîchir le // Puis pousser l'outbox (si un token est disponible) et rafraîchir le
@@ -133,6 +142,24 @@ export async function useSyncDevice(intervalMs = 30_000): Promise<void> {
} catch (error) { } catch (error) {
console.warn('syncDevice failed, retrying later', error); console.warn('syncDevice failed, retrying later', error);
} }
await new Promise((resolve) => setTimeout(resolve, intervalMs)); };
}
const loop = async () => {
while (!stopped) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
if (stopped) return;
await tick();
}
};
// Premier cycle différé : laisser le boot et l'écran actif répondre avant
// de lancer un walk SAF (potentiellement long) en arrière-plan.
const first = setTimeout(() => {
void loop();
}, 2000);
return () => {
stopped = true;
clearTimeout(first);
};
} }
+2 -1
View File
@@ -38,8 +38,9 @@
"test:migrations": "tsx --test tests/migrations.test.ts", "test:migrations": "tsx --test tests/migrations.test.ts",
"test:api": "tsx --test tests/apiClient.test.ts", "test:api": "tsx --test tests/apiClient.test.ts",
"test:sync": "tsx --test tests/syncOutbox.test.ts", "test:sync": "tsx --test tests/syncOutbox.test.ts",
"test:saf": "tsx --test tests/safWalk.test.ts",
"test:e2e": "tsx --test tests/e2e.live.test.ts", "test:e2e": "tsx --test tests/e2e.live.test.ts",
"test": "npm run test:db && npm run test:api && npm run test:sync" "test": "npm run test:db && npm run test:api && npm run test:sync && npm run test:saf"
}, },
"private": true "private": true
} }
+9
View File
@@ -98,6 +98,15 @@ export async function closeDatabase(): Promise<void> {
opening = null; opening = null;
} }
export async function checkpointDatabase(): Promise<void> {
try {
const db = await getDatabase();
await db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)');
} catch {
// Best-effort : ne jamais faire échouer la sync sur un checkpoint.
}
}
export async function withTransaction<T>( export async function withTransaction<T>(
work: (db: SQLiteDatabase) => Promise<T>, work: (db: SQLiteDatabase) => Promise<T>,
): Promise<T> { ): Promise<T> {
+6 -1
View File
@@ -1,4 +1,9 @@
export { getDatabase, closeDatabase, withTransaction } from './client'; export {
getDatabase,
closeDatabase,
withTransaction,
checkpointDatabase,
} from './client';
export { migrateDatabase, type Migration, type MigrationDb } from './migrations'; export { migrateDatabase, type Migration, type MigrationDb } from './migrations';
export * from './repositories'; export * from './repositories';
export type { AccessCheck, AccessSource } from './repositories/permissions'; export type { AccessCheck, AccessSource } from './repositories/permissions';
+8
View File
@@ -6,6 +6,14 @@ import type {
FolderInfo, FolderInfo,
PickDirectoryOptions, PickDirectoryOptions,
} from './safDirectory.types'; } from './safDirectory.types';
import { __setSafWalkImpl, yieldToMainThread } from './safWalk';
export { listFoldersChunked, yieldToMainThread, DEFAULT_WALK_BUDGET_MS } from './safWalk';
__setSafWalkImpl({
list: listDirectory,
info: getFolderInfo,
yield: yieldToMainThread,
});
export async function pickDirectory(initialUri?: string): Promise<Folder | null> { export async function pickDirectory(initialUri?: string): Promise<Folder | null> {
try { try {
+69
View File
@@ -0,0 +1,69 @@
import type {
DirectoryEntry,
Folder,
FolderInfo,
PickDirectoryOptions,
} from './safDirectory.types';
export async function yieldToMainThread(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
export const DEFAULT_WALK_BUDGET_MS = 16;
export type SafWalkImpl = {
list: (uri: string) => DirectoryEntry[];
info: (uri: string) => FolderInfo;
yield: () => Promise<void>;
};
let walkImpl: SafWalkImpl | null = null;
export function __setSafWalkImpl(impl: SafWalkImpl): void {
walkImpl = impl;
}
export function __setSafWalkForTests(impl: Partial<SafWalkImpl>): () => void {
const previous = walkImpl;
walkImpl = { ...previous, ...impl } as SafWalkImpl;
return () => {
walkImpl = previous;
};
}
export async function listFoldersChunked(
directoryUri: string,
options: PickDirectoryOptions = {},
budgetMs = DEFAULT_WALK_BUDGET_MS,
): Promise<Folder[]> {
const impl = walkImpl;
if (!impl) throw new Error('SafWalkImpl not configured');
const { recursive = false, includeRoot = false } = options;
const result: Folder[] = [];
if (includeRoot) {
result.push({ ...impl.info(directoryUri), isDirectory: true });
}
if (!recursive) {
for (const entry of impl.list(directoryUri)) {
if (entry.isDirectory) result.push(entry);
}
return result;
}
let lastYield = Date.now();
const visit = async (uri: string) => {
if (Date.now() - lastYield >= budgetMs) {
lastYield = Date.now();
await impl.yield();
}
for (const entry of impl.list(uri)) {
if (entry.isDirectory) {
result.push(entry);
await visit(entry.uri);
}
}
};
await visit(directoryUri);
return result;
}
+129
View File
@@ -0,0 +1,129 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { __setSafWalkForTests, listFoldersChunked } from '../services/safWalk';
import type { DirectoryEntry, Folder } from '../services/safDirectory.types';
function dir(uri: string): Folder {
return { uri, name: uri.split('/').pop() ?? uri, isDirectory: true, exists: true };
}
function file(uri: string): DirectoryEntry {
return {
uri,
name: uri.split('/').pop() ?? uri,
isDirectory: false,
extension: '',
exists: true,
size: 1,
type: 'text/plain',
lastModified: 0,
};
}
function info(uri: string): { uri: string; name: string; exists: boolean } {
return { uri, name: uri.split('/').pop() ?? uri, exists: true };
}
const tree: Record<string, DirectoryEntry[]> = {
'/root': [dir('/root/a'), dir('/root/b'), dir('/root/c'), file('/root/f1')],
'/root/a': [dir('/root/a/x'), file('/root/a/f2')],
'/root/a/x': [],
'/root/b': [file('/root/b/f3')],
'/root/c': [],
};
const restoreFns: Array<() => void> = [];
afterEach(() => {
while (restoreFns.length) restoreFns.pop()?.();
});
function fakeSaf() {
return __setSafWalkForTests({
list: (uri) => tree[uri] ?? [],
info,
});
}
test('listFoldersChunked : parcours récursif identique à listFolders', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root', { recursive: true, includeRoot: true });
assert.deepEqual(
folders.map((f) => f.uri),
['/root', '/root/a', '/root/a/x', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : non-récursif ne remonte que les sous-dossiers immédiats', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root');
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : includeRoot=false ne retourne pas la racine', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root', { recursive: true });
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : cède au event loop (budget 0 → yield par dossier)', async () => {
restoreFns.push(fakeSaf());
let yields = 0;
restoreFns.push(
__setSafWalkForTests({
yield: async () => {
yields++;
},
}),
);
const folders = await listFoldersChunked('/root', { recursive: true }, 0);
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
assert.ok(yields >= 5, `expected at least one yield per folder, got ${yields}`);
});
test('listFoldersChunked : budget maximum → aucun yield, résultat inchangé', async () => {
restoreFns.push(fakeSaf());
let yields = 0;
restoreFns.push(
__setSafWalkForTests({
yield: async () => {
yields++;
},
}),
);
const folders = await listFoldersChunked('/root', { recursive: true }, Number.MAX_SAFE_INTEGER);
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
assert.equal(yields, 0);
});
test('__setSafWalkForTests : la restauration rend limplémentation dorigine', async () => {
restoreFns.push(fakeSaf());
const restore = __setSafWalkForTests({ list: () => [dir('/fake')] });
restore();
let listed = false;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
listed = true;
return tree[uri] ?? [];
},
}),
);
await listFoldersChunked('/root', { recursive: true });
assert.equal(listed, true);
});