@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 { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
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
- useEffect(() => {
242
- let active = true;
243
- const loadTree = async () => {
244
- setLoading(true);
245
- setError("");
246
- try {
247
- const data = await fetchBrowseTree();
248
- if (!active) return;
249
- setTreeRoot(data.root || null);
250
- setCollapsedPaths((previousPaths) => {
251
- if (previousPaths instanceof Set) return previousPaths;
252
- const nextPaths = new Set();
253
- collectFolderPaths(data.root, nextPaths);
254
- return nextPaths;
255
- });
256
- } catch (loadError) {
257
- if (!active) return;
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
- loadTree();
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
- active = false;
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.3.4-beta.0",
3
+ "version": "0.3.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },