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