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
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
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
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";
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";
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";
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";
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.
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";
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";
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
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.
486
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 RecentProject entry = BuildEntry(filepath);
508 entry.recent_index = entries_.size();
509 entries_.push_back(std::move(entry));
510 }
511
512 // Pinned entries float to the top; otherwise preserve RecentFilesManager
513 // order (most recent first). std::stable_sort keeps ties deterministic.
514 std::stable_sort(entries_.begin(), entries_.end(),
515 [](const RecentProject& a, const RecentProject& b) {
516 return a.pinned && !b.pinned;
517 });
518
519 if (cache_dirty_) {
520 SaveCache();
521 cache_dirty_ = false;
522 }
523}
524
525void RecentProjectsModel::AddRecent(const std::string& path) {
527 manager.AddFile(path);
528 manager.Save();
529}
530
531void RecentProjectsModel::RemoveRecent(const std::string& path) {
532 // Capture the display name + cached extras *before* we tear them down so
533 // the undo buffer has everything it needs to re-attach annotations on
534 // restore. Cheap: one hashmap lookup.
535 RemovedRecent pending;
536 pending.path = path;
537 if (auto it = cache_.find(path); it != cache_.end()) {
538 pending.extras = it->second;
539 if (!it->second.display_name_override.empty()) {
540 pending.display_name = it->second.display_name_override;
541 }
542 }
543 if (pending.display_name.empty()) {
544 std::error_code ec;
545 pending.display_name = std::filesystem::path(path).filename().string();
546 if (pending.display_name.empty())
547 pending.display_name = path;
548 }
549 pending.expires_at =
550 std::chrono::steady_clock::now() +
551 std::chrono::milliseconds(static_cast<int>(kUndoWindowSeconds * 1000.0f));
552
554 manager.RemoveFile(path);
555 manager.Save();
556 if (cache_.erase(path) > 0) {
557 cache_dirty_ = true;
558 SaveCache();
559 cache_dirty_ = false;
560 }
561
562 // Single-slot buffer: a newer removal displaces an older pending one. That
563 // matches how "Undo" UX tends to feel — rapid successive removals shouldn't
564 // pile up stacked toasts.
565 undo_buffer_.clear();
566 undo_buffer_.push_back(std::move(pending));
567}
568
570 if (undo_buffer_.empty())
571 return false;
572 return std::chrono::steady_clock::now() < undo_buffer_.front().expires_at;
573}
574
576 if (!HasUndoableRemoval())
577 return {};
578 const auto& front = undo_buffer_.front();
579 return {front.path, front.display_name};
580}
581
583 if (!HasUndoableRemoval()) {
584 undo_buffer_.clear();
585 return false;
586 }
587 RemovedRecent entry = std::move(undo_buffer_.front());
588 undo_buffer_.clear();
589
591 manager.AddFile(entry.path); // Lands at the front of the MRU list.
592 manager.Save();
593
594 // Only restore extras if they carry user annotations worth keeping; the
595 // size/mtime/CRC will be recomputed on next Refresh. Avoid writing an empty
596 // CachedExtras record for no reason.
597 const bool has_annotations = entry.extras.pinned ||
598 !entry.extras.display_name_override.empty() ||
599 !entry.extras.notes.empty();
600 if (has_annotations) {
601 cache_[entry.path] = std::move(entry.extras);
602 cache_dirty_ = true;
604 SaveCache();
605 cache_dirty_ = false;
606 }
607 return true;
608}
609
613
614void RecentProjectsModel::RelinkRecent(const std::string& old_path,
615 const std::string& new_path) {
616 if (old_path == new_path || new_path.empty())
617 return;
618
620 manager.RemoveFile(old_path);
621 manager.AddFile(new_path);
622 manager.Save();
623
624 // Carry over user annotations (pin/rename/notes). The size/mtime cache is
625 // path-agnostic but size/mtime on disk may differ if this is a moved copy,
626 // so we drop the CRC + header snapshot; Refresh() will re-hash on demand.
627 auto it = cache_.find(old_path);
628 if (it != cache_.end()) {
629 CachedExtras carried = std::move(it->second);
630 carried.size_bytes = 0;
631 carried.mtime_epoch_ns = 0;
632 carried.crc32.clear();
633 carried.snes_title.clear();
634 carried.snes_region.clear();
635 carried.snes_map_mode.clear();
636 cache_.erase(it);
637 cache_.emplace(new_path, std::move(carried));
638 cache_dirty_ = true;
639 SaveCache();
640 cache_dirty_ = false;
641 }
642}
643
646 manager.Clear();
647 manager.Save();
648 if (!cache_.empty()) {
649 cache_.clear();
650 SaveCache();
651 cache_dirty_ = false;
652 }
653}
654
655void RecentProjectsModel::SetPinned(const std::string& path, bool pinned) {
656 auto& extras = cache_[path];
657 if (extras.pinned == pinned)
658 return;
659 extras.pinned = pinned;
660 cache_dirty_ = true;
662 SaveCache();
663 cache_dirty_ = false;
664}
665
666void RecentProjectsModel::SetDisplayName(const std::string& path,
667 std::string display_name) {
668 auto& extras = cache_[path];
669 if (extras.display_name_override == display_name)
670 return;
671 extras.display_name_override = std::move(display_name);
672 cache_dirty_ = true;
674 SaveCache();
675 cache_dirty_ = false;
676}
677
678void RecentProjectsModel::SetNotes(const std::string& path, std::string notes) {
679 auto& extras = cache_[path];
680 if (extras.notes == notes)
681 return;
682 extras.notes = std::move(notes);
683 cache_dirty_ = true;
685 SaveCache();
686 cache_dirty_ = false;
687}
688
690 const std::string& filepath, std::uint64_t size_bytes,
691 std::int64_t mtime_epoch_ns) {
692 if (!scan_state_)
693 return;
694 {
695 std::lock_guard<std::mutex> lock(scan_state_->mu);
696 // De-dupe: a single in-flight worker per path is enough. The welcome
697 // screen calls Refresh every frame, so without this guard we'd kick off
698 // a new thread per frame until the first one finishes.
699 if (scan_state_->in_flight[filepath])
700 return;
701 scan_state_->in_flight[filepath] = true;
702 }
703
704 std::thread worker(
705 [state = scan_state_, filepath, size_bytes, mtime_epoch_ns]() {
706 AsyncScanResult result;
707 result.path = filepath;
708 result.size_bytes = size_bytes;
709 result.mtime_epoch_ns = mtime_epoch_ns;
710
711 // Check cancel at entry/exit boundaries. The cost of the work itself
712 // is dominated by the CRC32 read — if the model is destroyed mid-scan
713 // we simply drop the result on the floor.
714 if (state->cancelled.load())
715 return;
716
717 std::filesystem::path path(filepath);
718 SnesHeaderMetadata header = ReadSnesHeaderMetadata(path);
719 if (state->cancelled.load())
720 return;
721 std::string crc32 = ReadFileCrc32(path);
722 if (state->cancelled.load())
723 return;
724
725 result.crc32 = std::move(crc32);
726 if (header.valid) {
727 result.snes_title = std::move(header.title);
728 result.snes_region = std::move(header.region);
729 result.snes_map_mode = std::move(header.map_mode);
730 }
731
732 std::lock_guard<std::mutex> lock(state->mu);
733 state->in_flight.erase(filepath);
734 if (state->cancelled.load())
735 return;
736 state->ready.push_back(std::move(result));
737 });
738 worker.detach();
739}
740
742 if (!scan_state_)
743 return false;
744 std::vector<AsyncScanResult> drained;
745 {
746 std::lock_guard<std::mutex> lock(scan_state_->mu);
747 if (scan_state_->ready.empty())
748 return false;
749 drained.swap(scan_state_->ready);
750 }
751
752 bool any_applied = false;
753 for (auto& r : drained) {
754 auto& extras = cache_[r.path];
755 // Only apply if the on-disk (size, mtime) the worker saw still matches
756 // what's in the cache. If the file has been written to since dispatch,
757 // a fresh scan will be kicked off on the next Refresh anyway.
758 if (extras.size_bytes != 0 && (extras.size_bytes != r.size_bytes ||
759 extras.mtime_epoch_ns != r.mtime_epoch_ns)) {
760 continue;
761 }
762 extras.size_bytes = r.size_bytes;
763 extras.mtime_epoch_ns = r.mtime_epoch_ns;
764 extras.crc32 = std::move(r.crc32);
765 extras.snes_title = std::move(r.snes_title);
766 extras.snes_region = std::move(r.snes_region);
767 extras.snes_map_mode = std::move(r.snes_map_mode);
768 cache_dirty_ = true;
769 any_applied = true;
770 }
771 if (any_applied) {
772 ++annotation_generation_; // Force the next Refresh to rebuild entries_.
773 SaveCache();
774 cache_dirty_ = false;
775 }
776 return any_applied;
777}
778
779std::filesystem::path RecentProjectsModel::CachePath() const {
780 auto config_dir = util::PlatformPaths::GetConfigDirectory();
781 if (!config_dir.ok())
782 return {};
783 return *config_dir / "recent_files_cache.json";
784}
785
787 const auto path = CachePath();
788 if (path.empty())
789 return;
790
791 std::ifstream input(path);
792 if (!input.is_open())
793 return; // First run — no cache yet.
794
795 try {
796 nlohmann::json root = nlohmann::json::parse(input, nullptr,
797 /*allow_exceptions=*/true,
798 /*ignore_comments=*/true);
799 if (!root.contains("entries") || !root["entries"].is_object())
800 return;
801
802 for (auto& [key, value] : root["entries"].items()) {
803 if (!value.is_object())
804 continue;
805 CachedExtras extras;
806 extras.size_bytes = value.value("size", std::uint64_t{0});
807 extras.mtime_epoch_ns = value.value("mtime", std::int64_t{0});
808 extras.crc32 = value.value("crc32", std::string{});
809 extras.snes_title = value.value("snes_title", std::string{});
810 extras.snes_region = value.value("snes_region", std::string{});
811 extras.snes_map_mode = value.value("snes_map_mode", std::string{});
812 extras.pinned = value.value("pinned", false);
813 extras.display_name_override =
814 value.value("display_name_override", std::string{});
815 extras.notes = value.value("notes", std::string{});
816 cache_.emplace(key, std::move(extras));
817 }
818 } catch (const std::exception& e) {
819 // A corrupted cache is a cold-start cost, not a correctness problem.
820 LOG_WARN("RecentProjectsModel",
821 "Failed to parse recent-files cache %s: %s — rebuilding.",
822 path.string().c_str(), e.what());
823 cache_.clear();
824 }
825}
826
828 const auto path = CachePath();
829 if (path.empty())
830 return;
831
832 nlohmann::json root;
833 root["version"] = 1;
834 nlohmann::json& entries = root["entries"] = nlohmann::json::object();
835 for (const auto& [key, extras] : cache_) {
836 entries[key] = {
837 {"size", extras.size_bytes},
838 {"mtime", extras.mtime_epoch_ns},
839 {"crc32", extras.crc32},
840 {"snes_title", extras.snes_title},
841 {"snes_region", extras.snes_region},
842 {"snes_map_mode", extras.snes_map_mode},
843 {"pinned", extras.pinned},
844 {"display_name_override", extras.display_name_override},
845 {"notes", extras.notes},
846 };
847 }
848
849 std::ofstream output(path);
850 if (!output.is_open()) {
851 LOG_WARN("RecentProjectsModel", "Could not write recent-files cache to %s",
852 path.string().c_str());
853 return;
854 }
855 output << root.dump(2);
856}
857
858} // namespace editor
859} // namespace yaze
std::unordered_map< std::string, CachedExtras > cache_
void SetPinned(const std::string &path, bool pinned)
std::filesystem::path CachePath() const
std::vector< RecentProject > entries_
void SetDisplayName(const std::string &path, std::string display_name)
void DispatchBackgroundRomScan(const std::string &filepath, std::uint64_t size_bytes, std::int64_t mtime_epoch_ns)
void SetNotes(const std::string &path, std::string notes)
void RelinkRecent(const std::string &old_path, const std::string &new_path)
void RemoveRecent(const std::string &path)
std::deque< RemovedRecent > undo_buffer_
const std::vector< RecentProject > & entries() const
std::shared_ptr< AsyncScanState > scan_state_
RecentProject BuildEntry(const std::string &filepath)
void AddRecent(const std::string &path)
static RecentFilesManager & GetInstance()
Definition project.h:441
static absl::StatusOr< std::filesystem::path > GetConfigDirectory()
Get the user-specific configuration directory for 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:108
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
std::chrono::steady_clock::time_point expires_at