Step 85: filesystem tree explorer
This commit is contained in:
@@ -182,6 +182,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1
|
||||
|
||||
### Phase 4b: File Management (Steps 84–89) — In Progress
|
||||
- [x] Step 84: **IMPLEMENTED** — Native file dialogs via tinyfiledialogs, FileDialog wrapper with test provider injection (2/2 tests pass)
|
||||
- [x] Step 85: **IMPLEMENTED** — Filesystem tree with .gitignore filtering, recursive Explorer rendering (1/1 tests pass)
|
||||
|
||||
---
|
||||
|
||||
@@ -245,6 +246,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi
|
||||
**Step 82:** Compile and pass (1/1)
|
||||
**Step 83:** Compile and pass (2/2)
|
||||
**Step 84:** Compile and pass (2/2)
|
||||
**Step 85:** Compile and pass (1/1)
|
||||
|
||||
---
|
||||
|
||||
@@ -337,3 +339,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e
|
||||
| 2026-02-09 | Codex | Step 82: Minimap overview with viewport indicator and click-to-scroll. 1/1 tests pass. |
|
||||
| 2026-02-09 | Codex | Step 83: Added tree-sitter grammars for JS/TS/Java/Rust/Go and editor modes; syntax highlighting extended to 8 languages. 2/2 tests pass. |
|
||||
| 2026-02-09 | Codex | Step 84: Native file dialogs via tinyfiledialogs; FileDialog wrapper with injectable provider. 2/2 tests pass. |
|
||||
| 2026-02-09 | Codex | Step 85: Filesystem tree with .gitignore filtering and Explorer rendering. 1/1 tests pass. |
|
||||
|
||||
@@ -486,6 +486,9 @@ add_executable(step84_test tests/step84_test.cpp src/FileDialog.cpp)
|
||||
target_include_directories(step84_test PRIVATE src)
|
||||
target_link_libraries(step84_test PRIVATE tinyfiledialogs)
|
||||
|
||||
add_executable(step85_test tests/step85_test.cpp)
|
||||
target_include_directories(step85_test PRIVATE src)
|
||||
|
||||
find_package(SDL2 CONFIG REQUIRED)
|
||||
find_package(OpenGL REQUIRED)
|
||||
find_package(glad CONFIG REQUIRED)
|
||||
|
||||
87
editor/src/FileTree.h
Normal file
87
editor/src/FileTree.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
// Step 85: Filesystem tree with basic .gitignore support
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
struct FileNode {
|
||||
std::string name;
|
||||
std::string path;
|
||||
bool isDir = false;
|
||||
std::vector<FileNode> children;
|
||||
};
|
||||
|
||||
class FileTree {
|
||||
public:
|
||||
FileNode build(const std::string& rootPath) {
|
||||
root_ = std::filesystem::path(rootPath);
|
||||
ignorePatterns_ = loadGitIgnore(root_ / ".gitignore");
|
||||
return buildNode(root_);
|
||||
}
|
||||
|
||||
private:
|
||||
std::filesystem::path root_;
|
||||
std::vector<std::string> ignorePatterns_;
|
||||
|
||||
FileNode buildNode(const std::filesystem::path& p) {
|
||||
FileNode node;
|
||||
node.name = p.filename().string();
|
||||
node.path = p.string();
|
||||
node.isDir = std::filesystem::is_directory(p);
|
||||
if (node.isDir) {
|
||||
for (auto& entry : std::filesystem::directory_iterator(p)) {
|
||||
const auto childPath = entry.path();
|
||||
if (shouldIgnore(childPath)) continue;
|
||||
node.children.push_back(buildNode(childPath));
|
||||
}
|
||||
std::sort(node.children.begin(), node.children.end(),
|
||||
[](const FileNode& a, const FileNode& b) {
|
||||
if (a.isDir != b.isDir) return a.isDir > b.isDir;
|
||||
return a.name < b.name;
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
std::vector<std::string> loadGitIgnore(const std::filesystem::path& p) {
|
||||
std::vector<std::string> patterns;
|
||||
std::ifstream f(p.string());
|
||||
if (!f.is_open()) return patterns;
|
||||
std::string line;
|
||||
while (std::getline(f, line)) {
|
||||
if (line.empty()) continue;
|
||||
if (line[0] == '#') continue;
|
||||
patterns.push_back(line);
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
bool shouldIgnore(const std::filesystem::path& p) const {
|
||||
std::string rel = std::filesystem::relative(p, root_).generic_string();
|
||||
std::string name = p.filename().string();
|
||||
bool isDir = std::filesystem::is_directory(p);
|
||||
|
||||
for (const auto& pat : ignorePatterns_) {
|
||||
if (pat.empty()) continue;
|
||||
if (pat.back() == '/' && isDir) {
|
||||
std::string dirName = pat.substr(0, pat.size() - 1);
|
||||
if (name == dirName) return true;
|
||||
if (rel.rfind(dirName + "/", 0) == 0) return true;
|
||||
} else if (pat.front() == '*') {
|
||||
std::string suffix = pat.substr(1);
|
||||
if (suffix.size() <= name.size() && name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0)
|
||||
return true;
|
||||
} else if (pat.front() == '/') {
|
||||
std::string exact = pat.substr(1);
|
||||
if (rel == exact) return true;
|
||||
} else {
|
||||
if (name == pat) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "CodeEditorWidget.h"
|
||||
#include "EditorMode.h"
|
||||
#include "FileDialog.h"
|
||||
#include "FileTree.h"
|
||||
#include "ast/Generator.h"
|
||||
|
||||
#include <cstdio>
|
||||
@@ -29,6 +30,7 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor application state
|
||||
@@ -66,6 +68,10 @@ struct EditorState {
|
||||
CodeEditorWidget codeWidget;
|
||||
bool showWhitespace = false;
|
||||
bool showMinimap = false;
|
||||
std::string workspaceRoot;
|
||||
FileTree fileTree;
|
||||
FileNode fileTreeRoot;
|
||||
bool fileTreeDirty = true;
|
||||
|
||||
void init() {
|
||||
std::string defaultContent =
|
||||
@@ -91,6 +97,8 @@ struct EditorState {
|
||||
editBuf = defaultContent;
|
||||
highlightsDirty = true;
|
||||
outputLog = "Whetstone Editor ready.\n";
|
||||
workspaceRoot = std::filesystem::current_path().string();
|
||||
fileTreeDirty = true;
|
||||
}
|
||||
|
||||
void setLanguage(const std::string& lang) {
|
||||
@@ -210,6 +218,12 @@ struct EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
void refreshFileTree() {
|
||||
if (!fileTreeDirty) return;
|
||||
fileTreeRoot = fileTree.build(workspaceRoot);
|
||||
fileTreeDirty = false;
|
||||
}
|
||||
|
||||
void updateHighlights() {
|
||||
if (!highlightsDirty) return;
|
||||
highlights = SyntaxHighlighter::highlight(editBuf, language);
|
||||
@@ -407,6 +421,26 @@ static void RenderHighlightedText(const std::string& text,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File tree rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
static void RenderFileTree(const FileNode& node, EditorState& state) {
|
||||
if (!node.isDir) {
|
||||
if (ImGui::Selectable(node.name.c_str())) {
|
||||
state.doOpen(node.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick;
|
||||
if (ImGui::TreeNodeEx(node.name.c_str(), flags)) {
|
||||
for (const auto& child : node.children) {
|
||||
RenderFileTree(child, state);
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -557,6 +591,14 @@ int main(int, char**) {
|
||||
state.doOpen(path);
|
||||
}
|
||||
}
|
||||
if (ImGui::MenuItem("Open Folder...")) {
|
||||
auto path = FileDialog::openFolder({"Open Folder", state.workspaceRoot});
|
||||
if (!path.empty()) {
|
||||
state.workspaceRoot = path;
|
||||
state.fileTreeDirty = true;
|
||||
lastDialogPath = path;
|
||||
}
|
||||
}
|
||||
if (ImGui::MenuItem("Save", state.keys.getBinding("file.save").toString().c_str()))
|
||||
{
|
||||
if (state.filePath == "(untitled)") {
|
||||
@@ -639,12 +681,11 @@ int main(int, char**) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "FILES");
|
||||
ImGui::Separator();
|
||||
// Placeholder files
|
||||
const char* files[] = {"Calculator.py", "ConditionalExample.py", "main.cpp", "test.el"};
|
||||
for (auto f : files) {
|
||||
if (ImGui::Selectable(f)) {
|
||||
state.doOpen(f);
|
||||
}
|
||||
state.refreshFileTree();
|
||||
if (state.fileTreeRoot.path.empty()) {
|
||||
ImGui::TextDisabled("(no workspace)");
|
||||
} else {
|
||||
RenderFileTree(state.fileTreeRoot, state);
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::End();
|
||||
|
||||
58
editor/tests/step85_test.cpp
Normal file
58
editor/tests/step85_test.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
// Step 85 TDD Test: Filesystem tree and gitignore
|
||||
//
|
||||
// Tests:
|
||||
// 1. Builds tree with files and folders
|
||||
// 2. Respects .gitignore patterns
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include "FileTree.h"
|
||||
|
||||
static void writeFile(const std::filesystem::path& p, const std::string& text) {
|
||||
std::ofstream f(p.string(), std::ios::binary);
|
||||
f << text;
|
||||
}
|
||||
|
||||
int main() {
|
||||
int passed = 0;
|
||||
int failed = 0;
|
||||
|
||||
std::filesystem::path root = std::filesystem::current_path() / "tmp_step85";
|
||||
std::filesystem::create_directories(root / "src");
|
||||
std::filesystem::create_directories(root / "ignoredir");
|
||||
|
||||
writeFile(root / "src" / "main.cpp", "int main(){}\n");
|
||||
writeFile(root / "ignore.tmp", "x");
|
||||
writeFile(root / "ignoredir" / "foo.txt", "y");
|
||||
writeFile(root / ".gitignore", "ignoredir/\n*.tmp\n");
|
||||
|
||||
FileTree tree;
|
||||
auto node = tree.build(root.string());
|
||||
assert(node.isDir);
|
||||
|
||||
bool sawMain = false;
|
||||
bool sawIgnored = false;
|
||||
for (const auto& child : node.children) {
|
||||
if (child.name == "src") {
|
||||
for (const auto& c2 : child.children) {
|
||||
if (c2.name == "main.cpp") sawMain = true;
|
||||
}
|
||||
}
|
||||
if (child.name == "ignoredir" || child.name == "ignore.tmp") {
|
||||
sawIgnored = true;
|
||||
}
|
||||
}
|
||||
|
||||
assert(sawMain);
|
||||
assert(!sawIgnored);
|
||||
|
||||
std::cout << "Test 1 PASS: Tree build + gitignore" << std::endl;
|
||||
++passed;
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
|
||||
std::cout << "\n=== Step 85 Results: " << passed << " passed, " << failed << " failed ===" << std::endl;
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user