add saf directory display fix
This commit is contained in:
+26
-4
@@ -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,7 +56,15 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<KeyboardAvoidingView
|
||||||
|
style={styles.flex}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
|
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@@ -82,13 +100,17 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.skipButtonText}>{i18n.t('login_skip')}</Text>
|
<Text style={styles.skipButtonText}>{i18n.t('login_skip')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</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',
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
const preferences = await getUserPreferences();
|
const preferences = await getUserPreferences();
|
||||||
if (preferences.syncMode === 'none') return;
|
if (preferences.syncMode === 'none') return;
|
||||||
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
while (!stopped) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
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: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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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