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