@relayfile/file-observer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import type {
6
+ FilesystemEvent,
7
+ RelayfileClient,
8
+ TreeEntry,
9
+ TreeResponse
10
+ } from '../lib/relayfile-client';
11
+
12
+ type FileEventsClient = Pick<RelayfileClient, 'connectWebSocket'>;
13
+
14
+ const DEFAULT_MAX_EVENTS = 300;
15
+ const FILE_EVENT_TYPES = new Set<FilesystemEvent['type']>([
16
+ 'file.created',
17
+ 'file.updated',
18
+ 'file.deleted'
19
+ ]);
20
+
21
+ export type UseFileEventsStatus = 'idle' | 'connecting' | 'connected' | 'disconnected';
22
+
23
+ export interface UseFileEventsOptions {
24
+ client: FileEventsClient | null;
25
+ workspaceId?: string | null;
26
+ initialTree?: TreeResponse | null;
27
+ enabled?: boolean;
28
+ maxEvents?: number;
29
+ onEvent?: (event: FilesystemEvent) => void;
30
+ }
31
+
32
+ export interface UseFileEventsResult {
33
+ tree: TreeResponse | null;
34
+ events: FilesystemEvent[];
35
+ status: UseFileEventsStatus;
36
+ error: string | null;
37
+ clearEvents: () => void;
38
+ }
39
+
40
+ function normalizeError(error: unknown): string {
41
+ if (error instanceof Error && error.message) {
42
+ return error.message;
43
+ }
44
+
45
+ return 'Unexpected relayfile WebSocket failure.';
46
+ }
47
+
48
+ function normalizeDirectoryPath(path: string): string {
49
+ const trimmed = path.trim().replace(/\/+$/, '');
50
+ return trimmed === '' ? '/' : trimmed;
51
+ }
52
+
53
+ function normalizeFilePath(path: string): string {
54
+ const trimmed = path.trim().replace(/\/+$/, '');
55
+ return trimmed === '' ? '/' : trimmed;
56
+ }
57
+
58
+ function isDirectChildPath(parentPath: string, candidatePath: string): boolean {
59
+ const normalizedParent = normalizeDirectoryPath(parentPath);
60
+ const normalizedCandidate = normalizeFilePath(candidatePath);
61
+
62
+ if (normalizedCandidate === normalizedParent) {
63
+ return false;
64
+ }
65
+
66
+ if (normalizedParent === '/') {
67
+ const remainder = normalizedCandidate.slice(1);
68
+ return remainder !== '' && !remainder.includes('/');
69
+ }
70
+
71
+ const prefix = `${normalizedParent}/`;
72
+ if (!normalizedCandidate.startsWith(prefix)) {
73
+ return false;
74
+ }
75
+
76
+ const remainder = normalizedCandidate.slice(prefix.length);
77
+ return remainder !== '' && !remainder.includes('/');
78
+ }
79
+
80
+ function createTreeEntryFromEvent(event: FilesystemEvent): TreeEntry {
81
+ return {
82
+ path: normalizeFilePath(event.path),
83
+ type: 'file',
84
+ revision: event.revision,
85
+ provider: event.provider,
86
+ updatedAt: event.timestamp
87
+ };
88
+ }
89
+
90
+ function applyFileEventToTree(current: TreeResponse | null, event: FilesystemEvent): TreeResponse | null {
91
+ if (!current || !FILE_EVENT_TYPES.has(event.type) || !isDirectChildPath(current.path, event.path)) {
92
+ return current;
93
+ }
94
+
95
+ const normalizedPath = normalizeFilePath(event.path);
96
+
97
+ if (event.type === 'file.deleted') {
98
+ const nextEntries = current.entries.filter((entry) => entry.path !== normalizedPath);
99
+ if (nextEntries.length === current.entries.length) {
100
+ return current;
101
+ }
102
+
103
+ return {
104
+ ...current,
105
+ entries: nextEntries
106
+ };
107
+ }
108
+
109
+ const nextEntry = createTreeEntryFromEvent(event);
110
+ const existingIndex = current.entries.findIndex((entry) => entry.path === normalizedPath);
111
+
112
+ if (existingIndex === -1) {
113
+ return {
114
+ ...current,
115
+ entries: [...current.entries, nextEntry]
116
+ };
117
+ }
118
+
119
+ const existingEntry = current.entries[existingIndex];
120
+ const mergedEntry: TreeEntry = {
121
+ ...existingEntry,
122
+ path: normalizedPath,
123
+ type: 'file',
124
+ revision: event.revision,
125
+ provider: event.provider ?? existingEntry.provider,
126
+ updatedAt: event.timestamp
127
+ };
128
+
129
+ const nextEntries = [...current.entries];
130
+ nextEntries[existingIndex] = mergedEntry;
131
+
132
+ return {
133
+ ...current,
134
+ entries: nextEntries
135
+ };
136
+ }
137
+
138
+ function appendEvent(
139
+ events: FilesystemEvent[],
140
+ nextEvent: FilesystemEvent,
141
+ maxEvents: number
142
+ ): FilesystemEvent[] {
143
+ const cappedEvents = [...events, nextEvent];
144
+
145
+ if (cappedEvents.length <= maxEvents) {
146
+ return cappedEvents;
147
+ }
148
+
149
+ return cappedEvents.slice(cappedEvents.length - maxEvents);
150
+ }
151
+
152
+ export function useFileEvents({
153
+ client,
154
+ workspaceId,
155
+ initialTree = null,
156
+ enabled = true,
157
+ maxEvents = DEFAULT_MAX_EVENTS,
158
+ onEvent
159
+ }: UseFileEventsOptions): UseFileEventsResult {
160
+ const trimmedWorkspaceId = workspaceId?.trim();
161
+ const resolvedMaxEvents = Number.isFinite(maxEvents) ? Math.max(1, Math.floor(maxEvents)) : DEFAULT_MAX_EVENTS;
162
+
163
+ const [tree, setTree] = useState<TreeResponse | null>(initialTree);
164
+ const [events, setEvents] = useState<FilesystemEvent[]>([]);
165
+ const [status, setStatus] = useState<UseFileEventsStatus>('idle');
166
+ const [error, setError] = useState<string | null>(null);
167
+
168
+ const onEventRef = useRef<typeof onEvent>(onEvent);
169
+
170
+ useEffect(() => {
171
+ onEventRef.current = onEvent;
172
+ }, [onEvent]);
173
+
174
+ useEffect(() => {
175
+ setTree(initialTree);
176
+ }, [initialTree, trimmedWorkspaceId]);
177
+
178
+ useEffect(() => {
179
+ setEvents([]);
180
+ setError(null);
181
+ }, [trimmedWorkspaceId]);
182
+
183
+ useEffect(() => {
184
+ if (!enabled || !client || !trimmedWorkspaceId) {
185
+ setStatus('idle');
186
+ setError(null);
187
+ return;
188
+ }
189
+
190
+ setStatus('connecting');
191
+ setError(null);
192
+
193
+ let isActive = true;
194
+ let connection: ReturnType<FileEventsClient['connectWebSocket']> | null = null;
195
+
196
+ try {
197
+ connection = client.connectWebSocket(trimmedWorkspaceId);
198
+ } catch (connectionError) {
199
+ setStatus('disconnected');
200
+ setError(normalizeError(connectionError));
201
+ return;
202
+ }
203
+
204
+ const unsubscribeOpen = connection.on('open', () => {
205
+ if (!isActive) {
206
+ return;
207
+ }
208
+
209
+ setStatus('connected');
210
+ setError(null);
211
+ });
212
+
213
+ const unsubscribeClose = connection.on('close', () => {
214
+ if (!isActive) {
215
+ return;
216
+ }
217
+
218
+ setStatus('disconnected');
219
+ });
220
+
221
+ const unsubscribeError = connection.on('error', (event) => {
222
+ if (!isActive) {
223
+ return;
224
+ }
225
+
226
+ setStatus('disconnected');
227
+ setError(normalizeError(event));
228
+ });
229
+
230
+ const unsubscribeEvent = connection.on('event', (event) => {
231
+ if (!isActive) {
232
+ return;
233
+ }
234
+
235
+ setEvents((current) => appendEvent(current, event, resolvedMaxEvents));
236
+
237
+ if (FILE_EVENT_TYPES.has(event.type)) {
238
+ setTree((current) => applyFileEventToTree(current, event));
239
+ }
240
+
241
+ onEventRef.current?.(event);
242
+ });
243
+
244
+ return () => {
245
+ isActive = false;
246
+ unsubscribeEvent();
247
+ unsubscribeError();
248
+ unsubscribeClose();
249
+ unsubscribeOpen();
250
+ connection?.close(1000, 'useFileEvents cleanup');
251
+ };
252
+ }, [client, enabled, resolvedMaxEvents, trimmedWorkspaceId]);
253
+
254
+ const clearEvents = useCallback(() => {
255
+ setEvents([]);
256
+ }, []);
257
+
258
+ return {
259
+ tree,
260
+ events,
261
+ status,
262
+ error,
263
+ clearEvents
264
+ };
265
+ }
266
+
267
+ export default useFileEvents;
@@ -0,0 +1,124 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import type { ListTreeOptions, RelayfileClient, TreeResponse } from '../lib/relayfile-client';
6
+
7
+ type FileTreeClient = Pick<RelayfileClient, 'listTree'>;
8
+
9
+ export interface UseFileTreeOptions {
10
+ client: FileTreeClient | null;
11
+ workspaceId?: string | null;
12
+ path?: string;
13
+ depth?: number;
14
+ cursor?: string;
15
+ enabled?: boolean;
16
+ keepPreviousData?: boolean;
17
+ }
18
+
19
+ export interface UseFileTreeResult {
20
+ tree: TreeResponse | null;
21
+ loading: boolean;
22
+ error: string | null;
23
+ refresh: () => Promise<TreeResponse | null>;
24
+ }
25
+
26
+ function normalizeError(error: unknown): string {
27
+ if (error instanceof Error && error.message) {
28
+ return error.message;
29
+ }
30
+
31
+ return 'Unexpected relayfile request failure.';
32
+ }
33
+
34
+ export function useFileTree({
35
+ client,
36
+ workspaceId,
37
+ path = '/',
38
+ depth = 1,
39
+ cursor,
40
+ enabled = true,
41
+ keepPreviousData = true
42
+ }: UseFileTreeOptions): UseFileTreeResult {
43
+ const [tree, setTree] = useState<TreeResponse | null>(null);
44
+ const [loading, setLoading] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ const requestIdRef = useRef(0);
48
+ const abortControllerRef = useRef<AbortController | null>(null);
49
+
50
+ const refresh = useCallback(async (): Promise<TreeResponse | null> => {
51
+ const trimmedWorkspaceId = workspaceId?.trim();
52
+
53
+ if (!enabled || !client || !trimmedWorkspaceId) {
54
+ abortControllerRef.current?.abort();
55
+ abortControllerRef.current = null;
56
+ setLoading(false);
57
+ setError(null);
58
+ if (!keepPreviousData) {
59
+ setTree(null);
60
+ }
61
+ return null;
62
+ }
63
+
64
+ abortControllerRef.current?.abort();
65
+ const controller = new AbortController();
66
+ abortControllerRef.current = controller;
67
+
68
+ const requestId = requestIdRef.current + 1;
69
+ requestIdRef.current = requestId;
70
+
71
+ setLoading(true);
72
+ setError(null);
73
+ if (!keepPreviousData) {
74
+ setTree(null);
75
+ }
76
+
77
+ try {
78
+ const options: ListTreeOptions = {
79
+ path,
80
+ depth,
81
+ cursor,
82
+ signal: controller.signal
83
+ };
84
+ const response = await client.listTree(trimmedWorkspaceId, options);
85
+
86
+ if (controller.signal.aborted || requestIdRef.current !== requestId) {
87
+ return null;
88
+ }
89
+
90
+ setTree(response);
91
+ return response;
92
+ } catch (error) {
93
+ if (controller.signal.aborted || requestIdRef.current !== requestId) {
94
+ return null;
95
+ }
96
+
97
+ setError(normalizeError(error));
98
+ return null;
99
+ } finally {
100
+ if (!controller.signal.aborted && requestIdRef.current === requestId) {
101
+ setLoading(false);
102
+ }
103
+ }
104
+ }, [client, cursor, depth, enabled, keepPreviousData, path, workspaceId]);
105
+
106
+ useEffect(() => {
107
+ void refresh();
108
+ }, [refresh]);
109
+
110
+ useEffect(() => {
111
+ return () => {
112
+ abortControllerRef.current?.abort();
113
+ };
114
+ }, []);
115
+
116
+ return {
117
+ tree,
118
+ loading,
119
+ error,
120
+ refresh
121
+ };
122
+ }
123
+
124
+ export default useFileTree;