From 105c17f68d80c54b51419bcba1084846ad92c00b Mon Sep 17 00:00:00 2001 From: Jeremy Lawrence <5951348+jlawrence6809@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:44:00 -0700 Subject: [PATCH] feat: add opt-in read-only file browser --- .github/workflows/build-linux.yml | 2 + CMakeLists.txt | 21 ++ Includes/directoryReader.hpp | 51 ++++ Includes/fileBrowser.hpp | 41 +++ Includes/fileBrowserMenu.hpp | 20 ++ Includes/fileBrowserModel.hpp | 73 +++++ Includes/textLayout.hpp | 13 + Makefile | 5 + README.md | 22 +- Sources/directoryReader.cpp | 143 ++++++++++ Sources/fileBrowser.cpp | 132 +++++++++ Sources/fileBrowserMenu.cpp | 16 ++ Sources/fileBrowserModel.cpp | 458 ++++++++++++++++++++++++++++++ Sources/menu.cpp | 13 + Sources/textLayout.cpp | 91 ++++++ Tests/fileBrowserModelTests.cpp | 371 ++++++++++++++++++++++++ 16 files changed, 1471 insertions(+), 1 deletion(-) create mode 100644 Includes/directoryReader.hpp create mode 100644 Includes/fileBrowser.hpp create mode 100644 Includes/fileBrowserMenu.hpp create mode 100644 Includes/fileBrowserModel.hpp create mode 100644 Includes/textLayout.hpp create mode 100644 Sources/directoryReader.cpp create mode 100644 Sources/fileBrowser.cpp create mode 100644 Sources/fileBrowserMenu.cpp create mode 100644 Sources/fileBrowserModel.cpp create mode 100644 Sources/textLayout.cpp create mode 100644 Tests/fileBrowserModelTests.cpp diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 68a1d6b..e7918be 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -30,3 +30,5 @@ jobs: cmake -S . -B build export MAKEFLAGS=-j$(nproc) cmake --build build --verbose + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a8da0e..430f1d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,10 @@ add_executable(${PROJECT_NAME} main.cpp Sources/audioMenu.cpp Sources/config.cpp + Sources/directoryReader.cpp + Sources/fileBrowser.cpp + Sources/fileBrowserMenu.cpp + Sources/fileBrowserModel.cpp Sources/font.cpp Sources/ftpConnection.cpp Sources/ftpServer.cpp @@ -35,6 +39,7 @@ add_executable(${PROJECT_NAME} Sources/sntpClient.cpp Sources/subAppRouter.cpp Sources/subsystems.cpp + Sources/textLayout.cpp Sources/timing.cpp Sources/timeMenu.cpp Sources/videoMenu.cpp @@ -58,5 +63,21 @@ else() target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -pedantic) endif() +include(CTest) + +if(BUILD_TESTING) + add_executable(FileBrowserModelTests + Tests/fileBrowserModelTests.cpp + Sources/directoryReader.cpp + Sources/fileBrowserModel.cpp + Sources/textLayout.cpp) + target_include_directories(FileBrowserModelTests PRIVATE + ${CMAKE_SOURCE_DIR} + ${PROJECT_SOURCE_DIR}/Includes) + set_property(TARGET FileBrowserModelTests PROPERTY CXX_STANDARD 11) + set_property(TARGET FileBrowserModelTests PROPERTY CXX_STANDARD_REQUIRED ON) + add_test(NAME FileBrowserModelTests COMMAND FileBrowserModelTests) +endif() + #set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS_DEBUG} -fno-omit-frame-pointer -fsanitize=address") #set (CMAKE_LINKER_FLAGS "${CMAKE_LINKER_FLAGS_DEBUG} -fno-omit-frame-pointer -fsanitize=address") diff --git a/Includes/directoryReader.hpp b/Includes/directoryReader.hpp new file mode 100644 index 0000000..57ba6bf --- /dev/null +++ b/Includes/directoryReader.hpp @@ -0,0 +1,51 @@ +#ifndef NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ +#define NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ + +#include +#include +#include +#include +#include + +enum class DirectoryEntryKind +{ + DIRECTORY, + FILE, + OTHER +}; + +struct DirectoryEntry { + DirectoryEntry() = default; + DirectoryEntry(std::string entryName, + DirectoryEntryKind entryKind, + uint64_t entrySize = 0) : + name(std::move(entryName)), kind(entryKind), size(entrySize) {} + + std::string name; + DirectoryEntryKind kind{ DirectoryEntryKind::OTHER }; + uint64_t size{ 0 }; +}; + +struct DirectoryReadResult { + bool succeeded{ false }; + std::vector entries; + std::string error; +}; + +class DirectoryReader { +public: + virtual ~DirectoryReader() = default; + + virtual DirectoryReadResult read(const std::string& path) const = 0; + virtual char separator() const = 0; + virtual size_t maxPathLength() const = 0; +}; + +class PlatformDirectoryReader : public DirectoryReader { +public: + DirectoryReadResult read(const std::string& path) const override; + char separator() const override; + size_t maxPathLength() const override; +}; + +#endif // NEVOLUTIONX_INCLUDES_DIRECTORYREADER_HPP_ diff --git a/Includes/fileBrowser.hpp b/Includes/fileBrowser.hpp new file mode 100644 index 0000000..11e6d34 --- /dev/null +++ b/Includes/fileBrowser.hpp @@ -0,0 +1,41 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ + +#include +#include +#include +#include "fileBrowserModel.hpp" +#include "renderer.hpp" +#include "subApp.hpp" + +class FileBrowser : public SubApp { +public: + FileBrowser(Renderer& renderer, const std::vector& roots); + + void render(Font& font) override; + + void onUpPressed() override; + void onDownPressed() override; + void onLeftPressed() override; + void onRightPressed() override; + void onAPressed() override; + void onBPressed() override; + void onBackPressed() override; + void onXPressed() override; + + void onLeftStickDigitalUpPressed() override { onUpPressed(); } + void onLeftStickDigitalDownPressed() override { onDownPressed(); } + void onLeftStickDigitalLeftPressed() override { onLeftPressed(); } + void onLeftStickDigitalRightPressed() override { onRightPressed(); } + +private: + void navigateBack(); + std::string displayLabel(const DirectoryEntry& entry) const; + + Renderer& renderer; + std::shared_ptr reader; + FileBrowserModel model; + size_t visibleRows{ 1 }; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSER_HPP_ diff --git a/Includes/fileBrowserMenu.hpp b/Includes/fileBrowserMenu.hpp new file mode 100644 index 0000000..4e8ce96 --- /dev/null +++ b/Includes/fileBrowserMenu.hpp @@ -0,0 +1,20 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ + +#include +#include +#include "menu.hpp" + +class FileBrowserMenu : public MenuItem { +public: + FileBrowserMenu(MenuNode* parent, + const std::string& label, + std::vector roots); + + void execute(Menu* menu) override; + +private: + std::vector roots; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSERMENU_HPP_ diff --git a/Includes/fileBrowserModel.hpp b/Includes/fileBrowserModel.hpp new file mode 100644 index 0000000..8f98208 --- /dev/null +++ b/Includes/fileBrowserModel.hpp @@ -0,0 +1,73 @@ +#ifndef NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ +#define NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ + +#include +#include +#include +#include "directoryReader.hpp" + +class FileBrowserModel { +public: + enum class ActivateResult + { + NONE, + ENTERED_DIRECTORY, + FILE_SELECTED, + FAILED + }; + + FileBrowserModel(std::vector configuredRoots, + std::shared_ptr reader); + + const std::vector& getEntries() const { return entries; } + size_t getSelected() const { return selected; } + const std::string& getStatus() const { return status; } + std::string getLocationLabel() const; + bool isAtVirtualRoot() const { return atVirtualRoot; } + + void moveSelection(int delta, bool allowWrap = true); + void pageSelection(int delta); + ActivateResult activateSelected(); + bool navigateBack(); + bool refresh(); + void setStatus(const std::string& newStatus) { status = newStatus; } + + static bool isSafeChildName(const std::string& name); + static bool entryLess(const DirectoryEntry& lhs, const DirectoryEntry& rhs); + +private: + struct SelectionIdentity { + bool valid{ false }; + std::string name; + DirectoryEntryKind kind{ DirectoryEntryKind::OTHER }; + }; + + struct HistoryFrame { + bool virtualRoot{ true }; + size_t rootIndex{ 0 }; + std::vector components; + SelectionIdentity selection; + }; + + static std::string trim(const std::string& value); + static bool equalsIgnoreCase(const std::string& lhs, const std::string& rhs); + std::vector normalizeRoots(const std::vector& roots) const; + bool normalizeRoot(const std::string& input, std::string& output) const; + std::string buildCurrentPath() const; + bool loadCurrentDirectory(bool preserveOldListing); + void showVirtualRoot(); + SelectionIdentity selectedIdentity() const; + void restoreSelection(const SelectionIdentity& identity); + + std::shared_ptr reader; + std::vector roots; + std::vector entries; + std::vector components; + std::vector history; + size_t rootIndex{ 0 }; + size_t selected{ 0 }; + bool atVirtualRoot{ true }; + std::string status; +}; + +#endif // NEVOLUTIONX_INCLUDES_FILEBROWSERMODEL_HPP_ diff --git a/Includes/textLayout.hpp b/Includes/textLayout.hpp new file mode 100644 index 0000000..7832fef --- /dev/null +++ b/Includes/textLayout.hpp @@ -0,0 +1,13 @@ +#ifndef NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ +#define NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ + +#include +#include + +std::string sanitizeUtf8ForDisplay(const std::string& text); + +std::string ellipsizeText(const std::string& text, + float maximumWidth, + const std::function& measure); + +#endif // NEVOLUTIONX_INCLUDES_TEXTLAYOUT_HPP_ diff --git a/Makefile b/Makefile index 19443d9..c6d72fa 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,10 @@ SRCS += \ $(SRCDIR)/audioMenu.cpp \ $(SRCDIR)/config.cpp \ $(SRCDIR)/font.cpp \ + $(SRCDIR)/directoryReader.cpp \ + $(SRCDIR)/fileBrowser.cpp \ + $(SRCDIR)/fileBrowserMenu.cpp \ + $(SRCDIR)/fileBrowserModel.cpp \ $(SRCDIR)/ftpConnection.cpp \ $(SRCDIR)/ftpServer.cpp \ $(SRCDIR)/infoLog.cpp \ @@ -24,6 +28,7 @@ SRCS += \ $(SRCDIR)/subsystems.cpp \ $(SRCDIR)/timeMenu.cpp \ $(SRCDIR)/timing.cpp \ + $(SRCDIR)/textLayout.cpp \ $(SRCDIR)/videoMenu.cpp \ $(SRCDIR)/wipeCache.cpp \ $(SRCDIR)/xbeLauncher.cpp \ diff --git a/README.md b/README.md index 7f18947..b20cd94 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,28 @@ As the XboxDev community grew, the need for an open-source, nxdk based dashboard - [ ] TLS - [x] Application launcher - [x] DVD launcher +- [x] Read-only file browser - [ ] Themes +### File browser + +The file browser is opt-in. Add a `file_browser` entry to the `menu` array in +`config.json` and explicitly list the roots it may expose: + +```json +{ + "label": "File browser", + "type": "file_browser", + "roots": ["C:\\", "E:\\", "F:\\", "G:\\"] +} +``` + +The configured roots are a safety boundary; cache and dashboard-private drives +are not discovered automatically. The browser can enter directories and view +files, but it cannot launch, copy, rename, or delete anything. Use D-pad +up/down to select, left/right to page, A to enter a directory, X to refresh, +and B or Back to move up or close the browser. + ## Building with nxdk ### Build In order to build NevolutionX you'll need to install [nxdk](https://github.com/XboxDev/nxdk) first. @@ -41,6 +61,7 @@ There is no further configuration required. The FTP-server will start automatica ## Building with CMake (Linux target) TODO: Document what parts of the Linux target are not supported. TODO: Document installation for the Linux target, if applicable. + `cmake -S . -B build && cmake --build build --verbose` ## Credits @@ -51,4 +72,3 @@ This software is built on top of other awesome projects: ## License NevolutionX is published under the MIT License. See [LICENSE](LICENSE) for more information. MIT © 2019 Lucas Eriksson - diff --git a/Sources/directoryReader.cpp b/Sources/directoryReader.cpp new file mode 100644 index 0000000..b40fcb4 --- /dev/null +++ b/Sources/directoryReader.cpp @@ -0,0 +1,143 @@ +#include "directoryReader.hpp" +#include +#include + +#ifdef NXDK +#include +#else +#include +#include +#include +#endif + +namespace +{ + +bool isDotEntry(const char* name) { + return !std::strcmp(name, ".") || !std::strcmp(name, ".."); +} + +} // namespace + +DirectoryReadResult PlatformDirectoryReader::read(const std::string& path) const { + DirectoryReadResult result; + + if (path.empty() || path.size() >= maxPathLength()) { + result.error = "Path is empty or too long"; + return result; + } + +#ifdef NXDK + std::string searchMask(path); + if (searchMask.back() != '\\') { + searchMask.push_back('\\'); + } + searchMask.push_back('*'); + if (searchMask.size() >= maxPathLength()) { + result.error = "Directory search path is too long"; + return result; + } + + WIN32_FIND_DATAA data{}; + HANDLE handle = FindFirstFileA(searchMask.c_str(), &data); + if (handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + result.succeeded = true; + } else { + result.error = "Unable to read directory (error " + std::to_string(error) + ")"; + } + return result; + } + + do { + if (isDotEntry(data.cFileName)) { + continue; + } + + DirectoryEntryKind kind = DirectoryEntryKind::FILE; + if (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + kind = DirectoryEntryKind::DIRECTORY; + } + uint64_t size = (static_cast(data.nFileSizeHigh) << 32) + | static_cast(data.nFileSizeLow); + result.entries.emplace_back(data.cFileName, kind, size); + } while (FindNextFileA(handle, &data)); + + DWORD finalError = GetLastError(); + FindClose(handle); + if (finalError != ERROR_NO_MORE_FILES) { + result.entries.clear(); + result.error = "Directory read failed (error " + std::to_string(finalError) + ")"; + return result; + } +#else + DIR* directory = opendir(path.c_str()); + if (!directory) { + result.error = std::strerror(errno); + return result; + } + + errno = 0; + while (dirent* item = readdir(directory)) { + if (isDotEntry(item->d_name)) { + continue; + } + + std::string childPath(path); + if (childPath.back() != '/') { + childPath.push_back('/'); + } + childPath.append(item->d_name); + if (childPath.size() >= maxPathLength()) { + result.entries.clear(); + result.error = "Directory entry path is too long"; + closedir(directory); + return result; + } + + struct stat metadata{}; + if (lstat(childPath.c_str(), &metadata) != 0) { + result.entries.clear(); + result.error = std::strerror(errno); + closedir(directory); + return result; + } + + DirectoryEntryKind kind = DirectoryEntryKind::OTHER; + if (S_ISDIR(metadata.st_mode)) { + kind = DirectoryEntryKind::DIRECTORY; + } else if (S_ISREG(metadata.st_mode)) { + kind = DirectoryEntryKind::FILE; + } + result.entries.emplace_back(item->d_name, kind, static_cast(metadata.st_size)); + } + + int readError = errno; + closedir(directory); + if (readError) { + result.entries.clear(); + result.error = std::strerror(readError); + return result; + } +#endif + + result.succeeded = true; + return result; +} + +char PlatformDirectoryReader::separator() const { +#ifdef NXDK + return '\\'; +#else + return '/'; +#endif +} + +size_t PlatformDirectoryReader::maxPathLength() const { +#ifdef NXDK + return MAX_PATH; +#else + return PATH_MAX; +#endif +} diff --git a/Sources/fileBrowser.cpp b/Sources/fileBrowser.cpp new file mode 100644 index 0000000..205b386 --- /dev/null +++ b/Sources/fileBrowser.cpp @@ -0,0 +1,132 @@ +#include "fileBrowser.hpp" +#include +#include +#include "subAppRouter.hpp" +#include "textLayout.hpp" + +FileBrowser::FileBrowser(Renderer& browserRenderer, const std::vector& roots) : + renderer(browserRenderer), reader(std::make_shared()), + model(roots, reader) { + autoRepeatIntervals[SDL_CONTROLLER_BUTTON_DPAD_UP] = 250; + autoRepeatIntervals[SDL_CONTROLLER_BUTTON_DPAD_DOWN] = 250; +} + +void FileBrowser::render(Font& font) { + float width = static_cast(renderer.getWidth()); + float height = static_cast(renderer.getHeight()); + float marginX = width * 0.075f; + float marginY = height * 0.05f; + float lineHeight = std::max(font.getFontHeight(), 1.0f); + float contentWidth = width - (marginX * 2.0f); + + font.draw("Read-only file browser", std::make_pair(marginX, marginY)); + + std::string location = ellipsizeText( + sanitizeUtf8ForDisplay(model.getLocationLabel()), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + font.draw(location, std::make_pair(marginX, marginY + lineHeight)); + + float listTop = marginY + (lineHeight * 2.5f); + // The application-wide IP/FPS overlay begins at 85% height, so keep this + // SubApp's footer above it. + float footerBottom = height * 0.80f; + float statusY = footerBottom - (lineHeight * 2.0f); + float helpY = footerBottom - lineHeight; + float listHeight = std::max(statusY - listTop, lineHeight); + visibleRows = std::max(static_cast(listHeight / lineHeight), + static_cast(1)); + + const auto& entries = model.getEntries(); + size_t selected = model.getSelected(); + size_t first = 0; + if (selected >= visibleRows) { + first = selected - visibleRows + 1; + } + if (entries.size() > visibleRows) { + first = std::min(first, entries.size() - visibleRows); + } + + size_t last = std::min(entries.size(), first + visibleRows); + for (size_t index = first; index < last; ++index) { + float y = listTop + static_cast(index - first) * lineHeight; + std::string label = ellipsizeText( + sanitizeUtf8ForDisplay(displayLabel(entries[index])), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + auto dimensions = font.draw(label, std::make_pair(marginX, y)); + + if (index == selected) { + SDL_Rect outline; + outline.x = static_cast(marginX - 8.0f); + outline.y = static_cast(y); + outline.w = static_cast(std::min(dimensions.first + 16.0f, contentWidth + 16.0f)); + outline.h = static_cast(lineHeight); + renderer.setDrawColor(0xFF, 0xFF, 0xFF, 0xFF); + SDL_RenderDrawRect(renderer.getRenderer(), &outline); + } + } + + std::string status = model.getStatus(); + if (status.empty() && entries.empty()) { + status = ""; + } + status = ellipsizeText( + sanitizeUtf8ForDisplay(status), contentWidth, + [&font](const std::string& value) { return font.getTextWidth(value); }); + font.draw(status, std::make_pair(marginX, statusY)); + + std::string help = "A: enter X: refresh B/Back: up/close"; + help = ellipsizeText(help, contentWidth, [&font](const std::string& value) { + return font.getTextWidth(value); + }); + font.draw(help, std::make_pair(marginX, helpY)); +} + +void FileBrowser::onUpPressed() { + model.moveSelection(-1); +} + +void FileBrowser::onDownPressed() { + model.moveSelection(1); +} + +void FileBrowser::onLeftPressed() { + model.pageSelection(-static_cast(visibleRows)); +} + +void FileBrowser::onRightPressed() { + model.pageSelection(static_cast(visibleRows)); +} + +void FileBrowser::onAPressed() { + model.activateSelected(); +} + +void FileBrowser::onBPressed() { + navigateBack(); +} + +void FileBrowser::onBackPressed() { + navigateBack(); +} + +void FileBrowser::onXPressed() { + model.refresh(); +} + +void FileBrowser::navigateBack() { + if (!model.navigateBack()) { + SubAppRouter::getInstance()->pop(); + } +} + +std::string FileBrowser::displayLabel(const DirectoryEntry& entry) const { + switch (entry.kind) { + case DirectoryEntryKind::DIRECTORY: + return "[DIR] " + entry.name; + case DirectoryEntryKind::FILE: + return "[FILE] " + entry.name; + case DirectoryEntryKind::OTHER: + return "[OTHER] " + entry.name; + } + return entry.name; +} diff --git a/Sources/fileBrowserMenu.cpp b/Sources/fileBrowserMenu.cpp new file mode 100644 index 0000000..e06182a --- /dev/null +++ b/Sources/fileBrowserMenu.cpp @@ -0,0 +1,16 @@ +#include "fileBrowserMenu.hpp" +#include +#include +#include "fileBrowser.hpp" +#include "subAppRouter.hpp" + +FileBrowserMenu::FileBrowserMenu(MenuNode* parent, + const std::string& label, + std::vector browserRoots) : + MenuItem(parent, label), roots(std::move(browserRoots)) { +} + +void FileBrowserMenu::execute(Menu* menu) { + SubAppRouter::getInstance()->push( + std::make_shared(menu->getRenderer(), roots)); +} diff --git a/Sources/fileBrowserModel.cpp b/Sources/fileBrowserModel.cpp new file mode 100644 index 0000000..f2d634b --- /dev/null +++ b/Sources/fileBrowserModel.cpp @@ -0,0 +1,458 @@ +#include "fileBrowserModel.hpp" +#include +#include + +namespace +{ + +unsigned char asciiFold(unsigned char value) { + if (value >= 'A' && value <= 'Z') { + return static_cast(value + ('a' - 'A')); + } + return value; +} + +int safeCaseCompare(const std::string& lhs, const std::string& rhs) { + size_t count = std::min(lhs.size(), rhs.size()); + for (size_t index = 0; index < count; ++index) { + unsigned char left = asciiFold(static_cast(lhs[index])); + unsigned char right = asciiFold(static_cast(rhs[index])); + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + } + if (lhs.size() < rhs.size()) { + return -1; + } + if (lhs.size() > rhs.size()) { + return 1; + } + return 0; +} + +int unsignedByteCompare(const std::string& lhs, const std::string& rhs) { + size_t count = std::min(lhs.size(), rhs.size()); + for (size_t index = 0; index < count; ++index) { + unsigned char left = static_cast(lhs[index]); + unsigned char right = static_cast(rhs[index]); + if (left != right) { + return left < right ? -1 : 1; + } + } + if (lhs.size() == rhs.size()) { + return 0; + } + return lhs.size() < rhs.size() ? -1 : 1; +} + +int naturalNameCompare(const std::string& lhs, const std::string& rhs) { + size_t leftIndex = 0; + size_t rightIndex = 0; + + while (leftIndex < lhs.size() && rightIndex < rhs.size()) { + unsigned char left = static_cast(lhs[leftIndex]); + unsigned char right = static_cast(rhs[rightIndex]); + bool leftDigit = left >= '0' && left <= '9'; + bool rightDigit = right >= '0' && right <= '9'; + + if (leftDigit && rightDigit) { + size_t leftRunEnd = leftIndex; + size_t rightRunEnd = rightIndex; + while (leftRunEnd < lhs.size() && lhs[leftRunEnd] >= '0' && lhs[leftRunEnd] <= '9') { + ++leftRunEnd; + } + while (rightRunEnd < rhs.size() && rhs[rightRunEnd] >= '0' + && rhs[rightRunEnd] <= '9') { + ++rightRunEnd; + } + + size_t leftSignificant = leftIndex; + size_t rightSignificant = rightIndex; + while (leftSignificant < leftRunEnd && lhs[leftSignificant] == '0') { + ++leftSignificant; + } + while (rightSignificant < rightRunEnd && rhs[rightSignificant] == '0') { + ++rightSignificant; + } + + size_t leftDigits = leftRunEnd - leftSignificant; + size_t rightDigits = rightRunEnd - rightSignificant; + if (leftDigits != rightDigits) { + return leftDigits < rightDigits ? -1 : 1; + } + for (size_t offset = 0; offset < leftDigits; ++offset) { + if (lhs[leftSignificant + offset] != rhs[rightSignificant + offset]) { + return lhs[leftSignificant + offset] < rhs[rightSignificant + offset] ? -1 : 1; + } + } + + size_t leftRunLength = leftRunEnd - leftIndex; + size_t rightRunLength = rightRunEnd - rightIndex; + if (leftRunLength != rightRunLength) { + return leftRunLength < rightRunLength ? -1 : 1; + } + leftIndex = leftRunEnd; + rightIndex = rightRunEnd; + continue; + } + + unsigned char foldedLeft = asciiFold(left); + unsigned char foldedRight = asciiFold(right); + if (foldedLeft != foldedRight) { + return foldedLeft < foldedRight ? -1 : 1; + } + ++leftIndex; + ++rightIndex; + } + + if (leftIndex != lhs.size() || rightIndex != rhs.size()) { + return leftIndex == lhs.size() ? -1 : 1; + } + return unsignedByteCompare(lhs, rhs); +} + +} // namespace + +FileBrowserModel::FileBrowserModel(std::vector configuredRoots, + std::shared_ptr directoryReader) : + reader(std::move(directoryReader)) { + if (!reader) { + status = "File browser has no directory reader"; + return; + } + + roots = normalizeRoots(configuredRoots); + if (roots.empty()) { + status = "File browser has no valid configured roots"; + return; + } + + if (roots.size() == 1) { + atVirtualRoot = false; + loadCurrentDirectory(false); + } else { + showVirtualRoot(); + } +} + +std::string FileBrowserModel::getLocationLabel() const { + if (atVirtualRoot) { + return "Configured roots"; + } + return buildCurrentPath(); +} + +void FileBrowserModel::moveSelection(int delta, bool allowWrap) { + if (entries.empty() || !delta) { + return; + } + + size_t count = entries.size(); + if (delta > 0) { + size_t offset = static_cast(delta); + selected = allowWrap ? (selected + offset) % count + : std::min(selected + offset, count - 1); + return; + } + + size_t offset = static_cast(-delta); + if (offset <= selected) { + selected -= offset; + } else if (!allowWrap) { + selected = 0; + } else { + offset = (offset - selected) % count; + selected = offset ? count - offset : 0; + } +} + +void FileBrowserModel::pageSelection(int delta) { + moveSelection(delta, false); +} + +FileBrowserModel::ActivateResult FileBrowserModel::activateSelected() { + if (entries.empty() || selected >= entries.size()) { + return ActivateResult::NONE; + } + + const DirectoryEntry entry = entries[selected]; + if (entry.kind != DirectoryEntryKind::DIRECTORY) { + status = "Files cannot be opened"; + return ActivateResult::FILE_SELECTED; + } + + HistoryFrame frame; + frame.virtualRoot = atVirtualRoot; + frame.rootIndex = rootIndex; + frame.components = components; + frame.selection = selectedIdentity(); + + if (atVirtualRoot) { + rootIndex = selected; + components.clear(); + atVirtualRoot = false; + } else { + if (!isSafeChildName(entry.name)) { + status = "Unsafe directory name refused"; + return ActivateResult::FAILED; + } + components.push_back(entry.name); + } + + if (!loadCurrentDirectory(false)) { + std::string failureStatus = status; + atVirtualRoot = frame.virtualRoot; + rootIndex = frame.rootIndex; + components = frame.components; + if (atVirtualRoot) { + showVirtualRoot(); + } else { + loadCurrentDirectory(false); + } + restoreSelection(frame.selection); + status = failureStatus; + return ActivateResult::FAILED; + } + + history.push_back(frame); + return ActivateResult::ENTERED_DIRECTORY; +} + +bool FileBrowserModel::navigateBack() { + if (history.empty()) { + return false; + } + + HistoryFrame frame = history.back(); + history.pop_back(); + atVirtualRoot = frame.virtualRoot; + rootIndex = frame.rootIndex; + components = frame.components; + + if (atVirtualRoot) { + showVirtualRoot(); + } else { + loadCurrentDirectory(false); + } + restoreSelection(frame.selection); + return true; +} + +bool FileBrowserModel::refresh() { + if (atVirtualRoot) { + SelectionIdentity identity = selectedIdentity(); + showVirtualRoot(); + restoreSelection(identity); + return true; + } + return loadCurrentDirectory(true); +} + +bool FileBrowserModel::isSafeChildName(const std::string& name) { + if (name.empty() || name == "." || name == "..") { + return false; + } + if (name[0] == '/' || name[0] == '\\') { + return false; + } + return name.find('/') == std::string::npos && name.find('\\') == std::string::npos; +} + +bool FileBrowserModel::entryLess(const DirectoryEntry& lhs, const DirectoryEntry& rhs) { + bool leftDirectory = lhs.kind == DirectoryEntryKind::DIRECTORY; + bool rightDirectory = rhs.kind == DirectoryEntryKind::DIRECTORY; + if (leftDirectory != rightDirectory) { + return leftDirectory; + } + + int nameComparison = naturalNameCompare(lhs.name, rhs.name); + if (nameComparison) { + return nameComparison < 0; + } + return static_cast(lhs.kind) < static_cast(rhs.kind); +} + +std::string FileBrowserModel::trim(const std::string& value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start]))) { + ++start; + } + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +bool FileBrowserModel::equalsIgnoreCase(const std::string& lhs, const std::string& rhs) { + return safeCaseCompare(lhs, rhs) == 0; +} + +std::vector FileBrowserModel::normalizeRoots( + const std::vector& configuredRoots) const { + std::vector normalized; + for (const auto& configuredRoot: configuredRoots) { + std::string root; + if (!normalizeRoot(configuredRoot, root)) { + continue; + } + + bool duplicate = false; + for (const auto& existing: normalized) { + if (reader->separator() == '\\' ? equalsIgnoreCase(root, existing) + : root == existing) { + duplicate = true; + break; + } + } + if (!duplicate) { + normalized.push_back(root); + } + } + return normalized; +} + +bool FileBrowserModel::normalizeRoot(const std::string& input, std::string& output) const { + output = trim(input); + if (output.empty()) { + return false; + } + + const char separator = reader->separator(); + const char alternateSeparator = separator == '\\' ? '/' : '\\'; + std::replace(output.begin(), output.end(), alternateSeparator, separator); + + if (separator == '\\') { + if (output.size() < 3 || !std::isalpha(static_cast(output[0])) + || output[1] != ':' || output[2] != '\\') { + return false; + } + output[0] = static_cast(std::toupper(static_cast(output[0]))); + } else if (output[0] != '/') { + return false; + } + + std::string collapsed; + collapsed.reserve(output.size()); + for (char character: output) { + if (character == separator && !collapsed.empty() && collapsed.back() == separator) { + continue; + } + collapsed.push_back(character); + } + output.swap(collapsed); + + size_t componentStart = separator == '\\' ? 3 : 1; + while (componentStart < output.size()) { + size_t componentEnd = output.find(separator, componentStart); + std::string component = output.substr(componentStart, componentEnd - componentStart); + if (component.empty() || component == "." || component == "..") { + return false; + } + if (componentEnd == std::string::npos) { + break; + } + componentStart = componentEnd + 1; + } + + size_t minimumLength = separator == '\\' ? 3 : 1; + while (output.size() > minimumLength && output.back() == separator) { + output.pop_back(); + } + return output.size() < reader->maxPathLength(); +} + +std::string FileBrowserModel::buildCurrentPath() const { + if (roots.empty() || rootIndex >= roots.size()) { + return ""; + } + + std::string path = roots[rootIndex]; + for (const auto& component: components) { + if (!path.empty() && path.back() != reader->separator()) { + path.push_back(reader->separator()); + } + path.append(component); + } + return path; +} + +bool FileBrowserModel::loadCurrentDirectory(bool preserveOldListing) { + const std::string path = buildCurrentPath(); + // Reserve room for a separator, wildcard, and terminator in platform readers. + if (path.empty() || path.size() + 2 >= reader->maxPathLength()) { + status = "Path is empty or too long"; + if (!preserveOldListing) { + entries.clear(); + selected = 0; + } + return false; + } + + SelectionIdentity identity; + size_t previousSelected = 0; + if (preserveOldListing) { + identity = selectedIdentity(); + previousSelected = selected; + } + DirectoryReadResult result = reader->read(path); + if (!result.succeeded) { + status = "Unable to read " + path + ": " + result.error; + if (!preserveOldListing) { + entries.clear(); + selected = 0; + } + return false; + } + + std::vector safeEntries; + for (const auto& entry: result.entries) { + if (isSafeChildName(entry.name)) { + safeEntries.push_back(entry); + } + } + std::sort(safeEntries.begin(), safeEntries.end(), entryLess); + entries.swap(safeEntries); + selected = entries.empty() ? 0 : std::min(previousSelected, entries.size() - 1); + if (preserveOldListing) { + restoreSelection(identity); + } + status = entries.empty() ? "Directory is empty" : ""; + return true; +} + +void FileBrowserModel::showVirtualRoot() { + entries.clear(); + for (const auto& root: roots) { + entries.emplace_back(root, DirectoryEntryKind::DIRECTORY); + } + selected = 0; + status = entries.empty() ? "File browser has no valid configured roots" : ""; +} + +FileBrowserModel::SelectionIdentity FileBrowserModel::selectedIdentity() const { + SelectionIdentity identity; + if (selected < entries.size()) { + identity.valid = true; + identity.name = entries[selected].name; + identity.kind = entries[selected].kind; + } + return identity; +} + +void FileBrowserModel::restoreSelection(const SelectionIdentity& identity) { + if (!identity.valid) { + selected = entries.empty() ? 0 : std::min(selected, entries.size() - 1); + return; + } + for (size_t index = 0; index < entries.size(); ++index) { + if (entries[index].name == identity.name && entries[index].kind == identity.kind) { + selected = index; + return; + } + } + selected = entries.empty() ? 0 : std::min(selected, entries.size() - 1); +} diff --git a/Sources/menu.cpp b/Sources/menu.cpp index 6c420af..fe4ac8b 100644 --- a/Sources/menu.cpp +++ b/Sources/menu.cpp @@ -1,6 +1,7 @@ #include "menu.hpp" #include #include "3rdparty/NaturalSort/natural_sort.hpp" +#include "fileBrowserMenu.hpp" #include "infoLog.hpp" #include "settingsMenu.hpp" #include "xbeLauncher.hpp" @@ -337,6 +338,18 @@ Menu::Menu(const Config& config, Renderer& renderer) : std::shared_ptr newNode = std::make_shared(e["label"], e["path"]); this->rootNode.addNode(newNode); + } else if (!static_cast(e["type"]).compare("file_browser")) { + std::vector roots; + if (e.contains("roots") && e["roots"].is_array()) { + for (const auto& root: e["roots"]) { + if (root.is_string()) { + roots.push_back(root); + } + } + } + std::shared_ptr newNode = std::make_shared( + currentMenu, e["label"], std::move(roots)); + this->rootNode.addNode(newNode); } else if (!static_cast(e["type"]).compare("reboot")) { std::shared_ptr newNode = std::make_shared( e["label"], [](Menu*) { XBELauncher::exitToDashboard(); }); diff --git a/Sources/textLayout.cpp b/Sources/textLayout.cpp new file mode 100644 index 0000000..1c6b5a7 --- /dev/null +++ b/Sources/textLayout.cpp @@ -0,0 +1,91 @@ +#include "textLayout.hpp" + +namespace +{ + +void removeLastUtf8Codepoint(std::string& text) { + if (text.empty()) { + return; + } + size_t start = text.size() - 1; + while (start > 0 && (static_cast(text[start]) & 0xC0) == 0x80) { + --start; + } + text.erase(start); +} + +bool isContinuation(unsigned char value) { + return (value & 0xC0) == 0x80; +} + +} // namespace + +std::string sanitizeUtf8ForDisplay(const std::string& text) { + std::string output; + output.reserve(text.size()); + + for (size_t index = 0; index < text.size();) { + unsigned char first = static_cast(text[index]); + if (first < 0x80) { + output.push_back(text[index++]); + continue; + } + + size_t sequenceLength = 0; + if (first >= 0xC2 && first <= 0xDF) { + sequenceLength = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequenceLength = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequenceLength = 4; + } + + bool valid = sequenceLength && index + sequenceLength <= text.size(); + for (size_t offset = 1; valid && offset < sequenceLength; ++offset) { + valid = isContinuation(static_cast(text[index + offset])); + } + + if (valid && sequenceLength == 3) { + unsigned char second = static_cast(text[index + 1]); + valid = (first != 0xE0 || second >= 0xA0) && (first != 0xED || second <= 0x9F); + } else if (valid && sequenceLength == 4) { + unsigned char second = static_cast(text[index + 1]); + valid = (first != 0xF0 || second >= 0x90) && (first != 0xF4 || second <= 0x8F); + } + + if (!valid) { + output.push_back('?'); + ++index; + continue; + } + + output.append(text, index, sequenceLength); + index += sequenceLength; + } + return output; +} + +std::string ellipsizeText(const std::string& text, + float maximumWidth, + const std::function& measure) { + if (maximumWidth <= 0.0f || !measure) { + return ""; + } + if (measure(text) <= maximumWidth) { + return text; + } + + std::string ellipsis = "..."; + while (!ellipsis.empty() && measure(ellipsis) > maximumWidth) { + ellipsis.pop_back(); + } + if (ellipsis.empty()) { + return ""; + } + + std::string prefix = text; + while (!prefix.empty() && measure(prefix + ellipsis) > maximumWidth) { + removeLastUtf8Codepoint(prefix); + } + return prefix + ellipsis; +} diff --git a/Tests/fileBrowserModelTests.cpp b/Tests/fileBrowserModelTests.cpp new file mode 100644 index 0000000..61dd066 --- /dev/null +++ b/Tests/fileBrowserModelTests.cpp @@ -0,0 +1,371 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "directoryReader.hpp" +#include "fileBrowserModel.hpp" +#include "textLayout.hpp" + +#define RUN_TEST(method) \ + do { \ + std::cout << "Running " #method "..." << std::endl; \ + method(); \ + } while (false) + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + std::cerr << __FILE__ << ":" << __LINE__ << ": check failed: " #expression \ + << std::endl; \ + std::abort(); \ + } \ + } while (false) + +namespace +{ + +DirectoryReadResult success(std::vector entries = {}) { + DirectoryReadResult result; + result.succeeded = true; + result.entries = std::move(entries); + return result; +} + +DirectoryReadResult failure(const std::string& error) { + DirectoryReadResult result; + result.error = error; + return result; +} + +class FakeReader : public DirectoryReader { +public: + explicit FakeReader(char pathSeparator = '/', size_t pathLimit = 260) : + pathSeparator(pathSeparator), pathLimit(pathLimit) {} + + DirectoryReadResult read(const std::string& path) const override { + ++readCount; + auto result = results.find(path); + if (result == results.end()) { + return failure("not found"); + } + return result->second; + } + + char separator() const override { return pathSeparator; } + size_t maxPathLength() const override { return pathLimit; } + + void set(const std::string& path, DirectoryReadResult result) { + results[path] = std::move(result); + } + + mutable int readCount{ 0 }; + +private: + char pathSeparator; + size_t pathLimit; + std::map results; +}; + +void testRootValidationAndNormalization() { + auto reader = std::make_shared(); + FileBrowserModel model( + { " /first/ ", "/first", "", "relative", "/bad/../root", "/second//" }, reader); + + CHECK(model.isAtVirtualRoot()); + CHECK(model.getEntries().size() == 2); + CHECK(model.getEntries()[0].name == "/first"); + CHECK(model.getEntries()[1].name == "/second"); + + auto windowsReader = std::make_shared('\\'); + FileBrowserModel windowsModel( + { " c:/ ", "C:\\", "D:\\Apps\\", "not-a-drive", "E:\\..\\bad" }, windowsReader); + CHECK(windowsModel.getEntries().size() == 2); + CHECK(windowsModel.getEntries()[0].name == "C:\\"); + CHECK(windowsModel.getEntries()[1].name == "D:\\Apps"); +} + +void testNoValidRoots() { + auto reader = std::make_shared(); + FileBrowserModel model({ "", "relative" }, reader); + CHECK(model.getEntries().empty()); + CHECK(model.getStatus().find("no valid") != std::string::npos); + CHECK(!model.navigateBack()); +} + +void testSafeChildNames() { + CHECK(FileBrowserModel::isSafeChildName("normal name")); + CHECK(!FileBrowserModel::isSafeChildName("")); + CHECK(!FileBrowserModel::isSafeChildName(".")); + CHECK(!FileBrowserModel::isSafeChildName("..")); + CHECK(!FileBrowserModel::isSafeChildName("/absolute")); + CHECK(!FileBrowserModel::isSafeChildName("\\absolute")); + CHECK(!FileBrowserModel::isSafeChildName("one/two")); + CHECK(!FileBrowserModel::isSafeChildName("one\\two")); +} + +void testOrderingAndUnsafeEntryFiltering() { + auto reader = std::make_shared(); + std::string highName(1, static_cast(0xFF)); + reader->set("/root", success({ { "file10", DirectoryEntryKind::FILE }, + { "Dir10", DirectoryEntryKind::DIRECTORY }, + { "file2", DirectoryEntryKind::FILE }, + { "dir2", DirectoryEntryKind::DIRECTORY }, + { "Case", DirectoryEntryKind::FILE }, + { "case", DirectoryEntryKind::FILE }, + { highName, DirectoryEntryKind::FILE }, + { "..", DirectoryEntryKind::DIRECTORY }, + { "bad/name", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + + const auto& entries = model.getEntries(); + CHECK(entries.size() == 7); + CHECK(entries[0].name == "dir2"); + CHECK(entries[1].name == "Dir10"); + CHECK(entries[2].name == "Case"); + CHECK(entries[3].name == "case"); + CHECK(entries[4].name == "file2"); + CHECK(entries[5].name == "file10"); + CHECK(entries[6].name == highName); +} + +void testOrderingIsTransitiveAcrossRawByteClasses() { + std::string highSuffix(1, static_cast(0xFF)); + std::vector entries{ + { "x2", DirectoryEntryKind::FILE }, + { "x10", DirectoryEntryKind::FILE }, + { "x1" + highSuffix, DirectoryEntryKind::FILE }, + { "X02", DirectoryEntryKind::FILE }, + { "x002", DirectoryEntryKind::FILE }, + }; + + for (size_t left = 0; left < entries.size(); ++left) { + CHECK(!FileBrowserModel::entryLess(entries[left], entries[left])); + for (size_t middle = 0; middle < entries.size(); ++middle) { + for (size_t right = 0; right < entries.size(); ++right) { + if (FileBrowserModel::entryLess(entries[left], entries[middle]) + && FileBrowserModel::entryLess(entries[middle], entries[right])) { + CHECK(FileBrowserModel::entryLess(entries[left], entries[right])); + } + } + } + } +} + +void testSingleRootNavigationAndSelectionRestore() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "Folder2", DirectoryEntryKind::DIRECTORY }, + { "Folder10", DirectoryEntryKind::DIRECTORY }, + { "readme.txt", DirectoryEntryKind::FILE } })); + // Repeat the selected parent's name at a nonzero child index. Before the + // enter/refresh preservation split, entering Folder10 incorrectly selected + // this child instead of starting at the first entry. + reader->set("/root/Folder10", success({ { "Alpha", DirectoryEntryKind::DIRECTORY }, + { "Folder10", DirectoryEntryKind::DIRECTORY } })); + FileBrowserModel model({ "/root" }, reader); + + model.moveSelection(1); + CHECK(model.getEntries()[model.getSelected()].name == "Folder10"); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::ENTERED_DIRECTORY); + CHECK(model.getLocationLabel() == "/root/Folder10"); + CHECK(model.getSelected() == 0); + CHECK(model.getEntries()[model.getSelected()].name == "Alpha"); + CHECK(model.navigateBack()); + CHECK(model.getLocationLabel() == "/root"); + CHECK(model.getEntries()[model.getSelected()].name == "Folder10"); + CHECK(!model.navigateBack()); +} + +void testVirtualRootNavigation() { + auto reader = std::make_shared(); + reader->set("/one", success()); + reader->set("/two", success({ { "first", DirectoryEntryKind::FILE }, + { "second", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/one", "/two" }, reader); + + model.moveSelection(1); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::ENTERED_DIRECTORY); + CHECK(model.getLocationLabel() == "/two"); + CHECK(model.getSelected() == 0); + CHECK(model.getEntries()[model.getSelected()].name == "first"); + CHECK(model.navigateBack()); + CHECK(model.isAtVirtualRoot()); + CHECK(model.getSelected() == 1); +} + +void testFileActivationIsReadOnly() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "GAME.XBE", DirectoryEntryKind::FILE }, + { "notes.txt", DirectoryEntryKind::FILE }, + { "device", DirectoryEntryKind::OTHER } })); + FileBrowserModel model({ "/root" }, reader); + + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(model.getStatus() == "Files cannot be opened"); + CHECK(model.getLocationLabel() == "/root"); + + model.moveSelection(1); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(model.getStatus() == "Files cannot be opened"); + + model.moveSelection(1); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FILE_SELECTED); + CHECK(model.getLocationLabel() == "/root"); +} + +void testRefreshRetentionAndRemoval() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + model.moveSelection(1); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + + reader->set("/root", success({ { "b", DirectoryEntryKind::FILE }, + { "d", DirectoryEntryKind::FILE } })); + CHECK(model.refresh()); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + + reader->set("/root", success({ { "b", DirectoryEntryKind::DIRECTORY }, + { "d", DirectoryEntryKind::FILE } })); + CHECK(model.refresh()); + CHECK(model.getEntries()[model.getSelected()].name == "b"); + CHECK(model.getEntries()[model.getSelected()].kind == DirectoryEntryKind::DIRECTORY); + + std::vector previous = model.getEntries(); + reader->set("/root", failure("media removed")); + CHECK(!model.refresh()); + CHECK(model.getEntries().size() == previous.size()); + CHECK(model.getEntries()[0].name == previous[0].name); + CHECK(model.getStatus().find("media removed") != std::string::npos); + + auto removalReader = std::make_shared(); + removalReader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel removalModel({ "/root" }, removalReader); + removalModel.moveSelection(1); + removalReader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + CHECK(removalModel.refresh()); + CHECK(removalModel.getSelected() == 1); + CHECK(removalModel.getEntries()[removalModel.getSelected()].name == "c"); +} + +void testEmptyAndInitialReadError() { + auto reader = std::make_shared(); + reader->set("/empty", success()); + FileBrowserModel emptyModel({ "/empty" }, reader); + CHECK(emptyModel.getEntries().empty()); + CHECK(emptyModel.getStatus() == "Directory is empty"); + + FileBrowserModel failedModel({ "/missing" }, reader); + CHECK(failedModel.getEntries().empty()); + CHECK(failedModel.getStatus().find("not found") != std::string::npos); +} + +void testSelectionEdges() { + auto reader = std::make_shared(); + reader->set("/root", success({ { "a", DirectoryEntryKind::FILE }, + { "b", DirectoryEntryKind::FILE }, + { "c", DirectoryEntryKind::FILE } })); + FileBrowserModel model({ "/root" }, reader); + model.moveSelection(-1); + CHECK(model.getSelected() == 2); + model.moveSelection(1); + CHECK(model.getSelected() == 0); + model.pageSelection(20); + CHECK(model.getSelected() == 2); + model.pageSelection(-20); + CHECK(model.getSelected() == 0); +} + +void testOverlongChildPathIsRefused() { + auto reader = std::make_shared('/', 12); + reader->set("/root", success({ { "abcdef", DirectoryEntryKind::DIRECTORY } })); + FileBrowserModel model({ "/root" }, reader); + CHECK(model.activateSelected() == FileBrowserModel::ActivateResult::FAILED); + CHECK(model.getLocationLabel() == "/root"); + CHECK(model.getStatus().find("too long") != std::string::npos); +} + +float byteWidth(const std::string& value) { + return static_cast(value.size()); +} + +void testEllipsisBoundariesAndUtf8() { + CHECK(ellipsizeText("hello", 5.0f, byteWidth) == "hello"); + CHECK(ellipsizeText("hello", 4.0f, byteWidth) == "h..."); + CHECK(ellipsizeText("hello", 2.0f, byteWidth) == ".."); + CHECK(ellipsizeText("hello", 0.5f, byteWidth).empty()); + + std::string utf8 = "a\xE2\x82\xACz"; + CHECK(ellipsizeText(utf8, 4.0f, byteWidth) == "a..."); + + CHECK(sanitizeUtf8ForDisplay(utf8) == utf8); + CHECK(sanitizeUtf8ForDisplay(std::string("a\xFF" + "b", + 3)) + == "a?b"); + CHECK(sanitizeUtf8ForDisplay(std::string("x\xE2\x82", 3)) == "x??"); + CHECK(sanitizeUtf8ForDisplay(std::string("\xED\xA0\x80", 3)) == "???"); +} + +void testPosixReaderDoesNotTraverseSymlinks() { + char templatePath[] = "/tmp/nevolutionx-browser-XXXXXX"; + char* root = mkdtemp(templatePath); + CHECK(root); + + std::string rootPath(root); + std::string directoryPath = rootPath + "/folder"; + std::string filePath = rootPath + "/file.txt"; + std::string symlinkPath = rootPath + "/link"; + CHECK(mkdir(directoryPath.c_str(), 0700) == 0); + FILE* file = std::fopen(filePath.c_str(), "wb"); + CHECK(file); + CHECK(std::fputs("test", file) >= 0); + CHECK(std::fclose(file) == 0); + CHECK(symlink(directoryPath.c_str(), symlinkPath.c_str()) == 0); + + PlatformDirectoryReader reader; + DirectoryReadResult result = reader.read(rootPath); + CHECK(result.succeeded); + std::map kinds; + for (const auto& entry: result.entries) { + kinds[entry.name] = entry.kind; + } + CHECK(kinds["folder"] == DirectoryEntryKind::DIRECTORY); + CHECK(kinds["file.txt"] == DirectoryEntryKind::FILE); + CHECK(kinds["link"] == DirectoryEntryKind::OTHER); + + CHECK(unlink(symlinkPath.c_str()) == 0); + CHECK(unlink(filePath.c_str()) == 0); + CHECK(rmdir(directoryPath.c_str()) == 0); + CHECK(rmdir(rootPath.c_str()) == 0); +} + +} // namespace + +int main() { + RUN_TEST(testRootValidationAndNormalization); + RUN_TEST(testNoValidRoots); + RUN_TEST(testSafeChildNames); + RUN_TEST(testOrderingAndUnsafeEntryFiltering); + RUN_TEST(testOrderingIsTransitiveAcrossRawByteClasses); + RUN_TEST(testSingleRootNavigationAndSelectionRestore); + RUN_TEST(testVirtualRootNavigation); + RUN_TEST(testFileActivationIsReadOnly); + RUN_TEST(testRefreshRetentionAndRemoval); + RUN_TEST(testEmptyAndInitialReadError); + RUN_TEST(testSelectionEdges); + RUN_TEST(testOverlongChildPathIsRefused); + RUN_TEST(testEllipsisBoundariesAndUtf8); + RUN_TEST(testPosixReaderDoesNotTraverseSymlinks); + return 0; +}