@chrysb/alphaclaw 0.3.4-beta.0 → 0.3.4
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.
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { h } from "https://esm.sh/preact";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "https://esm.sh/preact/hooks";
|
|
3
9
|
import htm from "https://esm.sh/htm";
|
|
4
10
|
import { fetchBrowseTree } from "../lib/api.js";
|
|
5
11
|
import {
|
|
@@ -23,6 +29,7 @@ const html = htm.bind(h);
|
|
|
23
29
|
const kTreeIndentPx = 9;
|
|
24
30
|
const kFolderBasePaddingPx = 10;
|
|
25
31
|
const kFileBasePaddingPx = 14;
|
|
32
|
+
const kTreeRefreshIntervalMs = 5000;
|
|
26
33
|
const kCollapsedFoldersStorageKey = "alphaclaw.browse.collapsedFolders";
|
|
27
34
|
const kLegacyCollapsedFoldersStorageKey = "alphaclawBrowseCollapsedFolders";
|
|
28
35
|
|
|
@@ -237,34 +244,55 @@ export const FileTree = ({
|
|
|
237
244
|
const [searchQuery, setSearchQuery] = useState("");
|
|
238
245
|
const [searchActivePath, setSearchActivePath] = useState("");
|
|
239
246
|
const searchInputRef = useRef(null);
|
|
247
|
+
const treeSignatureRef = useRef("");
|
|
240
248
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
249
|
+
const loadTree = useCallback(async ({ showLoading = false } = {}) => {
|
|
250
|
+
if (showLoading) setLoading(true);
|
|
251
|
+
if (showLoading) setError("");
|
|
252
|
+
try {
|
|
253
|
+
const data = await fetchBrowseTree();
|
|
254
|
+
const nextRoot = data.root || null;
|
|
255
|
+
const nextSignature = JSON.stringify(nextRoot || {});
|
|
256
|
+
if (treeSignatureRef.current !== nextSignature) {
|
|
257
|
+
treeSignatureRef.current = nextSignature;
|
|
258
|
+
setTreeRoot(nextRoot);
|
|
259
|
+
}
|
|
260
|
+
setCollapsedPaths((previousPaths) => {
|
|
261
|
+
if (previousPaths instanceof Set) return previousPaths;
|
|
262
|
+
const nextPaths = new Set();
|
|
263
|
+
collectFolderPaths(nextRoot, nextPaths);
|
|
264
|
+
return nextPaths;
|
|
265
|
+
});
|
|
266
|
+
if (showLoading) setError("");
|
|
267
|
+
} catch (loadError) {
|
|
268
|
+
if (showLoading) {
|
|
258
269
|
setError(loadError.message || "Could not load file tree");
|
|
259
|
-
} finally {
|
|
260
|
-
if (active) setLoading(false);
|
|
261
270
|
}
|
|
271
|
+
} finally {
|
|
272
|
+
if (showLoading) setLoading(false);
|
|
273
|
+
}
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
loadTree({ showLoading: true });
|
|
278
|
+
}, [loadTree]);
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const refreshTree = () => {
|
|
282
|
+
loadTree({ showLoading: false });
|
|
262
283
|
};
|
|
263
|
-
|
|
284
|
+
const refreshInterval = window.setInterval(
|
|
285
|
+
refreshTree,
|
|
286
|
+
kTreeRefreshIntervalMs,
|
|
287
|
+
);
|
|
288
|
+
window.addEventListener("alphaclaw:browse-file-saved", refreshTree);
|
|
289
|
+
window.addEventListener("alphaclaw:browse-tree-refresh", refreshTree);
|
|
264
290
|
return () => {
|
|
265
|
-
|
|
291
|
+
window.clearInterval(refreshInterval);
|
|
292
|
+
window.removeEventListener("alphaclaw:browse-file-saved", refreshTree);
|
|
293
|
+
window.removeEventListener("alphaclaw:browse-tree-refresh", refreshTree);
|
|
266
294
|
};
|
|
267
|
-
}, []);
|
|
295
|
+
}, [loadTree]);
|
|
268
296
|
|
|
269
297
|
const normalizedSearchQuery = String(searchQuery || "").trim().toLowerCase();
|
|
270
298
|
const rootChildren = useMemo(() => {
|
|
@@ -30,8 +30,10 @@ import { showToast } from "./toast.js";
|
|
|
30
30
|
const html = htm.bind(h);
|
|
31
31
|
const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode";
|
|
32
32
|
const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode";
|
|
33
|
+
const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath";
|
|
33
34
|
const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]);
|
|
34
35
|
const kLoadingIndicatorDelayMs = 1000;
|
|
36
|
+
const kFileRefreshIntervalMs = 5000;
|
|
35
37
|
|
|
36
38
|
const parsePathSegments = (inputPath) =>
|
|
37
39
|
String(inputPath || "")
|
|
@@ -60,6 +62,52 @@ const readStoredFileViewerMode = () => {
|
|
|
60
62
|
}
|
|
61
63
|
};
|
|
62
64
|
|
|
65
|
+
const clampSelectionIndex = (value, maxValue) => {
|
|
66
|
+
const numericValue = Number.parseInt(String(value ?? ""), 10);
|
|
67
|
+
if (!Number.isFinite(numericValue)) return 0;
|
|
68
|
+
return Math.max(0, Math.min(maxValue, numericValue));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const readStoredEditorSelection = (filePath) => {
|
|
72
|
+
const safePath = String(filePath || "").trim();
|
|
73
|
+
if (!safePath) return null;
|
|
74
|
+
try {
|
|
75
|
+
const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey);
|
|
76
|
+
if (!rawStorageValue) return null;
|
|
77
|
+
const parsedStorageValue = JSON.parse(rawStorageValue);
|
|
78
|
+
if (!parsedStorageValue || typeof parsedStorageValue !== "object") return null;
|
|
79
|
+
const selection = parsedStorageValue[safePath];
|
|
80
|
+
if (!selection || typeof selection !== "object") return null;
|
|
81
|
+
return {
|
|
82
|
+
start: selection.start,
|
|
83
|
+
end: selection.end,
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const writeStoredEditorSelection = (filePath, selection) => {
|
|
91
|
+
const safePath = String(filePath || "").trim();
|
|
92
|
+
if (!safePath || !selection || typeof selection !== "object") return;
|
|
93
|
+
try {
|
|
94
|
+
const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey);
|
|
95
|
+
const parsedStorageValue = rawStorageValue ? JSON.parse(rawStorageValue) : {};
|
|
96
|
+
const nextStorageValue =
|
|
97
|
+
parsedStorageValue && typeof parsedStorageValue === "object"
|
|
98
|
+
? parsedStorageValue
|
|
99
|
+
: {};
|
|
100
|
+
nextStorageValue[safePath] = {
|
|
101
|
+
start: selection.start,
|
|
102
|
+
end: selection.end,
|
|
103
|
+
};
|
|
104
|
+
window.localStorage.setItem(
|
|
105
|
+
kEditorSelectionStorageKey,
|
|
106
|
+
JSON.stringify(nextStorageValue),
|
|
107
|
+
);
|
|
108
|
+
} catch {}
|
|
109
|
+
};
|
|
110
|
+
|
|
63
111
|
|
|
64
112
|
export const FileViewer = ({
|
|
65
113
|
filePath = "",
|
|
@@ -77,6 +125,7 @@ export const FileViewer = ({
|
|
|
77
125
|
const [error, setError] = useState("");
|
|
78
126
|
const [isFolderPath, setIsFolderPath] = useState(false);
|
|
79
127
|
const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false);
|
|
128
|
+
const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false);
|
|
80
129
|
const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(
|
|
81
130
|
() => new Set(),
|
|
82
131
|
);
|
|
@@ -87,6 +136,8 @@ export const FileViewer = ({
|
|
|
87
136
|
const viewScrollRatioRef = useRef(0);
|
|
88
137
|
const isSyncingScrollRef = useRef(false);
|
|
89
138
|
const loadedFilePathRef = useRef("");
|
|
139
|
+
const restoredSelectionPathRef = useRef("");
|
|
140
|
+
const fileRefreshInFlightRef = useRef(false);
|
|
90
141
|
const editorLineNumberRowRefs = useRef([]);
|
|
91
142
|
const editorHighlightLineRefs = useRef([]);
|
|
92
143
|
|
|
@@ -186,6 +237,7 @@ export const FileViewer = ({
|
|
|
186
237
|
useEffect(() => {
|
|
187
238
|
let active = true;
|
|
188
239
|
loadedFilePathRef.current = "";
|
|
240
|
+
restoredSelectionPathRef.current = "";
|
|
189
241
|
if (!hasSelectedPath) {
|
|
190
242
|
setContent("");
|
|
191
243
|
setInitialContent("");
|
|
@@ -214,8 +266,10 @@ export const FileViewer = ({
|
|
|
214
266
|
{ dispatchEvent: (event) => window.dispatchEvent(event) },
|
|
215
267
|
);
|
|
216
268
|
setInitialContent(nextContent);
|
|
269
|
+
setExternalChangeNoticeShown(false);
|
|
217
270
|
viewScrollRatioRef.current = 0;
|
|
218
271
|
loadedFilePathRef.current = normalizedPath;
|
|
272
|
+
restoredSelectionPathRef.current = "";
|
|
219
273
|
} catch (loadError) {
|
|
220
274
|
if (!active) return;
|
|
221
275
|
const message = loadError.message || "Could not load file";
|
|
@@ -225,6 +279,7 @@ export const FileViewer = ({
|
|
|
225
279
|
setIsFolderPath(true);
|
|
226
280
|
setError("");
|
|
227
281
|
loadedFilePathRef.current = normalizedPath;
|
|
282
|
+
restoredSelectionPathRef.current = "";
|
|
228
283
|
return;
|
|
229
284
|
}
|
|
230
285
|
setError(message);
|
|
@@ -238,6 +293,60 @@ export const FileViewer = ({
|
|
|
238
293
|
};
|
|
239
294
|
}, [hasSelectedPath, normalizedPath]);
|
|
240
295
|
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {};
|
|
298
|
+
const refreshFromDisk = async () => {
|
|
299
|
+
if (loading || saving) return;
|
|
300
|
+
if (fileRefreshInFlightRef.current) return;
|
|
301
|
+
fileRefreshInFlightRef.current = true;
|
|
302
|
+
try {
|
|
303
|
+
const data = await fetchFileContent(normalizedPath);
|
|
304
|
+
const diskContent = data.content || "";
|
|
305
|
+
if (diskContent === initialContent) {
|
|
306
|
+
setExternalChangeNoticeShown(false);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Auto-refresh only when editor has no unsaved work.
|
|
310
|
+
if (!isDirty) {
|
|
311
|
+
setContent(diskContent);
|
|
312
|
+
setInitialContent(diskContent);
|
|
313
|
+
clearStoredFileDraft(normalizedPath);
|
|
314
|
+
updateDraftIndex(normalizedPath, false, {
|
|
315
|
+
dispatchEvent: (event) => window.dispatchEvent(event),
|
|
316
|
+
});
|
|
317
|
+
setExternalChangeNoticeShown(false);
|
|
318
|
+
window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!externalChangeNoticeShown) {
|
|
322
|
+
showToast(
|
|
323
|
+
"This file changed on disk. Save to overwrite or reload by re-opening.",
|
|
324
|
+
"error",
|
|
325
|
+
);
|
|
326
|
+
setExternalChangeNoticeShown(true);
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore transient refresh errors to avoid interrupting editing.
|
|
330
|
+
} finally {
|
|
331
|
+
fileRefreshInFlightRef.current = false;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs);
|
|
335
|
+
return () => {
|
|
336
|
+
window.clearInterval(intervalId);
|
|
337
|
+
};
|
|
338
|
+
}, [
|
|
339
|
+
hasSelectedPath,
|
|
340
|
+
isFolderPath,
|
|
341
|
+
canEditFile,
|
|
342
|
+
loading,
|
|
343
|
+
saving,
|
|
344
|
+
normalizedPath,
|
|
345
|
+
initialContent,
|
|
346
|
+
isDirty,
|
|
347
|
+
externalChangeNoticeShown,
|
|
348
|
+
]);
|
|
349
|
+
|
|
241
350
|
useEffect(() => {
|
|
242
351
|
if (loadedFilePathRef.current !== normalizedPath) return;
|
|
243
352
|
if (!canEditFile || !hasSelectedPath || loading) return;
|
|
@@ -261,6 +370,21 @@ export const FileViewer = ({
|
|
|
261
370
|
normalizedPath,
|
|
262
371
|
]);
|
|
263
372
|
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!canEditFile || loading || !hasSelectedPath) return;
|
|
375
|
+
if (loadedFilePathRef.current !== normalizedPath) return;
|
|
376
|
+
if (restoredSelectionPathRef.current === normalizedPath) return;
|
|
377
|
+
const textareaElement = editorTextareaRef.current;
|
|
378
|
+
if (!textareaElement) return;
|
|
379
|
+
const storedSelection = readStoredEditorSelection(normalizedPath);
|
|
380
|
+
restoredSelectionPathRef.current = normalizedPath;
|
|
381
|
+
if (!storedSelection) return;
|
|
382
|
+
const maxIndex = String(content || "").length;
|
|
383
|
+
const start = clampSelectionIndex(storedSelection.start, maxIndex);
|
|
384
|
+
const end = clampSelectionIndex(storedSelection.end, maxIndex);
|
|
385
|
+
textareaElement.setSelectionRange(start, Math.max(start, end));
|
|
386
|
+
}, [canEditFile, loading, hasSelectedPath, normalizedPath, content]);
|
|
387
|
+
|
|
264
388
|
const handleSave = async () => {
|
|
265
389
|
if (!canEditFile || saving || !isDirty || isProtectedLocked) return;
|
|
266
390
|
setSaving(true);
|
|
@@ -268,6 +392,7 @@ export const FileViewer = ({
|
|
|
268
392
|
try {
|
|
269
393
|
const data = await saveFileContent(normalizedPath, content);
|
|
270
394
|
setInitialContent(content);
|
|
395
|
+
setExternalChangeNoticeShown(false);
|
|
271
396
|
clearStoredFileDraft(normalizedPath);
|
|
272
397
|
updateDraftIndex(normalizedPath, false, {
|
|
273
398
|
dispatchEvent: (event) => window.dispatchEvent(event),
|
|
@@ -304,6 +429,12 @@ export const FileViewer = ({
|
|
|
304
429
|
if (isProtectedLocked || isPreviewOnly) return;
|
|
305
430
|
const nextContent = event.target.value;
|
|
306
431
|
setContent(nextContent);
|
|
432
|
+
if (hasSelectedPath && canEditFile) {
|
|
433
|
+
writeStoredEditorSelection(normalizedPath, {
|
|
434
|
+
start: event.target.selectionStart,
|
|
435
|
+
end: event.target.selectionEnd,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
307
438
|
if (hasSelectedPath && canEditFile) {
|
|
308
439
|
writeStoredFileDraft(normalizedPath, nextContent);
|
|
309
440
|
updateDraftIndex(normalizedPath, nextContent !== initialContent, {
|
|
@@ -312,6 +443,16 @@ export const FileViewer = ({
|
|
|
312
443
|
}
|
|
313
444
|
};
|
|
314
445
|
|
|
446
|
+
const handleEditorSelectionChange = () => {
|
|
447
|
+
if (!hasSelectedPath || !canEditFile || loading) return;
|
|
448
|
+
const textareaElement = editorTextareaRef.current;
|
|
449
|
+
if (!textareaElement) return;
|
|
450
|
+
writeStoredEditorSelection(normalizedPath, {
|
|
451
|
+
start: textareaElement.selectionStart,
|
|
452
|
+
end: textareaElement.selectionEnd,
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
|
|
315
456
|
const getScrollRatio = (element) => {
|
|
316
457
|
if (!element) return 0;
|
|
317
458
|
const maxScrollTop = element.scrollHeight - element.clientHeight;
|
|
@@ -608,6 +749,9 @@ ${formattedValue}</pre
|
|
|
608
749
|
value=${content}
|
|
609
750
|
onInput=${handleContentInput}
|
|
610
751
|
onScroll=${handleEditorScroll}
|
|
752
|
+
onSelect=${handleEditorSelectionChange}
|
|
753
|
+
onKeyUp=${handleEditorSelectionChange}
|
|
754
|
+
onClick=${handleEditorSelectionChange}
|
|
611
755
|
spellcheck=${false}
|
|
612
756
|
autocorrect="off"
|
|
613
757
|
autocapitalize="off"
|
|
@@ -676,6 +820,9 @@ ${formattedValue}</pre
|
|
|
676
820
|
value=${content}
|
|
677
821
|
onInput=${handleContentInput}
|
|
678
822
|
onScroll=${handleEditorScroll}
|
|
823
|
+
onSelect=${handleEditorSelectionChange}
|
|
824
|
+
onKeyUp=${handleEditorSelectionChange}
|
|
825
|
+
onClick=${handleEditorSelectionChange}
|
|
679
826
|
readonly=${isProtectedLocked || isPreviewOnly}
|
|
680
827
|
spellcheck=${false}
|
|
681
828
|
autocorrect="off"
|
|
@@ -695,6 +842,9 @@ ${formattedValue}</pre
|
|
|
695
842
|
value=${content}
|
|
696
843
|
onInput=${handleContentInput}
|
|
697
844
|
onScroll=${handleEditorScroll}
|
|
845
|
+
onSelect=${handleEditorSelectionChange}
|
|
846
|
+
onKeyUp=${handleEditorSelectionChange}
|
|
847
|
+
onClick=${handleEditorSelectionChange}
|
|
698
848
|
readonly=${isProtectedLocked || isPreviewOnly}
|
|
699
849
|
spellcheck=${false}
|
|
700
850
|
autocorrect="off"
|