13#include <system_error>
18#include "absl/strings/str_format.h"
21#include "nlohmann/json.hpp"
37 value.begin(), value.end(), value.begin(),
38 [](
unsigned char c) { return static_cast<char>(std::tolower(c)); });
43 const auto not_space = [](
unsigned char c) {
44 return !std::isspace(c);
46 auto begin = std::find_if(value.begin(), value.end(), not_space);
47 if (begin == value.end())
49 auto end = std::find_if(value.rbegin(), value.rend(), not_space).base();
50 return std::string(begin, end);
54 const std::string ext =
ToLowerAscii(path.extension().string());
55 return ext ==
".sfc" || ext ==
".smc";
59 const std::string ext =
ToLowerAscii(path.extension().string());
60 return ext ==
".yaze" || ext ==
".yazeproj" || ext ==
".zsproj";
64 static constexpr std::array<const char*, 4> kUnits = {
"B",
"KB",
"MB",
"GB"};
65 double value =
static_cast<double>(bytes);
67 while (value >= 1024.0 && unit + 1 < kUnits.size()) {
72 return absl::StrFormat(
"%llu %s",
static_cast<unsigned long long>(bytes),
75 return absl::StrFormat(
"%.1f %s", value, kUnits[unit]);
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);
86 int hours = diff.count();
92 int days = hours / 24;
93 return absl::StrFormat(
"%d days ago", days);
96 int weeks = hours / 168;
97 return absl::StrFormat(
"%d week%s ago", weeks, weeks > 1 ?
"s" :
"");
99 int months = hours / 720;
100 return absl::StrFormat(
"%d month%s ago", months, months > 1 ?
"s" :
"");
116 return "Netherlands";
128 return "Unknown region";
133 switch (code & 0x3F) {
147 return absl::StrFormat(
"Mode %02X", code);
163 file->seekg(offset, std::ios::beg);
166 file->read(out,
static_cast<std::streamsize
>(size));
167 return file->good() && file->gcount() ==
static_cast<std::streamsize
>(size);
174 for (
unsigned char c : title) {
175 if (c >= 32 && c <= 126)
178 return printable >= std::max(6,
static_cast<int>(title.size()) / 2);
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)
187 std::ifstream input(path, std::ios::binary);
188 if (!input.is_open())
191 static constexpr std::array<std::streamoff, 2> kHeaderBases = {0x7FC0,
193 static constexpr std::array<std::streamoff, 2> kHeaderBiases = {0, 512};
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)
201 char header[0x20] = {};
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)
211 const std::string title =
TrimAscii(raw_title);
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]);
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())) {
235 std::ifstream input(path, std::ios::binary);
236 if (!input.is_open())
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()))
245 return absl::StrFormat(
"%08X",
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())
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);
264 if (!value.empty() && value.back() ==
',')
267 if (value.size() >= 2 && ((value.front() ==
'"' && value.back() ==
'"') ||
268 (value.front() ==
'\'' && value.back() ==
'\''))) {
269 value = value.substr(1, value.size() - 2);
275 std::ifstream input(path);
276 if (!input.is_open())
279 static constexpr std::array<const char*, 3> kKeys = {
"rom_filename",
280 "rom_file",
"rom_path"};
282 while (std::getline(input, line)) {
284 for (
const char* key : kKeys) {
285 if (lowered.find(key) == std::string::npos)
288 if (!value.empty()) {
289 return std::filesystem::path(value).filename().string();
315 std::filesystem::path path(filepath);
319 entry.
name = path.filename().string();
320 if (entry.
name.empty())
321 entry.
name = filepath;
328 auto cache_it =
cache_.find(filepath);
329 if (cache_it !=
cache_.end()) {
330 entry.
pinned = cache_it->second.pinned;
332 entry.
notes = cache_it->second.notes;
341 std::error_code exists_ec;
342 const bool exists = std::filesystem::exists(path, exists_ec);
365 std::error_code time_ec;
366 auto ftime = std::filesystem::last_write_time(path, time_ec);
367 const std::int64_t mtime_ns =
369 : std::chrono::duration_cast<std::chrono::nanoseconds>(
370 ftime.time_since_epoch())
379 std::error_code size_ec;
380 const std::uintmax_t size_bytes = std::filesystem::file_size(path, size_ec);
382 const std::string size_text =
383 size_ec ?
"Unknown size" : FormatFileSize(size_bytes);
386 const bool cache_hit =
387 cache_it !=
cache_.end() &&
388 cache_it->second.size_bytes == entry.
size_bytes &&
392 SnesHeaderMetadata rom_metadata;
393 bool need_write_back =
false;
395 if (IsRomPath(path)) {
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();
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()) {
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) {
427 absl::StrFormat(
"%s • %s", size_text.c_str(), crc32_summary.c_str());
431 entry.
rom_title =
"SNES ROM (scanning…)";
433 absl::StrFormat(
"%s • Scanning…", size_text.c_str());
435 }
else if (IsProjectPath(path)) {
439 const std::string linked_rom = ExtractLinkedProjectRomName(path);
441 ?
"Project metadata + settings"
442 : absl::StrFormat(
"ROM: %s", linked_rom.c_str());
462 if (need_write_back) {
463 cached.
crc32 = crc32;
492 const std::uint64_t combined_generation =
502 auto recent_files = manager.GetRecentFiles();
504 for (
const auto& filepath : recent_files) {
505 if (
entries_.size() >= kMaxRecentEntries)
514 return a.pinned && !b.pinned;
525 manager.AddFile(path);
536 pending.
extras = it->second;
537 if (!it->second.display_name_override.empty()) {
538 pending.
display_name = it->second.display_name_override;
543 pending.
display_name = std::filesystem::path(path).filename().string();
548 std::chrono::steady_clock::now() +
552 manager.RemoveFile(path);
554 if (
cache_.erase(path) > 0) {
570 return std::chrono::steady_clock::now() <
undo_buffer_.front().expires_at;
577 return {front.
path, front.display_name};
589 manager.AddFile(entry.
path);
598 if (has_annotations) {
613 const std::string& new_path) {
614 if (old_path == new_path || new_path.empty())
618 manager.RemoveFile(old_path);
619 manager.AddFile(new_path);
625 auto it =
cache_.find(old_path);
630 carried.
crc32.clear();
635 cache_.emplace(new_path, std::move(carried));
654 auto& extras =
cache_[path];
655 if (extras.pinned == pinned)
657 extras.pinned = pinned;
665 std::string display_name) {
666 auto& extras =
cache_[path];
667 if (extras.display_name_override == display_name)
669 extras.display_name_override = std::move(display_name);
677 auto& extras =
cache_[path];
678 if (extras.notes == notes)
680 extras.notes = std::move(notes);
688 const std::string& filepath, std::uint64_t size_bytes,
689 std::int64_t mtime_epoch_ns) {
703 [state =
scan_state_, filepath, size_bytes, mtime_epoch_ns]() {
705 result.
path = filepath;
712 if (state->cancelled.load())
715 std::filesystem::path path(filepath);
716 SnesHeaderMetadata header = ReadSnesHeaderMetadata(path);
717 if (state->cancelled.load())
719 std::string crc32 = ReadFileCrc32(path);
720 if (state->cancelled.load())
723 result.
crc32 = std::move(crc32);
730 std::lock_guard<std::mutex> lock(state->mu);
731 state->in_flight.erase(filepath);
732 if (state->cancelled.load())
734 state->ready.push_back(std::move(result));
742 std::vector<AsyncScanResult> drained;
750 bool any_applied =
false;
751 for (
auto& r : drained) {
752 auto& extras =
cache_[r.path];
756 if (extras.size_bytes != 0 && (extras.size_bytes != r.size_bytes ||
757 extras.mtime_epoch_ns != r.mtime_epoch_ns)) {
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);
779 if (!config_dir.ok())
781 return *config_dir /
"recent_files_cache.json";
789 std::ifstream input(path);
790 if (!input.is_open())
794 nlohmann::json root = nlohmann::json::parse(input,
nullptr,
797 if (!root.contains(
"entries") || !root[
"entries"].is_object())
800 for (
auto& [key, value] : root[
"entries"].items()) {
801 if (!value.is_object())
804 extras.
size_bytes = value.value(
"size", std::uint64_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);
812 value.value(
"display_name_override", std::string{});
813 extras.
notes = value.value(
"notes", std::string{});
814 cache_.emplace(key, std::move(extras));
816 }
catch (
const std::exception& e) {
819 "Failed to parse recent-files cache %s: %s — rebuilding.",
820 path.string().c_str(), e.what());
832 nlohmann::json&
entries = root[
"entries"] = nlohmann::json::object();
833 for (
const auto& [key, extras] :
cache_) {
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},
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());
853 output << root.dump(2);
std::unordered_map< std::string, CachedExtras > cache_
void SetPinned(const std::string &path, bool pinned)
PendingUndo PeekLastRemoval() const
std::filesystem::path CachePath() const
std::uint64_t annotation_generation_
std::uint64_t cached_generation_
std::vector< RecentProject > entries_
bool HasUndoableRemoval() const
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 Refresh(bool force=false)
void DismissLastRemoval()
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 constexpr float kUndoWindowSeconds
static RecentFilesManager & GetInstance()
#define ICON_MD_INSERT_DRIVE_FILE
#define ICON_MD_FOLDER_SPECIAL
#define LOG_WARN(category, format,...)
bool IsProjectPath(const std::filesystem::path &path)
bool ReadFileBlock(std::ifstream *file, std::streamoff offset, char *out, std::size_t size)
std::string ToLowerAscii(std::string value)
SnesHeaderMetadata ReadSnesHeaderMetadata(const std::filesystem::path &path)
std::string ReadFileCrc32(const std::filesystem::path &path)
std::string TrimAscii(const std::string &value)
std::string GetRelativeTimeString(const std::filesystem::file_time_type &ftime)
std::string FormatFileSize(std::uintmax_t bytes)
std::string ParseConfigValue(const std::string &line)
bool LooksLikeSnesTitle(const std::string &title)
std::string DecodeSnesMapMode(std::uint8_t code)
std::string ExtractLinkedProjectRomName(const std::filesystem::path &path)
bool IsRomPath(const std::filesystem::path &path)
constexpr std::size_t kMaxRecentEntries
std::string DecodeSnesRegion(std::uint8_t code)
uint32_t CalculateCrc32(const uint8_t *data, size_t size)
std::string last_modified
std::int64_t mtime_epoch_ns
std::string snes_map_mode
std::string display_name_override
std::string metadata_summary
std::int64_t mtime_epoch_ns
std::string snes_map_mode
std::chrono::steady_clock::time_point expires_at