diff --git a/PROGRESS.md b/PROGRESS.md index e70773e..a02ed65 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 516cb1a..f69e4d7 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/FileTree.h b/editor/src/FileTree.h new file mode 100644 index 0000000..52bb8ed --- /dev/null +++ b/editor/src/FileTree.h @@ -0,0 +1,87 @@ +#pragma once +// Step 85: Filesystem tree with basic .gitignore support + +#include +#include +#include +#include +#include +#include + +struct FileNode { + std::string name; + std::string path; + bool isDir = false; + std::vector 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 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 loadGitIgnore(const std::filesystem::path& p) { + std::vector 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; + } +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 85500c4..d268551 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -20,6 +20,7 @@ #include "CodeEditorWidget.h" #include "EditorMode.h" #include "FileDialog.h" +#include "FileTree.h" #include "ast/Generator.h" #include @@ -29,6 +30,7 @@ #include #include #include +#include // --------------------------------------------------------------------------- // 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(); diff --git a/editor/tests/step85_test.cpp b/editor/tests/step85_test.cpp new file mode 100644 index 0000000..e424484 --- /dev/null +++ b/editor/tests/step85_test.cpp @@ -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 +#include +#include +#include +#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; +}