yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
file_browser.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <fstream>
5#include <regex>
6
7#include "absl/strings/str_cat.h"
8#include "absl/strings/str_split.h"
10#include "app/gui/core/style.h"
12#include "imgui/imgui.h"
13
14namespace yaze {
15namespace editor {
16
17namespace fs = std::filesystem;
18
19// ============================================================================
20// GitignoreParser Implementation
21// ============================================================================
22
23void GitignoreParser::LoadFromFile(const std::string& gitignore_path) {
24 std::ifstream file(gitignore_path);
25 if (!file.is_open()) {
26 return;
27 }
28
29 std::string line;
30 while (std::getline(file, line)) {
31 // Skip empty lines and comments
32 if (line.empty() || line[0] == '#') {
33 continue;
34 }
35
36 // Trim whitespace
37 size_t start = line.find_first_not_of(" \t");
38 size_t end = line.find_last_not_of(" \t\r\n");
39 if (start == std::string::npos || end == std::string::npos) {
40 continue;
41 }
42 line = line.substr(start, end - start + 1);
43
44 if (!line.empty()) {
45 AddPattern(line);
46 }
47 }
48}
49
50void GitignoreParser::AddPattern(const std::string& pattern) {
51 Pattern p;
52 p.pattern = pattern;
53
54 // Check for negation
55 if (!pattern.empty() && pattern[0] == '!') {
56 p.is_negation = true;
57 p.pattern = pattern.substr(1);
58 }
59
60 // Check for directory-only
61 if (!p.pattern.empty() && p.pattern.back() == '/') {
62 p.directory_only = true;
63 p.pattern.pop_back();
64 }
65
66 // Remove leading slash (anchors to root, but we match anywhere for simplicity)
67 if (!p.pattern.empty() && p.pattern[0] == '/') {
68 p.pattern = p.pattern.substr(1);
69 }
70
71 patterns_.push_back(p);
72}
73
74bool GitignoreParser::IsIgnored(const std::string& path,
75 bool is_directory) const {
76 // Extract just the filename for simple patterns
77 fs::path filepath(path);
78 std::string filename = filepath.filename().string();
79
80 bool ignored = false;
81
82 for (const auto& pattern : patterns_) {
83 // Directory-only patterns only match directories
84 if (pattern.directory_only && !is_directory) {
85 continue;
86 }
87
88 if (MatchPattern(filename, pattern) || MatchPattern(path, pattern)) {
89 ignored = !pattern.is_negation;
90 }
91 }
92
93 return ignored;
94}
95
97
98bool GitignoreParser::MatchPattern(const std::string& path,
99 const Pattern& pattern) const {
100 return MatchGlob(path, pattern.pattern);
101}
102
103bool GitignoreParser::MatchGlob(const std::string& text,
104 const std::string& pattern) const {
105 // Simple glob matching with * wildcard
106 size_t text_pos = 0;
107 size_t pattern_pos = 0;
108 size_t star_pos = std::string::npos;
109 size_t text_backup = 0;
110
111 while (text_pos < text.length()) {
112 if (pattern_pos < pattern.length() &&
113 (pattern[pattern_pos] == text[text_pos] || pattern[pattern_pos] == '?')) {
114 // Characters match or single-char wildcard
115 text_pos++;
116 pattern_pos++;
117 } else if (pattern_pos < pattern.length() && pattern[pattern_pos] == '*') {
118 // Multi-char wildcard - remember position
119 star_pos = pattern_pos;
120 text_backup = text_pos;
121 pattern_pos++;
122 } else if (star_pos != std::string::npos) {
123 // Mismatch after wildcard - backtrack
124 pattern_pos = star_pos + 1;
125 text_backup++;
126 text_pos = text_backup;
127 } else {
128 // No match
129 return false;
130 }
131 }
132
133 // Check remaining pattern characters (should only be wildcards)
134 while (pattern_pos < pattern.length() && pattern[pattern_pos] == '*') {
135 pattern_pos++;
136 }
137
138 return pattern_pos == pattern.length();
139}
140
141// ============================================================================
142// FileBrowser Implementation
143// ============================================================================
144
145void FileBrowser::SetRootPath(const std::string& path) {
146 if (path.empty()) {
147 root_path_.clear();
149 needs_refresh_ = true;
150 return;
151 }
152
153 std::error_code ec;
154 fs::path resolved_path;
155
156 if (fs::path(path).is_relative()) {
157 resolved_path = fs::absolute(path, ec);
158 } else {
159 resolved_path = path;
160 }
161
162 if (ec || !fs::exists(resolved_path, ec) ||
163 !fs::is_directory(resolved_path, ec)) {
164 // Invalid path - keep current state
165 return;
166 }
167
168 root_path_ = resolved_path.string();
169 needs_refresh_ = true;
170
171 // Load .gitignore if present
172 if (respect_gitignore_) {
174
175 // Load .gitignore from root
176 fs::path gitignore = resolved_path / ".gitignore";
177 if (fs::exists(gitignore, ec)) {
178 gitignore_parser_.LoadFromFile(gitignore.string());
179 }
180
181 // Also add common default ignores
182 gitignore_parser_.AddPattern("node_modules/");
185 gitignore_parser_.AddPattern("__pycache__/");
187 gitignore_parser_.AddPattern(".DS_Store");
188 gitignore_parser_.AddPattern("Thumbs.db");
189 }
190}
191
193 if (root_path_.empty()) {
194 return;
195 }
196
198 root_entry_.name = fs::path(root_path_).filename().string();
203
204 file_count_ = 0;
206
208 needs_refresh_ = false;
209}
210
211void FileBrowser::ScanDirectory(const fs::path& path, FileEntry& parent,
212 int depth) {
213 if (depth > kMaxDepth) {
214 return;
215 }
216
217 std::error_code ec;
218 std::vector<FileEntry> entries;
219
220 for (const auto& entry : fs::directory_iterator(
221 path, fs::directory_options::skip_permission_denied, ec)) {
222 if (ec) {
223 continue;
224 }
225
226 // Check entry count limit
228 break;
229 }
230
231 fs::path entry_path = entry.path();
232 std::string filename = entry_path.filename().string();
233 bool is_dir = entry.is_directory(ec);
234
235 // Apply filters
236 if (!ShouldShow(entry_path, is_dir)) {
237 continue;
238 }
239
240 // Check gitignore
241 if (respect_gitignore_) {
242 std::string relative_path =
243 fs::relative(entry_path, fs::path(root_path_), ec).string();
244 if (!ec && gitignore_parser_.IsIgnored(relative_path, is_dir)) {
245 continue;
246 }
247 }
248
249 FileEntry fe;
250 fe.name = filename;
251 fe.full_path = entry_path.string();
252 fe.is_directory = is_dir;
254 : DetectFileType(filename);
255
256 if (is_dir) {
258 // Recursively scan subdirectories
259 ScanDirectory(entry_path, fe, depth + 1);
260 } else {
261 file_count_++;
262 }
263
264 entries.push_back(std::move(fe));
265 }
266
267 // Sort: directories first, then alphabetically
268 std::sort(entries.begin(), entries.end(), [](const FileEntry& a, const FileEntry& b) {
269 if (a.is_directory != b.is_directory) {
270 return a.is_directory; // Directories first
271 }
272 return a.name < b.name;
273 });
274
275 parent.children = std::move(entries);
276}
277
278bool FileBrowser::ShouldShow(const fs::path& path, bool is_directory) const {
279 std::string filename = path.filename().string();
280
281 // Hide dotfiles unless explicitly enabled
282 if (!show_hidden_files_ && !filename.empty() && filename[0] == '.') {
283 return false;
284 }
285
286 // Apply file filter (only for files, not directories)
287 if (!is_directory && !file_filter_.empty()) {
288 return MatchesFilter(filename);
289 }
290
291 return true;
292}
293
294bool FileBrowser::MatchesFilter(const std::string& filename) const {
295 if (file_filter_.empty()) {
296 return true;
297 }
298
299 // Extract extension
300 size_t dot_pos = filename.rfind('.');
301 if (dot_pos == std::string::npos) {
302 return false;
303 }
304
305 std::string ext = filename.substr(dot_pos);
306 // Convert to lowercase for comparison
307 std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
308
309 return file_filter_.count(ext) > 0;
310}
311
312void FileBrowser::SetFileFilter(const std::vector<std::string>& extensions) {
313 file_filter_.clear();
314 for (const auto& ext : extensions) {
315 std::string lower_ext = ext;
316 std::transform(lower_ext.begin(), lower_ext.end(), lower_ext.begin(),
317 ::tolower);
318 // Ensure extension starts with dot
319 if (!lower_ext.empty() && lower_ext[0] != '.') {
320 lower_ext = "." + lower_ext;
321 }
322 file_filter_.insert(lower_ext);
323 }
324 needs_refresh_ = true;
325}
326
327void FileBrowser::ClearFileFilter() {
328 file_filter_.clear();
329 needs_refresh_ = true;
330}
331
332FileEntry::FileType FileBrowser::DetectFileType(
333 const std::string& filename) const {
334 // Extract extension
335 size_t dot_pos = filename.rfind('.');
336 if (dot_pos == std::string::npos) {
337 return FileEntry::FileType::kUnknown;
338 }
339
340 std::string ext = filename.substr(dot_pos);
341 std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
342
343 // Assembly files
344 if (ext == ".asm" || ext == ".s" || ext == ".65c816") {
345 return FileEntry::FileType::kAssembly;
346 }
347
348 // Source files
349 if (ext == ".cc" || ext == ".cpp" || ext == ".c" || ext == ".py" ||
350 ext == ".js" || ext == ".ts" || ext == ".rs" || ext == ".go") {
351 return FileEntry::FileType::kSource;
352 }
353
354 // Header files
355 if (ext == ".h" || ext == ".hpp" || ext == ".hxx") {
356 return FileEntry::FileType::kHeader;
357 }
358
359 // Text files
360 if (ext == ".txt" || ext == ".md" || ext == ".rst") {
361 return FileEntry::FileType::kText;
362 }
363
364 // Config files
365 if (ext == ".cfg" || ext == ".ini" || ext == ".conf" || ext == ".yaml" ||
366 ext == ".yml" || ext == ".toml") {
367 return FileEntry::FileType::kConfig;
368 }
369
370 // JSON
371 if (ext == ".json") {
372 return FileEntry::FileType::kJson;
373 }
374
375 // Images
376 if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" ||
377 ext == ".bmp") {
378 return FileEntry::FileType::kImage;
379 }
380
381 // Binary
382 if (ext == ".bin" || ext == ".sfc" || ext == ".smc" || ext == ".rom") {
383 return FileEntry::FileType::kBinary;
384 }
385
386 return FileEntry::FileType::kUnknown;
387}
388
389const char* FileBrowser::GetFileIcon(FileEntry::FileType type) const {
390 switch (type) {
391 case FileEntry::FileType::kDirectory:
392 return ICON_MD_FOLDER;
393 case FileEntry::FileType::kAssembly:
394 return ICON_MD_MEMORY;
395 case FileEntry::FileType::kSource:
396 return ICON_MD_CODE;
397 case FileEntry::FileType::kHeader:
399 case FileEntry::FileType::kText:
400 return ICON_MD_DESCRIPTION;
401 case FileEntry::FileType::kConfig:
402 return ICON_MD_SETTINGS;
403 case FileEntry::FileType::kJson:
404 return ICON_MD_DATA_OBJECT;
405 case FileEntry::FileType::kImage:
406 return ICON_MD_IMAGE;
407 case FileEntry::FileType::kBinary:
408 return ICON_MD_HEXAGON;
409 default:
411 }
412}
413
414void FileBrowser::Draw() {
415 if (root_path_.empty()) {
416 ImGui::TextDisabled("No folder selected");
417 if (ImGui::Button(ICON_MD_FOLDER_OPEN " Open Folder...")) {
418 // Note: Actual folder dialog should be handled externally
419 // via the callback or by the host component
420 }
421 return;
422 }
423
424 if (needs_refresh_) {
425 Refresh();
426 }
427
428 // Header with folder name and refresh button
429 ImGui::PushStyleColor(ImGuiCol_Text, gui::GetTextSecondaryVec4());
430 ImGui::Text("%s", ICON_MD_FOLDER_OPEN);
431 ImGui::PopStyleColor();
432 ImGui::SameLine();
433 ImGui::Text("%s", root_entry_.name.c_str());
434 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 24.0f);
435 if (ImGui::SmallButton(ICON_MD_REFRESH)) {
436 needs_refresh_ = true;
437 }
438 if (ImGui::IsItemHovered()) {
439 ImGui::SetTooltip("Refresh file list");
440 }
441
442 ImGui::Separator();
443
444 // File count
445 ImGui::PushStyleColor(ImGuiCol_Text, gui::GetTextDisabledVec4());
446 ImGui::Text("%zu files, %zu folders", file_count_, directory_count_);
447 ImGui::PopStyleColor();
448
449 ImGui::Spacing();
450
451 // Tree view
452 ImGui::BeginChild("##FileTree", ImVec2(0, 0), false);
453 for (auto& child : root_entry_.children) {
454 DrawEntry(child);
455 }
456 ImGui::EndChild();
457}
458
459void FileBrowser::DrawCompact() {
460 if (root_path_.empty()) {
461 ImGui::TextDisabled("No folder");
462 return;
463 }
464
465 if (needs_refresh_) {
466 Refresh();
467 }
468
469 // Just the tree without header
470 for (auto& child : root_entry_.children) {
471 DrawEntry(child);
472 }
473}
474
475void FileBrowser::DrawEntry(FileEntry& entry, int depth) {
476 ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth |
477 ImGuiTreeNodeFlags_OpenOnArrow |
478 ImGuiTreeNodeFlags_OpenOnDoubleClick;
479
480 if (!entry.is_directory || entry.children.empty()) {
481 flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
482 }
483
484 if (entry.full_path == selected_path_) {
485 flags |= ImGuiTreeNodeFlags_Selected;
486 }
487
488 // Build label with icon
489 std::string label =
490 absl::StrCat(GetFileIcon(entry.file_type), " ", entry.name);
491
492 bool node_open = ImGui::TreeNodeEx(label.c_str(), flags);
493
494 // Handle selection
495 if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) {
496 selected_path_ = entry.full_path;
497
498 if (entry.is_directory) {
499 if (on_directory_clicked_) {
500 on_directory_clicked_(entry.full_path);
501 }
502 } else {
503 if (on_file_clicked_) {
504 on_file_clicked_(entry.full_path);
505 }
506 }
507 }
508
509 // Tooltip with full path
510 if (ImGui::IsItemHovered()) {
511 ImGui::SetTooltip("%s", entry.full_path.c_str());
512 }
513
514 // Draw children if expanded
515 if (node_open && entry.is_directory && !entry.children.empty()) {
516 for (auto& child : entry.children) {
517 DrawEntry(child, depth + 1);
518 }
519 ImGui::TreePop();
520 }
521}
522
523} // namespace editor
524} // namespace yaze
void Refresh()
Refresh the file tree from disk.
bool ShouldShow(const std::filesystem::path &path, bool is_directory) const
void SetRootPath(const std::string &path)
Set the root path for the file browser.
static constexpr size_t kMaxEntries
FileEntry::FileType DetectFileType(const std::string &filename) const
void ScanDirectory(const std::filesystem::path &path, FileEntry &parent, int depth=0)
static constexpr int kMaxDepth
GitignoreParser gitignore_parser_
std::vector< Pattern > patterns_
bool MatchPattern(const std::string &path, const Pattern &pattern) const
void AddPattern(const std::string &pattern)
bool IsIgnored(const std::string &path, bool is_directory) const
bool MatchGlob(const std::string &text, const std::string &pattern) const
void LoadFromFile(const std::string &gitignore_path)
#define ICON_MD_INSERT_DRIVE_FILE
Definition icons.h:999
#define ICON_MD_FOLDER_OPEN
Definition icons.h:813
#define ICON_MD_SETTINGS
Definition icons.h:1699
#define ICON_MD_DATA_OBJECT
Definition icons.h:521
#define ICON_MD_MEMORY
Definition icons.h:1195
#define ICON_MD_REFRESH
Definition icons.h:1572
#define ICON_MD_CODE
Definition icons.h:434
#define ICON_MD_DEVELOPER_BOARD
Definition icons.h:547
#define ICON_MD_IMAGE
Definition icons.h:982
#define ICON_MD_DESCRIPTION
Definition icons.h:539
#define ICON_MD_FOLDER
Definition icons.h:809
#define ICON_MD_HEXAGON
Definition icons.h:937
ImVec4 GetTextDisabledVec4()
ImVec4 GetTextSecondaryVec4()
Represents a file or folder in the file browser.
std::vector< FileEntry > children