@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.
- package/next-env.d.ts +5 -0
- package/next.config.js +10 -0
- package/package.json +62 -0
- package/postcss.config.mjs +5 -0
- package/src/app/globals.css +45 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/page.tsx +1520 -0
- package/src/components/FileDetails.tsx +442 -0
- package/src/components/FileTree.tsx +490 -0
- package/src/components/WorkspaceSelector.tsx +127 -0
- package/src/hooks/useFileEvents.ts +267 -0
- package/src/hooks/useFileTree.ts +124 -0
- package/src/lib/relayfile-client.ts +459 -0
- package/tsconfig.json +23 -0
- package/wrangler.file-observer.toml +10 -0
|
@@ -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;
|