diff --git a/src/bonsaiviewer/CMakeLists.txt b/src/bonsaiviewer/CMakeLists.txt index a7648fef7a..280c339c01 100644 --- a/src/bonsaiviewer/CMakeLists.txt +++ b/src/bonsaiviewer/CMakeLists.txt @@ -83,6 +83,8 @@ set(BONSAIVIEWER_FILES ${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h + ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.cpp diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index 75adde0fc9..b7d0944568 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -33,6 +33,7 @@ #include "modules/models/View.h" #include "modules/models/Panel.h" #include "modules/project/Commands.h" +#include "modules/project/RecentProjects.h" #include "modules/properties/View.h" #include "modules/properties/Panel.h" #include "modules/settings/Dialog.h" @@ -42,11 +43,13 @@ #include "modules/viewport/Panel.h" #include "modules/viewport/View.h" +#include #include #include #include #include #include +#include #include #include #include @@ -75,6 +78,13 @@ MainWindow::MainWindow(QWidget* parent) connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, on_clean_state); connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, on_clean_state); connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, on_clean_state); + + // Every successful open/save (local or cloud) feeds the recent list. + auto remember_recent = [](const QString& path) { + modules::project::RecentProjects::add(path); + }; + connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, remember_recent); + connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, remember_recent); setupChrome(); setupViewport(); setupPanels(); @@ -140,6 +150,37 @@ QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) return button; } +void MainWindow::populateRecentMenu(QMenu* menu) { + menu->clear(); + + const QStringList recent = modules::project::RecentProjects::list(); + if (recent.isEmpty()) { + QAction* empty = menu->addAction("No Recent Projects"); + empty->setEnabled(false); + return; + } + + for (const QString& path : recent) { + QAction* action = menu->addAction(QFileInfo(path).fileName()); + action->setToolTip(path); + connect(action, &QAction::triggered, this, [this, path]() { + const bool opened = modules::project::commands::openProjectPath( + *session_state_, *this, *viewport_widget_->viewport(), path); + // A recent entry that no longer loads is dropped so it stops + // cluttering the menu (e.g. moved/deleted file). + if (!opened && !QFileInfo::exists(path)) { + modules::project::RecentProjects::remove(path); + } + }); + } + + menu->addSeparator(); + QAction* clear = menu->addAction("Clear Recent Projects"); + connect(clear, &QAction::triggered, this, []() { + modules::project::RecentProjects::clear(); + }); +} + QWidget* MainWindow::buildHomeRibbonPage() { auto* page = new QFrame(this); page->setObjectName("ribbonPage"); @@ -163,8 +204,12 @@ QWidget* MainWindow::buildHomeRibbonPage() { *session_state_, *this, *viewport_widget_->viewport()); }); auto* open_recent = components::buttons::makeButton("Open Recent", ":/icons/clock-rotate-right.svg", this); - connect(open_recent, &QToolButton::clicked, this, [this]() { - session_state_->setStatusMessage("Project", "Open Recent coming soon"); + auto* recent_menu = new QMenu(open_recent); + recent_menu->setToolTipsVisible(true); + open_recent->setMenu(recent_menu); + open_recent->setPopupMode(QToolButton::InstantPopup); + connect(recent_menu, &QMenu::aboutToShow, this, [this, recent_menu]() { + populateRecentMenu(recent_menu); }); auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this); connect(save_project, &QToolButton::clicked, this, [this]() { diff --git a/src/bonsaiviewer/MainWindow.h b/src/bonsaiviewer/MainWindow.h index 6b83148815..6f8567ac86 100644 --- a/src/bonsaiviewer/MainWindow.h +++ b/src/bonsaiviewer/MainWindow.h @@ -27,6 +27,7 @@ class QLabel; class QDockWidget; +class QMenu; class QProgressBar; class QStackedWidget; class QToolButton; @@ -61,6 +62,9 @@ private: QWidget* buildPanelsRibbonPage(); void updateWindowTitle(); QToolButton* makePanelToggle(const QString& text, QDockWidget* dock); + // Rebuilds `menu` from the persisted recent-projects list. Wired to the + // menu's aboutToShow so it always reflects the current MRU state. + void populateRecentMenu(QMenu* menu); private: QLabel* status_mode_label_ = nullptr; diff --git a/src/bonsaiviewer/modules/project/Commands.cpp b/src/bonsaiviewer/modules/project/Commands.cpp index 978274ce22..f0e626c7f2 100644 --- a/src/bonsaiviewer/modules/project/Commands.cpp +++ b/src/bonsaiviewer/modules/project/Commands.cpp @@ -297,6 +297,11 @@ bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp) { return openProjectAt(s, host, vp, path); } +bool openProjectPath(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path) { + if (path.isEmpty()) return false; + return openProjectAt(s, host, vp, path); +} + bool openCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp) { SceneLoader* loader = s.loader(); if (loader && loader->isLoading()) { diff --git a/src/bonsaiviewer/modules/project/Commands.h b/src/bonsaiviewer/modules/project/Commands.h index e49b5f1b42..12dccdc057 100644 --- a/src/bonsaiviewer/modules/project/Commands.h +++ b/src/bonsaiviewer/modules/project/Commands.h @@ -34,6 +34,10 @@ namespace bonsaiviewer::modules::project::commands { // projectSaved) so views refresh once per command. bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp); bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp); +// Open a specific .ifcfed by path, bypassing the file dialog. Used by the +// "Open Recent" menu. Same dirty-check / load / cloud-resolve flow as +// openProject; returns false if the load failed or was cancelled. +bool openProjectPath(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path); // Pick a connector, then call pull_ifcfed_interactive and open the resulting // .ifcfed as a fresh project. Non-local models in the loaded federation are // resolved asynchronously via pull_models. diff --git a/src/bonsaiviewer/modules/project/RecentProjects.cpp b/src/bonsaiviewer/modules/project/RecentProjects.cpp new file mode 100644 index 0000000000..21ed2652d6 --- /dev/null +++ b/src/bonsaiviewer/modules/project/RecentProjects.cpp @@ -0,0 +1,85 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "RecentProjects.h" + +#include +#include + +namespace bonsaiviewer::modules::project { + +namespace { + +constexpr const char* kRecentKey = "project/recent_projects"; + +// Absolute, cleaned form used both for storage and de-duplication. +QString canonicalize(const QString& path) { + if (path.isEmpty()) return {}; + return QFileInfo(path).absoluteFilePath(); +} + +QStringList readRaw() { + QSettings settings; + return settings.value(kRecentKey).toStringList(); +} + +void writeRaw(const QStringList& paths) { + QSettings settings; + settings.setValue(kRecentKey, paths); +} + +} // namespace + +QStringList RecentProjects::list() { + const QStringList stored = readRaw(); + QStringList existing; + for (const QString& path : stored) { + if (QFileInfo::exists(path)) existing << path; + } + // Persist the pruned list so vanished files don't linger forever. + if (existing != stored) writeRaw(existing); + return existing; +} + +void RecentProjects::add(const QString& path) { + const QString canonical = canonicalize(path); + if (canonical.isEmpty()) return; + + QStringList paths = readRaw(); + paths.removeAll(canonical); + paths.prepend(canonical); + while (paths.size() > kMaxEntries) paths.removeLast(); + writeRaw(paths); +} + +void RecentProjects::remove(const QString& path) { + const QString canonical = canonicalize(path); + if (canonical.isEmpty()) return; + + QStringList paths = readRaw(); + if (paths.removeAll(canonical) > 0) writeRaw(paths); +} + +void RecentProjects::clear() { + QSettings settings; + settings.remove(kRecentKey); +} + +} // namespace bonsaiviewer::modules::project diff --git a/src/bonsaiviewer/modules/project/RecentProjects.h b/src/bonsaiviewer/modules/project/RecentProjects.h new file mode 100644 index 0000000000..27bf79fd8e --- /dev/null +++ b/src/bonsaiviewer/modules/project/RecentProjects.h @@ -0,0 +1,56 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_PROJECT_RECENTPROJECTS_H +#define IFCINTERFACE_MODULES_PROJECT_RECENTPROJECTS_H + +#include +#include + +namespace bonsaiviewer::modules::project { + +// Persistent most-recently-used list of project (.ifcfed) paths, backed by +// QSettings so it survives across sessions. All entries are stored as +// absolute, cleaned paths; the list is capped and de-duplicated. +class RecentProjects { +public: + // Hard cap on how many entries are kept / shown. + static constexpr int kMaxEntries = 10; + + // Recent project paths, most-recent first. Entries whose file no longer + // exists on disk are pruned (and the pruned list is persisted) so callers + // never need to filter the result themselves. + static QStringList list(); + + // Promote `path` to the front of the list. De-duplicates against any + // existing entry for the same file and trims the list to kMaxEntries. + // A no-op for empty paths. + static void add(const QString& path); + + // Drop a single entry, e.g. after a failed open. No-op if absent. + static void remove(const QString& path); + + // Forget every entry. + static void clear(); +}; + +} // namespace bonsaiviewer::modules::project + +#endif