yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
recent_projects_model.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cctype>
6#include <chrono>
7#include <cstdint>
8#include <filesystem>
9#include <fstream>
10#include <limits>
11#include <mutex>
12#include <sstream>
13#include <system_error>
14#include <thread>
15#include <utility>
16#include <vector>
17
18#include "absl/strings/str_format.h"
19#include "app/gui/core/icons.h"
20#include "core/project.h"
21#include "nlohmann/json.hpp"
22#include "util/log.h"
23#include "util/platform_paths.h"
24#include "util/rom_hash.h"
25
26namespace yaze {
27namespace editor {
28
29namespace {
30
31// Display cap. Matches the previous welcome_screen constant; kept here because
32// the model owns the truncation decision.
33constexpr std::size_t kMaxRecentEntries = 6;
34
35std::string ToLowerAscii(std::string value) {
36 std::transform(
37 value.begin(), value.end(), value.begin(),
38 [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
39 return value;
40}
41
42std::string TrimAscii(const std::string& value) {
43 const auto not_space = [](unsigned char c) {
44 return !std::isspace(c);
45 };
46 auto begin = std::find_if(value.begin(), value.end(), not_space);
47 if (begin == value.end())
48 return "";
49 auto end = std::find_if(value.rbegin(), value.rend(), not_space).base();
50 return std::string(begin, end);
51}
52
53bool IsRomPath(const std::filesystem::path& path) {
54 const std::string ext = ToLowerAscii(path.extension().string());
55 return ext == ".sfc" || ext == ".smc";
56}
57
58bool IsProjectPath(const std::filesystem::path& path) {
59 const std::string ext = ToLowerAscii(path.extension().string());
60 return ext == ".yaze" || ext == ".yazeproj" || ext == ".zsproj";
61}
62
63std::string FormatFileSize(std::uintmax_t bytes) {
64 static constexpr std::array<const char*, 4> kUnits = {"B", "KB", "MB", "GB"};
65 double value = static_cast<double>(bytes);
66 std::size_t unit = 0;
67 while (value >= 1024.0 && unit + 1 < kUnits.size()) {
68 value /= 1024.0;
69 ++unit;
70 }
71 if (unit == 0) {
72 return absl::StrFormat("%llu %s", static_cast<unsigned long long>(bytes),
73 kUnits[unit]);
74 }
75 return absl::StrFormat("%.1f %s", value, kUnits[unit]);
76}
77
78std::string GetRelativeTimeString(
79 const std::filesystem::file_time_type& ftime) {
80 auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
81 ftime - std::filesystem::file_time_type::clock::now() +
82 std::chrono::system_clock::now());
83 auto now = std::chrono::system_clock::now();
84 auto diff = std::chrono::duration_cast<std::chrono::hours>(now - sctp);
85
86 int hours = diff.count();
87 if (hours < 24)
88 return "Today";
89 if (hours < 48)
90 return "Yesterday";
91 if (hours < 168) {
92 int days = hours / 24;
93 return absl::StrFormat("%d days ago", days);
94 }
95 if (hours < 720) {
96 int weeks = hours / 168;
97 return absl::StrFormat("%d week%s ago", weeks, weeks > 1 ? "s" : "");
98 }
99 int months = hours / 720;
100 return absl::StrFormat("%d month%s ago", months, months > 1 ? "s" : "");
101}
102
103std::string DecodeSnesRegion(std::uint8_t code) {
104 switch (code) {
105 case 0x00:
106 return "Japan";
107 case 0x01:
108 return "USA";
109 case 0x02:
110 return "Europe";
111 case 0x03:
112 return "Sweden";
113 case 0x06:
114 return "France";
115 case 0x07:
116 return "Netherlands";
117 case 0x08:
118 return "Spain";
119 case 0x09:
120 return "Germany";
121 case 0x0A:
122 return "Italy";
123 case 0x0B:
124 return "China";
125 case 0x0D:
126 return "Korea";
127 default:
128 return "Unknown region";
129 }
130}
131
132std::string DecodeSnesMapMode(std::uint8_t code) {
133 switch (code & 0x3F) {
134 case 0x20:
135 return "LoROM";
136 case 0x21:
137 return "HiROM";
138 case 0x22:
139 return "ExLoROM";
140 case 0x25:
141 return "ExHiROM";
142 case 0x30:
143 return "Fast LoROM";
144 case 0x31:
145 return "Fast HiROM";
146 default:
147 return absl::StrFormat("Mode %02X", code);
148 }
149}
150
151struct SnesHeaderMetadata {
152 std::string title;
153 std::string region;
154 std::string map_mode;
155 bool valid = false;
156};
157
158bool ReadFileBlock(std::ifstream* file, std::streamoff offset, char* out,
159 std::size_t size) {
160 if (!file)
161 return false;
162 file->clear();
163 file->seekg(offset, std::ios::beg);
164 if (!file->good())
165 return false;
166 file->read(out, static_cast<std::streamsize>(size));
167 return file->good() && file->gcount() == static_cast<std::streamsize>(size);
168}
169
170bool LooksLikeSnesTitle(const std::string& title) {
171 if (title.empty())
172 return false;
173 int printable = 0;
174 for (unsigned char c : title) {
175 if (c >= 32 && c <= 126)
176 ++printable;
177 }
178 return printable >= std::max(6, static_cast<int>(title.size()) / 2);
179}
180
181SnesHeaderMetadata ReadSnesHeaderMetadata(const std::filesystem::path& path) {
182 std::error_code size_ec;
183 const std::uintmax_t file_size = std::filesystem::file_size(path, size_ec);
184 if (size_ec || file_size < 0x8020)
185 return {};
186
187 std::ifstream input(path, std::ios::binary);
188 if (!input.is_open())
189 return {};
190
191 static constexpr std::array<std::streamoff, 2> kHeaderBases = {0x7FC0,
192 0xFFC0};
193 static constexpr std::array<std::streamoff, 2> kHeaderBiases = {0, 512};
194
195 for (std::streamoff bias : kHeaderBiases) {
196 for (std::streamoff base : kHeaderBases) {
197 const std::streamoff offset = base + bias;
198 if (static_cast<std::uintmax_t>(offset + 0x20) > file_size)
199 continue;
200
201 char header[0x20] = {};
202 if (!ReadFileBlock(&input, offset, header, sizeof(header)))
203 continue;
204
205 std::string raw_title(header, header + 21);
206 for (char& c : raw_title) {
207 unsigned char uc = static_cast<unsigned char>(c);
208 if (uc < 32 || uc > 126)
209 c = ' ';
210 }
211 const std::string title = TrimAscii(raw_title);
212 if (!LooksLikeSnesTitle(title))
213 continue;
214
215 const std::uint8_t map_mode_code =
216 static_cast<std::uint8_t>(header[0x15]);
217 const std::uint8_t region_code = static_cast<std::uint8_t>(header[0x19]);
218 return {title, DecodeSnesRegion(region_code),
219 DecodeSnesMapMode(map_mode_code), true};
220 }
221 }
222
223 return {};
224}
225
226std::string ReadFileCrc32(const std::filesystem::path& path) {
227 std::error_code size_ec;
228 const std::uintmax_t file_size = std::filesystem::file_size(path, size_ec);
229 if (size_ec || file_size == 0 ||
230 file_size > static_cast<std::uintmax_t>(
231 std::numeric_limits<std::size_t>::max())) {
232 return "";
233 }
234
235 std::ifstream input(path, std::ios::binary);
236 if (!input.is_open())
237 return "";
238
239 std::vector<std::uint8_t> data(static_cast<std::size_t>(file_size));
240 input.read(reinterpret_cast<char*>(data.data()),
241 static_cast<std::streamsize>(data.size()));
242 if (input.gcount() != static_cast<std::streamsize>(data.size()))
243 return "";
244
245 return absl::StrFormat("%08X",
246 util::CalculateCrc32(data.data(), data.size()));
247}
248
249std::string ParseConfigValue(const std::string& line) {
250 if (line.empty())
251 return "";
252 std::size_t sep = line.find('=');
253 if (sep == std::string::npos)
254 sep = line.find(':');
255 if (sep == std::string::npos || sep + 1 >= line.size())
256 return "";
257 std::string value = line.substr(sep + 1);
258 const std::size_t comment_pos = value.find('#');
259 if (comment_pos != std::string::npos)
260 value = value.substr(0, comment_pos);
261 value = TrimAscii(value);
262 if (value.empty())
263 return "";
264 if (!value.empty() && value.back() == ',')
265 value.pop_back();
266 value = TrimAscii(value);
267 if (value.size() >= 2 && ((value.front() == '"' && value.back() == '"') ||
268 (value.front() == '\'' && value.back() == '\''))) {
269 value = value.substr(1, value.size() - 2);
270 }
271 return TrimAscii(value);
272}
273
274std::string ExtractLinkedProjectRomName(const std::filesystem::path& path) {
275 std::ifstream input(path);
276 if (!input.is_open())
277 return "";
278
279 static constexpr std::array<const char*, 3> kKeys = {"rom_filename",
280 "rom_file", "rom_path"};
281 std::string line;
282 while (std::getline(input, line)) {
283 const std::string lowered = ToLowerAscii(line);
284 for (const char* key : kKeys) {
285 if (lowered.find(key) == std::string::npos)
286 continue;
287 const std::string value = ParseConfigValue(line);
288 if (!value.empty()) {
289 return std::filesystem::path(value).filename().string();
290 }
291 }
292 }
293 return "";
294}
295
296} // namespace
297
299 : scan_state_(std::make_shared<AsyncScanState>()) {}
300
301RecentProjectsModel::~RecentProjectsModel() {
302 // Flip cancel so any in-flight workers stop touching their result slot
303 // after the expensive I/O finishes. They still release their shared_ptr
304 // ref naturally; the state outlives the model only long enough to let
305 // them exit cleanly.
306 if (scan_state_)
307 scan_state_->cancelled.store(true);
308}
309
310// Build a display-ready entry for a single filepath. Populates all fields the
311// welcome card needs, and consults / updates the persisted cache so we skip
312// the full-ROM CRC32 hash and header parse whenever (size_bytes, mtime) still
313// match what we saw last time.
314RecentProject RecentProjectsModel::BuildEntry(const std::string& filepath) {
315 std::filesystem::path path(filepath);
316
317 RecentProject entry;
318 entry.filepath = filepath;
319 entry.name = path.filename().string();
320 if (entry.name.empty())
321 entry.name = filepath;
322 entry.item_type = "File";
323 entry.item_icon = ICON_MD_INSERT_DRIVE_FILE;
324 entry.rom_title = "Local file";
325
326 // Pull any persisted overrides. display_name_override takes precedence over
327 // the filename for display purposes; the raw filename stays in filepath.
328 auto cache_it = cache_.find(filepath);
329 if (cache_it != cache_.end()) {
330 entry.pinned = cache_it->second.pinned;
331 entry.display_name_override = cache_it->second.display_name_override;
332 entry.notes = cache_it->second.notes;
333 if (!entry.display_name_override.empty()) {
334 entry.name = entry.display_name_override;
335 }
336 }
337
338 // iOS note: `filesystem::exists` without an error_code may throw when the
339 // app lost security-scoped access to an iCloud/document-picker URL. Always
340 // use the error_code overload.
341 std::error_code exists_ec;
342 const bool exists = std::filesystem::exists(path, exists_ec);
343 if (exists_ec) {
344 entry.unavailable = true;
345 entry.last_modified = "Unavailable";
346 entry.item_type = "Unavailable";
347 entry.item_icon = ICON_MD_WARNING;
348 entry.rom_title = "Re-open required";
349 entry.metadata_summary = "Permission expired for this location";
350 return entry;
351 }
352 if (!exists) {
353 // Task #4: keep missing entries visible with a warning badge instead of
354 // silently dropping them. User decides whether to "Locate..." (triggers
355 // RelinkRecent) or "Forget" (RemoveRecent).
356 entry.is_missing = true;
357 entry.item_type = "Missing";
358 entry.item_icon = ICON_MD_WARNING;
359 entry.rom_title = "File not found";
360 entry.last_modified = "Missing";
361 entry.metadata_summary = "Choose Locate... to point at the new location";
362 return entry;
363 }
364
365 std::error_code time_ec;
366 auto ftime = std::filesystem::last_write_time(path, time_ec);
367 const std::int64_t mtime_ns =
368 time_ec ? 0
369 : std::chrono::duration_cast<std::chrono::nanoseconds>(
370 ftime.time_since_epoch())
371 .count();
372 if (!time_ec) {
373 entry.last_modified = GetRelativeTimeString(ftime);
374 entry.mtime_epoch_ns = mtime_ns;
375 } else {
376 entry.last_modified = "Unknown";
377 }
378
379 std::error_code size_ec;
380 const std::uintmax_t size_bytes = std::filesystem::file_size(path, size_ec);
381 entry.size_bytes = size_ec ? 0 : size_bytes;
382 const std::string size_text =
383 size_ec ? "Unknown size" : FormatFileSize(size_bytes);
384
385 // Cache hit: size and mtime both unchanged. Reuse expensive fields.
386 const bool cache_hit =
387 cache_it != cache_.end() &&
388 cache_it->second.size_bytes == entry.size_bytes &&
389 cache_it->second.mtime_epoch_ns == entry.mtime_epoch_ns;
390
391 std::string crc32;
392 SnesHeaderMetadata rom_metadata;
393 bool need_write_back = false;
394
395 if (IsRomPath(path)) {
396 entry.item_type = "ROM";
397 entry.item_icon = ICON_MD_MEMORY;
398
399 if (cache_hit) {
400 crc32 = cache_it->second.crc32;
401 rom_metadata.title = cache_it->second.snes_title;
402 rom_metadata.region = cache_it->second.snes_region;
403 rom_metadata.map_mode = cache_it->second.snes_map_mode;
404 rom_metadata.valid = !rom_metadata.title.empty();
405 } else {
406 // Cache miss: don't block the UI thread on a full-ROM CRC32 + header
407 // parse. Dispatch a detached worker; the result is drained on the next
408 // Refresh call. In the meantime the card shows a "Scanning…" summary.
409 DispatchBackgroundRomScan(filepath, entry.size_bytes,
410 entry.mtime_epoch_ns);
411 }
412
413 entry.crc32 = crc32;
414 const std::string crc32_summary =
415 crc32.empty() ? "Scanning…" : absl::StrFormat("CRC %s", crc32.c_str());
416 if (rom_metadata.valid && !rom_metadata.title.empty()) {
417 entry.rom_title = rom_metadata.title;
418 entry.snes_region = rom_metadata.region;
419 entry.snes_map_mode = rom_metadata.map_mode;
420 entry.metadata_summary =
421 absl::StrFormat("%s • %s • %s • %s", rom_metadata.region.c_str(),
422 rom_metadata.map_mode.c_str(), size_text.c_str(),
423 crc32_summary.c_str());
424 } else if (cache_hit) {
425 entry.rom_title = "SNES ROM";
426 entry.metadata_summary =
427 absl::StrFormat("%s • %s", size_text.c_str(), crc32_summary.c_str());
428 } else {
429 // Still waiting on the background worker. Show size immediately so the
430 // card has something other than a blank line.
431 entry.rom_title = "SNES ROM (scanning…)";
432 entry.metadata_summary =
433 absl::StrFormat("%s • Scanning…", size_text.c_str());
434 }
435 } else if (IsProjectPath(path)) {
436 entry.item_type = "Project";
437 entry.item_icon = ICON_MD_FOLDER_SPECIAL;
438
439 const std::string linked_rom = ExtractLinkedProjectRomName(path);
440 entry.rom_title = linked_rom.empty()
441 ? "Project metadata + settings"
442 : absl::StrFormat("ROM: %s", linked_rom.c_str());
443 entry.metadata_summary = absl::StrFormat("%s • %s", size_text.c_str(),
444 entry.last_modified.c_str());
445 } else {
446 entry.item_type = "File";
447 entry.item_icon = ICON_MD_INSERT_DRIVE_FILE;
448 entry.rom_title = "Imported file";
449 entry.metadata_summary = absl::StrFormat("%s • %s", size_text.c_str(),
450 entry.last_modified.c_str());
451 }
452
453 // Always keep the cache's (size, mtime) fresh; only write back the expensive
454 // fields for ROMs that we actually recomputed.
455 CachedExtras& cached = cache_[filepath];
456 if (cached.size_bytes != entry.size_bytes ||
457 cached.mtime_epoch_ns != entry.mtime_epoch_ns) {
458 cached.size_bytes = entry.size_bytes;
459 cached.mtime_epoch_ns = entry.mtime_epoch_ns;
460 cache_dirty_ = true;
461 }
462 if (need_write_back) {
463 cached.crc32 = crc32;
464 cached.snes_title = rom_metadata.title;
465 cached.snes_region = rom_metadata.region;
466 cached.snes_map_mode = rom_metadata.map_mode;
467 cache_dirty_ = true;
468 }
469
470 return entry;
471}
472
473void RecentProjectsModel::Refresh(bool force) {
474 // Lazy-load the sidecar cache on the first call. Cheap; small file, once
475 // per process. Done here instead of the constructor so PlatformPaths
476 // initialization errors don't crash global-ctor order.
477 if (!cache_loaded_) {
478 LoadCache();
479 cache_loaded_ = true;
480 }
481
482 // Fold in any background-scan results before we decide whether to rebuild.
483 // DrainAsyncResults bumps annotation_generation_ on hit, so the combined
484 // counter below catches it naturally.
485 DrainAsyncResults();
486
487 auto& manager = project::RecentFilesManager::GetInstance();
488
489 // The manager's counter covers add/remove/clear; our annotation counter
490 // covers pin/rename/notes mutations that the manager doesn't observe.
491 // Combining lets the fast path skip work only when *neither* has changed.
492 const std::uint64_t combined_generation =
493 manager.GetGeneration() + annotation_generation_;
494 if (!force && loaded_once_ && combined_generation == cached_generation_) {
495 return;
496 }
497 cached_generation_ = combined_generation;
498 loaded_once_ = true;
499
500 entries_.clear();
501
502 auto recent_files = manager.GetRecentFiles();
503
504 for (const auto& filepath : recent_files) {
505 if (entries_.size() >= kMaxRecentEntries)
506 break;
507 entries_.push_back(BuildEntry(filepath));
508 }
509
510 // Pinned entries float to the top; otherwise preserve RecentFilesManager
511 // order (most recent first). std::stable_sort keeps ties deterministic.
512 std::stable_sort(entries_.begin(), entries_.end(),
513 [](const RecentProject& a, const RecentProject& b) {
514 return a.pinned && !b.pinned;
515 });
516
517 if (cache_dirty_) {
518 SaveCache();
519 cache_dirty_ = false;
520 }
521}
522
523void RecentProjectsModel::AddRecent(const std::string& path) {
524 auto& manager = project::RecentFilesManager::GetInstance();
525 manager.AddFile(path);
526 manager.Save();
527}
528
529void RecentProjectsModel::RemoveRecent(const std::string& path) {
530 // Capture the display name + cached extras *before* we tear them down so
531 // the undo buffer has everything it needs to re-attach annotations on
532 // restore. Cheap: one hashmap lookup.
533 RemovedRecent pending;
534 pending.path = path;
535 if (auto it = cache_.find(path); it != cache_.end()) {
536 pending.extras = it->second;
537 if (!it->second.display_name_override.empty()) {
538 pending.display_name = it->second.display_name_override;
539 }
540 }
541 if (pending.display_name.empty()) {
542 std::error_code ec;
543 pending.display_name = std::filesystem::path(path).filename().string();
544 if (pending.display_name.empty())
545 pending.display_name = path;
546 }
547 pending.expires_at =
548 std::chrono::steady_clock::now() +
549 std::chrono::milliseconds(static_cast<int>(kUndoWindowSeconds * 1000.0f));
550
551 auto& manager = project::RecentFilesManager::GetInstance();
552 manager.RemoveFile(path);
553 manager.Save();
554 if (cache_.erase(path) > 0) {
555 cache_dirty_ = true;
556 SaveCache();
557 cache_dirty_ = false;
558 }
559
560 // Single-slot buffer: a newer removal displaces an older pending one. That
561 // matches how "Undo" UX tends to feel — rapid successive removals shouldn't
562 // pile up stacked toasts.
563 undo_buffer_.clear();
564 undo_buffer_.push_back(std::move(pending));
565}
566
567bool RecentProjectsModel::HasUndoableRemoval() const {
568 if (undo_buffer_.empty())
569 return false;
570 return std::chrono::steady_clock::now() < undo_buffer_.front().expires_at;
571}
572
573RecentProjectsModel::PendingUndo RecentProjectsModel::PeekLastRemoval() const {
574 if (!HasUndoableRemoval())
575 return {};
576 const auto& front = undo_buffer_.front();
577 return {front.path, front.display_name};
578}
579
580bool RecentProjectsModel::UndoLastRemoval() {
581 if (!HasUndoableRemoval()) {
582 undo_buffer_.clear();
583 return false;
584 }
585 RemovedRecent entry = std::move(undo_buffer_.front());
586 undo_buffer_.clear();
587
588 auto& manager = project::RecentFilesManager::GetInstance();
589 manager.AddFile(entry.path); // Lands at the front of the MRU list.
590 manager.Save();
591
592 // Only restore extras if they carry user annotations worth keeping; the
593 // size/mtime/CRC will be recomputed on next Refresh. Avoid writing an empty
594 // CachedExtras record for no reason.
595 const bool has_annotations = entry.extras.pinned ||
596 !entry.extras.display_name_override.empty() ||
597 !entry.extras.notes.empty();
598 if (has_annotations) {
599 cache_[entry.path] = std::move(entry.extras);
600 cache_dirty_ = true;
601 ++annotation_generation_;
602 SaveCache();
603 cache_dirty_ = false;
604 }
605 return true;
606}
607
608void RecentProjectsModel::DismissLastRemoval() {
609 undo_buffer_.clear();
610}
611
612void RecentProjectsModel::RelinkRecent(const std::string& old_path,
613 const std::string& new_path) {
614 if (old_path == new_path || new_path.empty())
615 return;
616
617 auto& manager = project::RecentFilesManager::GetInstance();
618 manager.RemoveFile(old_path);
619 manager.AddFile(new_path);
620 manager.Save();
621
622 // Carry over user annotations (pin/rename/notes). The size/mtime cache is
623 // path-agnostic but size/mtime on disk may differ if this is a moved copy,
624 // so we drop the CRC + header snapshot; Refresh() will re-hash on demand.
625 auto it = cache_.find(old_path);
626 if (it != cache_.end()) {
627 CachedExtras carried = std::move(it->second);
628 carried.size_bytes = 0;
629 carried.mtime_epoch_ns = 0;
630 carried.crc32.clear();
631 carried.snes_title.clear();
632 carried.snes_region.clear();
633 carried.snes_map_mode.clear();
634 cache_.erase(it);
635 cache_.emplace(new_path, std::move(carried));
636 cache_dirty_ = true;
637 SaveCache();
638 cache_dirty_ = false;
639 }
640}
641
642void RecentProjectsModel::ClearAll() {
643 auto& manager = project::RecentFilesManager::GetInstance();
644 manager.Clear();
645 manager.Save();
646 if (!cache_.empty()) {
647 cache_.clear();
648 SaveCache();
649 cache_dirty_ = false;
650 }
651}
652
653void RecentProjectsModel::SetPinned(const std::string& path, bool pinned) {
654 auto& extras = cache_[path];
655 if (extras.pinned == pinned)
656 return;
657 extras.pinned = pinned;
658 cache_dirty_ = true;
659 ++annotation_generation_;
660 SaveCache();
661 cache_dirty_ = false;
662}
663
664void RecentProjectsModel::SetDisplayName(const std::string& path,
665 std::string display_name) {
666 auto& extras = cache_[path];
667 if (extras.display_name_override == display_name)
668 return;
669 extras.display_name_override = std::move(display_name);
670 cache_dirty_ = true;
671 ++annotation_generation_;
672 SaveCache();
673 cache_dirty_ = false;
674}
675
676void RecentProjectsModel::SetNotes(const std::string& path, std::string notes) {
677 auto& extras = cache_[path];
678 if (extras.notes == notes)
679 return;
680 extras.notes = std::move(notes);
681 cache_dirty_ = true;
682 ++annotation_generation_;
683 SaveCache();
684 cache_dirty_ = false;
685}
686
687void RecentProjectsModel::DispatchBackgroundRomScan(
688 const std::string& filepath, std::uint64_t size_bytes,
689 std::int64_t mtime_epoch_ns) {
690 if (!scan_state_)
691 return;
692 {
693 std::lock_guard<std::mutex> lock(scan_state_->mu);
694 // De-dupe: a single in-flight worker per path is enough. The welcome
695 // screen calls Refresh every frame, so without this guard we'd kick off
696 // a new thread per frame until the first one finishes.
697 if (scan_state_->in_flight[filepath])
698 return;
699 scan_state_->in_flight[filepath] = true;
700 }
701
702 std::thread worker(
703 [state = scan_state_, filepath, size_bytes, mtime_epoch_ns]() {
704 AsyncScanResult result;
705 result.path = filepath;
706 result.size_bytes = size_bytes;
707 result.mtime_epoch_ns = mtime_epoch_ns;
708
709 // Check cancel at entry/exit boundaries. The cost of the work itself
710 // is dominated by the CRC32 read — if the model is destroyed mid-scan
711 // we simply drop the result on the floor.
712 if (state->cancelled.load())
713 return;
714
715 std::filesystem::path path(filepath);
716 SnesHeaderMetadata header = ReadSnesHeaderMetadata(path);
717 if (state->cancelled.load())
718 return;
719 std::string crc32 = ReadFileCrc32(path);
720 if (state->cancelled.load())
721 return;
722
723 result.crc32 = std::move(crc32);
724 if (header.valid) {
725 result.snes_title = std::move(header.title);
726 result.snes_region = std::move(header.region);
727 result.snes_map_mode = std::move(header.map_mode);
728 }
729
730 std::lock_guard<std::mutex> lock(state->mu);
731 state->in_flight.erase(filepath);
732 if (state->cancelled.load())
733 return;
734 state->ready.push_back(std::move(result));
735 });
736 worker.detach();
737}
738
739bool RecentProjectsModel::DrainAsyncResults() {
740 if (!scan_state_)
741 return false;
742 std::vector<AsyncScanResult> drained;
743 {
744 std::lock_guard<std::mutex> lock(scan_state_->mu);
745 if (scan_state_->ready.empty())
746 return false;
747 drained.swap(scan_state_->ready);
748 }
749
750 bool any_applied = false;
751 for (auto& r : drained) {
752 auto& extras = cache_[r.path];
753 // Only apply if the on-disk (size, mtime) the worker saw still matches
754 // what's in the cache. If the file has been written to since dispatch,
755 // a fresh scan will be kicked off on the next Refresh anyway.
756 if (extras.size_bytes != 0 && (extras.size_bytes != r.size_bytes ||
757 extras.mtime_epoch_ns != r.mtime_epoch_ns)) {
758 continue;
759 }
760 extras.size_bytes = r.size_bytes;
761 extras.mtime_epoch_ns = r.mtime_epoch_ns;
762 extras.crc32 = std::move(r.crc32);
763 extras.snes_title = std::move(r.snes_title);
764 extras.snes_region = std::move(r.snes_region);
765 extras.snes_map_mode = std::move(r.snes_map_mode);
766 cache_dirty_ = true;
767 any_applied = true;
768 }
769 if (any_applied) {
770 ++annotation_generation_; // Force the next Refresh to rebuild entries_.
771 SaveCache();
772 cache_dirty_ = false;
773 }
774 return any_applied;
775}
776
777std::filesystem::path RecentProjectsModel::CachePath() const {
778 auto config_dir = util::PlatformPaths::GetConfigDirectory();
779 if (!config_dir.ok())
780 return {};
781 return *config_dir / "recent_files_cache.json";
782}
783
784void RecentProjectsModel::LoadCache() {
785 const auto path = CachePath();
786 if (path.empty())
787 return;
788
789 std::ifstream input(path);
790 if (!input.is_open())
791 return; // First run — no cache yet.
792
793 try {
794 nlohmann::json root = nlohmann::json::parse(input, nullptr,
795 /*allow_exceptions=*/true,
796 /*ignore_comments=*/true);
797 if (!root.contains("entries") || !root["entries"].is_object())
798 return;
799
800 for (auto& [key, value] : root["entries"].items()) {
801 if (!value.is_object())
802 continue;
803 CachedExtras extras;
804 extras.size_bytes = value.value("size", std::uint64_t{0});
805 extras.mtime_epoch_ns = value.value("mtime", std::int64_t{0});
806 extras.crc32 = value.value("crc32", std::string{});
807 extras.snes_title = value.value("snes_title", std::string{});
808 extras.snes_region = value.value("snes_region", std::string{});
809 extras.snes_map_mode = value.value("snes_map_mode", std::string{});
810 extras.pinned = value.value("pinned", false);
811 extras.display_name_override =
812 value.value("display_name_override", std::string{});
813 extras.notes = value.value("notes", std::string{});
814 cache_.emplace(key, std::move(extras));
815 }
816 } catch (const std::exception& e) {
817 // A corrupted cache is a cold-start cost, not a correctness problem.
818 LOG_WARN("RecentProjectsModel",
819 "Failed to parse recent-files cache %s: %s — rebuilding.",
820 path.string().c_str(), e.what());
821 cache_.clear();
822 }
823}
824
825void RecentProjectsModel::SaveCache() {
826 const auto path = CachePath();
827 if (path.empty())
828 return;
829
830 nlohmann::json root;
831 root["version"] = 1;
832 nlohmann::json& entries = root["entries"] = nlohmann::json::object();
833 for (const auto& [key, extras] : cache_) {
834 entries[key] = {
835 {"size", extras.size_bytes},
836 {"mtime", extras.mtime_epoch_ns},
837 {"crc32", extras.crc32},
838 {"snes_title", extras.snes_title},
839 {"snes_region", extras.snes_region},
840 {"snes_map_mode", extras.snes_map_mode},
841 {"pinned", extras.pinned},
842 {"display_name_override", extras.display_name_override},
843 {"notes", extras.notes},
844 };
845 }
846
847 std::ofstream output(path);
848 if (!output.is_open()) {
849 LOG_WARN("RecentProjectsModel", "Could not write recent-files cache to %s",
850 path.string().c_str());
851 return;
852 }
853 output << root.dump(2);
854}
855
856} // namespace editor
857} // namespace yaze
#define ICON_MD_INSERT_DRIVE_FILE
Definition icons.h:999
#define ICON_MD_MEMORY
Definition icons.h:1195
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_FOLDER_SPECIAL
Definition icons.h:815
#define LOG_WARN(category, format,...)
Definition log.h:107
bool ReadFileBlock(std::ifstream *file, std::streamoff offset, char *out, std::size_t size)
SnesHeaderMetadata ReadSnesHeaderMetadata(const std::filesystem::path &path)
std::string ReadFileCrc32(const std::filesystem::path &path)
std::string GetRelativeTimeString(const std::filesystem::file_time_type &ftime)
std::string ExtractLinkedProjectRomName(const std::filesystem::path &path)
uint32_t CalculateCrc32(const uint8_t *data, size_t size)
Definition rom_hash.cc:62