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