diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index cf53c30..e53f9dd 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -7,6 +7,7 @@ import ( "novamd/internal/models" "os" "path/filepath" + "sort" "strings" ) @@ -17,9 +18,10 @@ type FileSystem struct { } type FileNode struct { - Type string `json:"type"` - Name string `json:"name"` - Files []FileNode `json:"files,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Children []FileNode `json:"children,omitempty"` } func New(rootDir string, settings *models.Settings) *FileSystem { @@ -73,40 +75,52 @@ func (fs *FileSystem) validatePath(path string) (string, error) { } func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) { - return fs.walkDirectory(fs.RootDir) + return fs.walkDirectory(fs.RootDir, "") } -func (fs *FileSystem) walkDirectory(dir string) ([]FileNode, error) { - var nodes []FileNode - +func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } + var folders []FileNode + var files []FileNode + for _, entry := range entries { + name := entry.Name() + path := filepath.Join(prefix, name) + fullPath := filepath.Join(dir, name) + if entry.IsDir() { - subdir := filepath.Join(dir, entry.Name()) - subFiles, err := fs.walkDirectory(subdir) + children, err := fs.walkDirectory(fullPath, path) if err != nil { return nil, err } - nodes = append(nodes, FileNode{ - Type: "directory", - Name: entry.Name(), - Files: subFiles, + folders = append(folders, FileNode{ + ID: path, // Using path as ID ensures uniqueness + Name: name, + Path: path, + Children: children, }) } else { - nodes = append(nodes, FileNode{ - Type: "file", - Name: entry.Name(), + files = append(files, FileNode{ + ID: path, // Using path as ID ensures uniqueness + Name: name, + Path: path, }) } } - return nodes, nil + // Sort folders and files alphabetically + sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) + sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name }) + + // Combine folders and files, with folders first + return append(folders, files...), nil } + func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { var foundPaths []string var searchPattern string diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d240b4b..d5badbe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "codemirror": "^6.0.1", "katex": "^0.16.11", "react": "^18.3.1", + "react-arborist": "^3.4.0", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", @@ -2549,6 +2550,24 @@ "node": ">= 8" } }, + "node_modules/@react-dnd/asap": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" + }, "node_modules/@tabler/icons": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.19.0.tgz", @@ -2739,7 +2758,7 @@ "version": "22.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.0.tgz", "integrity": "sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -4242,6 +4261,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -4647,7 +4686,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5344,6 +5382,15 @@ "node": "*" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -6458,6 +6505,12 @@ "url": "https://github.com/sponsors/streamich" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -7883,6 +7936,62 @@ "node": ">=0.10.0" } }, + "node_modules/react-arborist": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.0.tgz", + "integrity": "sha512-QI46oRGXJr0oaQfqqVobIiIoqPp5Y5gM69D2A2P7uHVif+X75XWnScR5drC7YDKgJ4CXVaDeFwnYKOWRRfncMg==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.10", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -8057,6 +8166,23 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8098,6 +8224,12 @@ "node": ">= 10.13.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/refractor": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", @@ -9386,7 +9518,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -9694,6 +9826,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e656b04..aef4811 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "codemirror": "^6.0.1", "katex": "^0.16.11", "react": "^18.3.1", + "react-arborist": "^3.4.0", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", diff --git a/frontend/src/components/FileTree.js b/frontend/src/components/FileTree.js index 682da34..30bfeaf 100644 --- a/frontend/src/components/FileTree.js +++ b/frontend/src/components/FileTree.js @@ -1,28 +1,65 @@ -import React, { useMemo } from 'react'; -import { Tree } from '@geist-ui/core'; -import { File, Folder, Image } from '@geist-ui/icons'; +import React from 'react'; +import { Tree } from 'react-arborist'; +import { Group, Text } from '@mantine/core'; +import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react'; import { isImageFile } from '../utils/fileHelpers'; -const FileTree = ({ files, handleFileSelect }) => { - if (files.length === 0) { - return