mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 04:42:39 +02:00
feat(filesystem): introduce support for local openable text file extensions and enhance folder expansion persistence in the UI
This commit is contained in:
parent
7134b0feae
commit
b85b7cbae0
5 changed files with 202 additions and 28 deletions
|
|
@ -21,6 +21,51 @@ const MAX_LOCAL_ROOTS = 10;
|
||||||
const DEFAULT_SPACE_KEY = "default";
|
const DEFAULT_SPACE_KEY = "default";
|
||||||
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
|
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
|
||||||
|
|
||||||
|
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".txt",
|
||||||
|
".json",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".csv",
|
||||||
|
".tsv",
|
||||||
|
".xml",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".sass",
|
||||||
|
".sql",
|
||||||
|
".toml",
|
||||||
|
".ini",
|
||||||
|
".conf",
|
||||||
|
".log",
|
||||||
|
".py",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".java",
|
||||||
|
".kt",
|
||||||
|
".kts",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".rb",
|
||||||
|
".php",
|
||||||
|
".swift",
|
||||||
|
".r",
|
||||||
|
".lua",
|
||||||
|
".sh",
|
||||||
|
".bash",
|
||||||
|
".zsh",
|
||||||
|
".fish",
|
||||||
|
".env",
|
||||||
|
".mk",
|
||||||
|
]);
|
||||||
|
|
||||||
function getSettingsPath(): string {
|
function getSettingsPath(): string {
|
||||||
return join(app.getPath("userData"), SETTINGS_FILENAME);
|
return join(app.getPath("userData"), SETTINGS_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +274,16 @@ function toVirtualPath(rootPath: string, absolutePath: string): string {
|
||||||
return `/${rel.replace(/\\/g, "/")}`;
|
return `/${rel.replace(/\\/g, "/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertLocalOpenableTextFile(absolutePath: string): void {
|
||||||
|
const extension = extname(absolutePath).toLowerCase();
|
||||||
|
if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported local file type '${extension || "(no extension)"}'. ` +
|
||||||
|
"Only text/code files can be opened in local mode."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type LocalRootMount = {
|
export type LocalRootMount = {
|
||||||
mount: string;
|
mount: string;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
|
|
@ -441,6 +496,7 @@ export async function readAgentLocalFileText(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
|
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
|
||||||
|
assertLocalOpenableTextFile(absolutePath);
|
||||||
const content = await readFile(absolutePath, "utf8");
|
const content = await readFile(absolutePath, "utf8");
|
||||||
return {
|
return {
|
||||||
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
|
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded folder keys for Local filesystem tree, keyed by search space ID.
|
||||||
|
* Persisted so local tree expansion survives remounts/reloads.
|
||||||
|
*/
|
||||||
|
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
|
||||||
|
"surfsense:localExpandedFolderKeys",
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Folder currently being renamed (inline edit mode).
|
* Folder currently being renamed (inline edit mode).
|
||||||
* null means no folder is being renamed.
|
* null means no folder is being renamed.
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ export function SourceCodeEditor({
|
||||||
fontFamily:
|
fontFamily:
|
||||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
|
unicodeHighlight: {
|
||||||
|
ambiguousCharacters: false,
|
||||||
|
invisibleCharacters: false,
|
||||||
|
nonBasicASCII: false,
|
||||||
|
},
|
||||||
smoothScrolling: true,
|
smoothScrolling: true,
|
||||||
readOnly,
|
readOnly,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Folder, FolderPlus, Search, X } from "lucide-react";
|
import { Folder, FolderPlus, Search, X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
|
|
@ -45,6 +47,20 @@ export function DesktopLocalTabContent({
|
||||||
const [localSearch, setLocalSearch] = useState("");
|
const [localSearch, setLocalSearch] = useState("");
|
||||||
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
|
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
|
||||||
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom);
|
||||||
|
const expandedFolderKeys = useMemo(
|
||||||
|
() => new Set(expandedFolderKeyMap[searchSpaceId] ?? []),
|
||||||
|
[expandedFolderKeyMap, searchSpaceId]
|
||||||
|
);
|
||||||
|
const handleExpandedFolderKeysChange = useCallback(
|
||||||
|
(nextExpandedKeys: Set<string>) => {
|
||||||
|
setExpandedFolderKeyMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[searchSpaceId]: Array.from(nextExpandedKeys),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[searchSpaceId, setExpandedFolderKeyMap]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col select-none">
|
<div className="flex min-h-0 flex-1 flex-col select-none">
|
||||||
|
|
@ -181,6 +197,8 @@ export function DesktopLocalTabContent({
|
||||||
active
|
active
|
||||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
searchQuery={debouncedLocalSearch.trim() || undefined}
|
||||||
onOpenFile={onOpenLocalFile}
|
onOpenFile={onOpenLocalFile}
|
||||||
|
expandedFolderKeys={expandedFolderKeys}
|
||||||
|
onExpandedFolderKeysChange={handleExpandedFolderKeysChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
|
||||||
|
|
||||||
interface LocalFilesystemBrowserProps {
|
interface LocalFilesystemBrowserProps {
|
||||||
rootPaths: string[];
|
rootPaths: string[];
|
||||||
|
|
@ -14,6 +13,8 @@ interface LocalFilesystemBrowserProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
onOpenFile: (fullPath: string) => void;
|
onOpenFile: (fullPath: string) => void;
|
||||||
|
expandedFolderKeys?: Set<string>;
|
||||||
|
onExpandedFolderKeysChange?: (nextExpandedKeys: Set<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalFolderFileEntry {
|
interface LocalFolderFileEntry {
|
||||||
|
|
@ -43,6 +44,51 @@ type LocalRootMount = {
|
||||||
|
|
||||||
type MountLoadStatus = "idle" | "loading" | "complete" | "error";
|
type MountLoadStatus = "idle" | "loading" | "complete" | "error";
|
||||||
|
|
||||||
|
const LOCAL_OPENABLE_EXTENSIONS = [
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".txt",
|
||||||
|
".json",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".csv",
|
||||||
|
".tsv",
|
||||||
|
".xml",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".sass",
|
||||||
|
".sql",
|
||||||
|
".toml",
|
||||||
|
".ini",
|
||||||
|
".conf",
|
||||||
|
".log",
|
||||||
|
".py",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".java",
|
||||||
|
".kt",
|
||||||
|
".kts",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".rb",
|
||||||
|
".php",
|
||||||
|
".swift",
|
||||||
|
".r",
|
||||||
|
".lua",
|
||||||
|
".sh",
|
||||||
|
".bash",
|
||||||
|
".zsh",
|
||||||
|
".fish",
|
||||||
|
".env",
|
||||||
|
".mk",
|
||||||
|
];
|
||||||
|
|
||||||
const getFolderDisplayName = (rootPath: string): string =>
|
const getFolderDisplayName = (rootPath: string): string =>
|
||||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||||
|
|
||||||
|
|
@ -73,16 +119,29 @@ function toMountedVirtualPath(mount: string, relativePath: string): string {
|
||||||
return `/${mount}${toVirtualPath(relativePath)}`;
|
return `/${mount}${toVirtualPath(relativePath)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNormalizedExtension(pathValue: string): string {
|
||||||
|
const fileName = getFileName(pathValue).toLowerCase();
|
||||||
|
if (!fileName) return "";
|
||||||
|
if (fileName === "dockerfile" || fileName === "makefile") {
|
||||||
|
return `.${fileName}`;
|
||||||
|
}
|
||||||
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
|
if (dotIndex <= 0) return "";
|
||||||
|
return fileName.slice(dotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
export function LocalFilesystemBrowser({
|
export function LocalFilesystemBrowser({
|
||||||
rootPaths,
|
rootPaths,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
active = true,
|
active = true,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
expandedFolderKeys,
|
||||||
|
onExpandedFolderKeysChange,
|
||||||
}: LocalFilesystemBrowserProps) {
|
}: LocalFilesystemBrowserProps) {
|
||||||
const electronAPI = useElectronAPI();
|
const electronAPI = useElectronAPI();
|
||||||
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
|
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
|
||||||
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
|
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
|
||||||
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
||||||
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
|
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
|
||||||
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
|
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
|
||||||
|
|
@ -90,8 +149,9 @@ export function LocalFilesystemBrowser({
|
||||||
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
|
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
|
||||||
const hasLoadedMountsOnceRef = useRef(false);
|
const hasLoadedMountsOnceRef = useRef(false);
|
||||||
const hasResolvedAtLeastOneRootRef = useRef(false);
|
const hasResolvedAtLeastOneRootRef = useRef(false);
|
||||||
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
|
const openableExtensions = useMemo(() => new Set(LOCAL_OPENABLE_EXTENSIONS), []);
|
||||||
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
|
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
|
||||||
|
const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
@ -153,7 +213,6 @@ export function LocalFilesystemBrowser({
|
||||||
rootPath,
|
rootPath,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||||
fileExtensions: supportedExtensions,
|
|
||||||
})) as LocalFolderFileEntry[];
|
})) as LocalFolderFileEntry[];
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRootStateMap((prev) => ({
|
setRootStateMap((prev) => ({
|
||||||
|
|
@ -181,7 +240,7 @@ export function LocalFilesystemBrowser({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]);
|
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) return;
|
if (active) return;
|
||||||
|
|
@ -198,7 +257,13 @@ export function LocalFilesystemBrowser({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event) => {
|
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: {
|
||||||
|
searchSpaceId: number | null;
|
||||||
|
reason: "watcher_event" | "safety_poll";
|
||||||
|
rootPath: string;
|
||||||
|
changedPath: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
}) => {
|
||||||
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -225,14 +290,13 @@ export function LocalFilesystemBrowser({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
rootPaths,
|
rootPaths,
|
||||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||||
fileExtensions: supportedExtensions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||||
};
|
};
|
||||||
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]);
|
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||||
|
|
@ -315,7 +379,7 @@ export function LocalFilesystemBrowser({
|
||||||
}, [rootPaths, rootStateMap, searchQuery]);
|
}, [rootPaths, rootStateMap, searchQuery]);
|
||||||
|
|
||||||
const toggleFolder = useCallback((folderKey: string) => {
|
const toggleFolder = useCallback((folderKey: string) => {
|
||||||
setExpandedFolderKeys((prev) => {
|
const update = (prev: Set<string>) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(folderKey)) {
|
if (next.has(folderKey)) {
|
||||||
next.delete(folderKey);
|
next.delete(folderKey);
|
||||||
|
|
@ -323,12 +387,17 @@ export function LocalFilesystemBrowser({
|
||||||
next.add(folderKey);
|
next.add(folderKey);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
};
|
||||||
}, []);
|
if (onExpandedFolderKeysChange) {
|
||||||
|
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInternalExpandedFolderKeys(update);
|
||||||
|
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
|
||||||
|
|
||||||
const renderFolder = useCallback(
|
const renderFolder = useCallback(
|
||||||
(folder: LocalFolderNode, depth: number, mount: string) => {
|
(folder: LocalFolderNode, depth: number, mount: string) => {
|
||||||
const isExpanded = expandedFolderKeys.has(folder.key);
|
const isExpanded = effectiveExpandedFolderKeys.has(folder.key);
|
||||||
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
||||||
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
|
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
|
||||||
a.name.localeCompare(b.name)
|
a.name.localeCompare(b.name)
|
||||||
|
|
@ -354,26 +423,43 @@ export function LocalFilesystemBrowser({
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
|
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
|
||||||
{files.map((file) => (
|
{files.map((file) => {
|
||||||
<button
|
const extension = getNormalizedExtension(file.relativePath);
|
||||||
key={file.fullPath}
|
const isOpenable = openableExtensions.has(extension);
|
||||||
type="button"
|
return (
|
||||||
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
|
<button
|
||||||
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
|
key={file.fullPath}
|
||||||
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
type="button"
|
||||||
title={file.fullPath}
|
onClick={
|
||||||
draggable={false}
|
isOpenable
|
||||||
>
|
? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
: undefined
|
||||||
<span className="truncate">{getFileName(file.relativePath)}</span>
|
}
|
||||||
</button>
|
className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
|
||||||
))}
|
isOpenable
|
||||||
|
? "hover:bg-muted/60"
|
||||||
|
: "cursor-not-allowed opacity-60"
|
||||||
|
}`}
|
||||||
|
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||||
|
title={
|
||||||
|
isOpenable
|
||||||
|
? file.fullPath
|
||||||
|
: `${file.fullPath}\nThis file type cannot be opened in the editor.`
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
disabled={!isOpenable}
|
||||||
|
>
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{getFileName(file.relativePath)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[expandedFolderKeys, onOpenFile, toggleFolder]
|
[effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rootPaths.length === 0) {
|
if (rootPaths.length === 0) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue