add saf directory display fix
This commit is contained in:
+60
-38
@@ -1,6 +1,16 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
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 { ApiError } from '../api/client';
|
||||
import i18n from '../i18n';
|
||||
@@ -46,49 +56,61 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={i18n.t('login_username')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={username}
|
||||
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}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
<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>
|
||||
</View>
|
||||
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={i18n.t('login_username')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={username}
|
||||
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}>
|
||||
{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({
|
||||
container: {
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: '#fff',
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -60,10 +60,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
useSyncDevice();
|
||||
// Boucle de sync en arrière-plan (décalée, annulée au unmount).
|
||||
const stopSync = useSyncDevice();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
stopSync();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
checkpointDatabase,
|
||||
getFiles,
|
||||
getFolder,
|
||||
getFolders,
|
||||
@@ -47,9 +48,9 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
|
||||
return withTransaction(async () => {
|
||||
const seen = new Set<string>();
|
||||
|
||||
const folders = listFolders(root.uri as string, { recursive: true, includeRoot: true }).sort(
|
||||
(a, b) => uriDepth(a.uri) - uriDepth(b.uri),
|
||||
);
|
||||
const folders = (
|
||||
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 savedFolders: StoredFolder[] = [];
|
||||
@@ -66,9 +67,14 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
|
||||
savedFolders.push(saved);
|
||||
}
|
||||
|
||||
let lastYield = Date.now();
|
||||
let files = 0;
|
||||
for (const folder of savedFolders) {
|
||||
if (!folder.uri) continue;
|
||||
if (Date.now() - lastYield >= 16) {
|
||||
lastYield = Date.now();
|
||||
await yieldToMainThread();
|
||||
}
|
||||
for (const entry of listDirectory(folder.uri)) {
|
||||
if (entry.isDirectory) continue;
|
||||
seen.add(entry.uri);
|
||||
@@ -109,15 +115,18 @@ export async function syncDevice(): Promise<SyncResult[]> {
|
||||
for (const root of roots) {
|
||||
results.push(await syncRoot(root.resource_id));
|
||||
}
|
||||
await checkpointDatabase();
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function useSyncDevice(intervalMs = 30_000): Promise<void> {
|
||||
const preferences = await getUserPreferences();
|
||||
if (preferences.syncMode === 'none') return;
|
||||
export function useSyncDevice(intervalMs = 30_000): () => void {
|
||||
let stopped = false;
|
||||
|
||||
while (true) {
|
||||
const tick = async () => {
|
||||
try {
|
||||
const preferences = await getUserPreferences();
|
||||
if (preferences.syncMode === 'none') return;
|
||||
|
||||
const results = await syncDevice();
|
||||
console.info('syncDevice', JSON.stringify(results));
|
||||
// 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) {
|
||||
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
@@ -38,8 +38,9 @@
|
||||
"test:migrations": "tsx --test tests/migrations.test.ts",
|
||||
"test:api": "tsx --test tests/apiClient.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": "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
|
||||
}
|
||||
|
||||
@@ -98,6 +98,15 @@ export async function closeDatabase(): Promise<void> {
|
||||
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>(
|
||||
work: (db: SQLiteDatabase) => Promise<T>,
|
||||
): Promise<T> {
|
||||
|
||||
@@ -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 * from './repositories';
|
||||
export type { AccessCheck, AccessSource } from './repositories/permissions';
|
||||
|
||||
@@ -6,6 +6,14 @@ import type {
|
||||
FolderInfo,
|
||||
PickDirectoryOptions,
|
||||
} 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> {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 l’implémentation d’origine', 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);
|
||||
});
|
||||
Reference in New Issue
Block a user