@chrysb/alphaclaw 0.9.15 → 0.9.16

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.
@@ -76,6 +76,16 @@ const collectFolderPaths = (node, folderPaths) => {
76
76
  );
77
77
  };
78
78
 
79
+ const collectTruncatedExpandedFolderPaths = (node, expandedPaths, folderPaths) => {
80
+ if (!node || node.type !== "folder") return;
81
+ if (node.truncated && expandedPaths.has(node.path || "")) {
82
+ folderPaths.push(node.path || "");
83
+ }
84
+ (node.children || []).forEach((childNode) =>
85
+ collectTruncatedExpandedFolderPaths(childNode, expandedPaths, folderPaths),
86
+ );
87
+ };
88
+
79
89
  const collectFilePaths = (node, filePaths) => {
80
90
  if (!node) return;
81
91
  if (node.type === "file") {
@@ -104,6 +114,19 @@ const removeTreePath = (node, targetPath) => {
104
114
  };
105
115
  };
106
116
 
117
+ const replaceTreeNode = (node, nextNode) => {
118
+ if (!node || !nextNode) return node;
119
+ if (String(node.path || "") === String(nextNode.path || "")) return nextNode;
120
+ if (node.type !== "folder") return node;
121
+ const nextChildren = (node.children || []).map((childNode) =>
122
+ replaceTreeNode(childNode, nextNode),
123
+ );
124
+ return {
125
+ ...node,
126
+ children: nextChildren,
127
+ };
128
+ };
129
+
107
130
  const filterTreeNode = (node, normalizedQuery) => {
108
131
  if (!node) return null;
109
132
  const query = String(normalizedQuery || "")
@@ -410,6 +433,7 @@ const TreeNode = ({
410
433
  onCreationConfirm,
411
434
  onCreationCancel,
412
435
  dragSourcePath = "",
436
+ loadingFolderPaths = new Set(),
413
437
  }) => {
414
438
  if (!node) return null;
415
439
  if (node.type === "file") {
@@ -471,6 +495,7 @@ const TreeNode = ({
471
495
 
472
496
  const folderPath = node.path || "";
473
497
  const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath);
498
+ const isLoadingFolder = loadingFolderPaths.has(folderPath);
474
499
  const isFolderActive = selectedPath === folderPath;
475
500
  const isFolderLocked = folderPath && matchesBrowsePolicyPath(
476
501
  kLockedBrowsePaths,
@@ -484,7 +509,7 @@ const TreeNode = ({
484
509
  class=${`tree-folder ${isCollapsed ? "collapsed" : ""} ${isFolderActive ? "active" : ""} ${isDropTarget ? "is-drop-target" : ""}`.trim()}
485
510
  onclick=${() => {
486
511
  if (!folderPath) return;
487
- onSetFolderExpanded(folderPath, isCollapsed);
512
+ onSetFolderExpanded(folderPath, isCollapsed, node);
488
513
  onSelectFolder(folderPath);
489
514
  }}
490
515
  oncontextmenu=${(e) => {
@@ -540,8 +565,9 @@ const TreeNode = ({
540
565
  event.preventDefault();
541
566
  event.stopPropagation();
542
567
  if (!folderPath) return;
543
- onSetFolderExpanded(folderPath, isCollapsed);
568
+ onSetFolderExpanded(folderPath, isCollapsed, node);
544
569
  }}
570
+ disabled=${isLoadingFolder}
545
571
  >
546
572
  <span class="arrow">▼</span>
547
573
  </button>
@@ -587,6 +613,7 @@ const TreeNode = ({
587
613
  onCreationConfirm=${onCreationConfirm}
588
614
  onCreationCancel=${onCreationCancel}
589
615
  dragSourcePath=${dragSourcePath}
616
+ loadingFolderPaths=${loadingFolderPaths}
590
617
  />
591
618
  `,
592
619
  )}
@@ -623,6 +650,7 @@ const TreeNode = ({
623
650
  onCreationConfirm=${onCreationConfirm}
624
651
  onCreationCancel=${onCreationCancel}
625
652
  dragSourcePath=${dragSourcePath}
653
+ loadingFolderPaths=${loadingFolderPaths}
626
654
  />
627
655
  `,
628
656
  )}
@@ -650,6 +678,7 @@ export const FileTree = ({
650
678
  const [creatingType, setCreatingType] = useState("");
651
679
  const [contextMenu, setContextMenu] = useState(null);
652
680
  const [dragSourcePath, setDragSourcePath] = useState("");
681
+ const [loadingFolderPaths, setLoadingFolderPaths] = useState(new Set());
653
682
  const [selectedFolder, setSelectedFolder] = useState("");
654
683
  const effectiveSelectedPath = selectedFolder || selectedPath;
655
684
  const searchInputRef = useRef(null);
@@ -661,10 +690,31 @@ export const FileTree = ({
661
690
  try {
662
691
  const data = await fetchBrowseTree();
663
692
  const nextRoot = data.root || null;
664
- const nextSignature = JSON.stringify(nextRoot || {});
693
+ const nextExpandedPaths =
694
+ expandedPaths instanceof Set ? expandedPaths : new Set();
695
+ let hydratedRoot = nextRoot;
696
+ const hydratedPaths = new Set();
697
+ while (true) {
698
+ const truncatedExpandedPaths = [];
699
+ collectTruncatedExpandedFolderPaths(
700
+ hydratedRoot,
701
+ nextExpandedPaths,
702
+ truncatedExpandedPaths,
703
+ );
704
+ const nextFolderPath = truncatedExpandedPaths.find(
705
+ (folderPath) => !hydratedPaths.has(folderPath),
706
+ );
707
+ if (!nextFolderPath) break;
708
+ hydratedPaths.add(nextFolderPath);
709
+ const subtreeData = await fetchBrowseTree({ path: nextFolderPath });
710
+ if (subtreeData.root) {
711
+ hydratedRoot = replaceTreeNode(hydratedRoot, subtreeData.root);
712
+ }
713
+ }
714
+ const nextSignature = JSON.stringify(hydratedRoot || {});
665
715
  if (treeSignatureRef.current !== nextSignature) {
666
716
  treeSignatureRef.current = nextSignature;
667
- setTreeRoot(nextRoot);
717
+ setTreeRoot(hydratedRoot);
668
718
  }
669
719
  setExpandedPaths((previousPaths) =>
670
720
  previousPaths instanceof Set ? previousPaths : new Set(),
@@ -677,7 +727,7 @@ export const FileTree = ({
677
727
  } finally {
678
728
  if (showLoading) setLoading(false);
679
729
  }
680
- }, []);
730
+ }, [expandedPaths]);
681
731
 
682
732
  useEffect(() => {
683
733
  loadTree({ showLoading: true });
@@ -834,7 +884,31 @@ export const FileTree = ({
834
884
  onPreviewFile("");
835
885
  }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]);
836
886
 
837
- const setFolderExpanded = (folderPath, nextExpanded) => {
887
+ const setFolderExpanded = async (folderPath, nextExpanded, node = null) => {
888
+ if (nextExpanded === true && node?.truncated) {
889
+ setLoadingFolderPaths((previousPaths) => {
890
+ const nextPaths =
891
+ previousPaths instanceof Set ? new Set(previousPaths) : new Set();
892
+ nextPaths.add(folderPath);
893
+ return nextPaths;
894
+ });
895
+ try {
896
+ const data = await fetchBrowseTree({ path: folderPath });
897
+ if (data.root) {
898
+ setTreeRoot((previousRoot) => replaceTreeNode(previousRoot, data.root));
899
+ }
900
+ } catch (loadError) {
901
+ showToast(loadError.message || "Could not load folder", "error");
902
+ return;
903
+ } finally {
904
+ setLoadingFolderPaths((previousPaths) => {
905
+ const nextPaths =
906
+ previousPaths instanceof Set ? new Set(previousPaths) : new Set();
907
+ nextPaths.delete(folderPath);
908
+ return nextPaths;
909
+ });
910
+ }
911
+ }
838
912
  setExpandedPaths((previousPaths) => {
839
913
  const nextPaths =
840
914
  previousPaths instanceof Set ? new Set(previousPaths) : new Set();
@@ -1210,6 +1284,7 @@ export const FileTree = ({
1210
1284
  onCreationConfirm=${confirmCreate}
1211
1285
  onCreationCancel=${cancelCreate}
1212
1286
  dragSourcePath=${dragSourcePath}
1287
+ loadingFolderPaths=${loadingFolderPaths}
1213
1288
  />
1214
1289
  `,
1215
1290
  )}
@@ -1245,6 +1320,7 @@ export const FileTree = ({
1245
1320
  onCreationConfirm=${confirmCreate}
1246
1321
  onCreationCancel=${cancelCreate}
1247
1322
  dragSourcePath=${dragSourcePath}
1323
+ loadingFolderPaths=${loadingFolderPaths}
1248
1324
  />
1249
1325
  `,
1250
1326
  )}
@@ -1245,8 +1245,11 @@ export async function fetchWebhookRequest(name, id) {
1245
1245
  return parseJsonOrThrow(res, "Could not load webhook request");
1246
1246
  }
1247
1247
 
1248
- export const fetchBrowseTree = async (depth = 10) => {
1248
+ export const fetchBrowseTree = async (options = {}) => {
1249
+ const { depth = 3, path = "" } =
1250
+ typeof options === "number" ? { depth: options, path: "" } : options;
1249
1251
  const params = new URLSearchParams({ depth: String(depth) });
1252
+ if (path) params.set("path", String(path));
1250
1253
  const res = await authFetch(`/api/browse/tree?${params.toString()}`);
1251
1254
  return parseJsonOrThrow(res, "Could not load file tree");
1252
1255
  };
@@ -1,4 +1,5 @@
1
- const kDefaultTreeDepth = 10;
1
+ const kDefaultTreeDepth = 3;
2
+ const kMaxTreeDepth = 3;
2
3
  const kIgnoredDirectoryNames = new Set([
3
4
  ".git",
4
5
  ".alphaclaw",
@@ -42,6 +43,7 @@ const kSqliteTablePageSize = 50;
42
43
 
43
44
  module.exports = {
44
45
  kDefaultTreeDepth,
46
+ kMaxTreeDepth,
45
47
  kIgnoredDirectoryNames,
46
48
  kImageMimeTypeByExtension,
47
49
  kCommitHistoryLimit,
@@ -2,6 +2,7 @@ const path = require("path");
2
2
  const { kLockedBrowsePaths, kProtectedBrowsePaths } = require("../../constants");
3
3
  const {
4
4
  kDefaultTreeDepth,
5
+ kMaxTreeDepth,
5
6
  kIgnoredDirectoryNames,
6
7
  kCommitHistoryLimit,
7
8
  } = require("./constants");
@@ -34,6 +35,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
34
35
  fs.mkdirSync(kRootResolved, { recursive: true });
35
36
  }
36
37
 
38
+ const readVisibleDirectoryEntries = (absolutePath) =>
39
+ fs
40
+ .readdirSync(absolutePath, { withFileTypes: true })
41
+ .filter((entry) => {
42
+ if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {
43
+ return false;
44
+ }
45
+ return entry.isDirectory() || entry.isFile();
46
+ });
47
+
37
48
  const buildTreeNode = (absolutePath, depthRemaining) => {
38
49
  const stats = fs.statSync(absolutePath);
39
50
  const nodeName = path.basename(absolutePath);
@@ -44,17 +55,17 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
44
55
  }
45
56
 
46
57
  if (depthRemaining <= 0) {
47
- return { type: "folder", name: nodeName, path: nodePath, children: [] };
58
+ const hasMoreChildren = readVisibleDirectoryEntries(absolutePath).length > 0;
59
+ return {
60
+ type: "folder",
61
+ name: nodeName,
62
+ path: nodePath,
63
+ children: [],
64
+ truncated: hasMoreChildren,
65
+ };
48
66
  }
49
67
 
50
- const children = fs
51
- .readdirSync(absolutePath, { withFileTypes: true })
52
- .filter((entry) => {
53
- if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {
54
- return false;
55
- }
56
- return entry.isDirectory() || entry.isFile();
57
- })
68
+ const children = readVisibleDirectoryEntries(absolutePath)
58
69
  .map((entry) =>
59
70
  buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1),
60
71
  )
@@ -70,12 +81,29 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
70
81
 
71
82
  app.get("/api/browse/tree", (req, res) => {
72
83
  const depthValue = Number.parseInt(String(req.query.depth || ""), 10);
73
- const depth =
84
+ const requestedDepth =
74
85
  Number.isFinite(depthValue) && depthValue > 0
75
86
  ? depthValue
76
87
  : kDefaultTreeDepth;
88
+ const depth = Math.min(requestedDepth, kMaxTreeDepth);
89
+ const requestedPath = String(req.query.path || "").trim();
77
90
  try {
78
- const tree = buildTreeNode(kRootResolved, depth);
91
+ const resolvedPath = requestedPath
92
+ ? resolveSafePath(
93
+ requestedPath,
94
+ kRootResolved,
95
+ kRootWithSep,
96
+ kRootDisplayName,
97
+ )
98
+ : { ok: true, absolutePath: kRootResolved };
99
+ if (!resolvedPath.ok) {
100
+ return res.status(400).json({ ok: false, error: resolvedPath.error });
101
+ }
102
+ const stats = fs.statSync(resolvedPath.absolutePath);
103
+ if (!stats.isDirectory()) {
104
+ return res.status(400).json({ ok: false, error: "Path is not a folder" });
105
+ }
106
+ const tree = buildTreeNode(resolvedPath.absolutePath, depth);
79
107
  return res.json({ ok: true, root: tree });
80
108
  } catch (error) {
81
109
  return res.status(500).json({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },