Skip to content

Commit b03af1a

Browse files
committed
Modernize file dialogs with persistent state
Introduce FileDialogUtils to standardize open/save/folder dialogs: prefer platform-native dialogs by default, preserve recent locations, selected filters and sidebar bookmarks across sessions, and avoid synchronous volume probing that can hang the UI. Update callers across the UI (main_window, pak_tab, game_set_editor_dialog) to use the new API and add the new sources to the build. Document the change in README and CHANGELOG and add PAKFU_FORCE_QT_FILE_DIALOG to force the Qt dialog for troubleshooting.
1 parent e27023b commit b03af1a

8 files changed

Lines changed: 406 additions & 139 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ All notable changes to PakFu are documented here.
88

99
### Fixed
1010
- Fix stale folder listings when navigating newly added folders in a new PAK tab by using canonical archive paths for navigation/listing.
11+
- Revise all open/save file and folder dialogs to use platform-native navigation by default, with persistent recent locations, filters, and sidebar bookmarks across sessions.
12+
- Fix potential GUI hangs when opening file dialogs on systems with slow/unreachable volumes by removing synchronous volume probing from dialog state restore.
1113

1214
## [0.1.18.1] - 2026-02-19
1315
### Other

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ PakFu can also check for updates at runtime (GUI) and via CLI (`--check-updates`
6868
- One-click `Extract Selected` and `Extract All` workflows for the active archive tab.
6969
- Batch conversion tool for selected assets with category tabs (images, video, archives, models, sound, maps, text, other).
7070
- Image batch conversion supports output to all supported image formats (`png`, `jpg`, `jpeg`, `bmp`, `gif`, `tga`, `tif`, `tiff`, `pcx`, `wal`, `swl`, `mip`, `lmp`, `ftx`, `dds`) with format-aware settings (quality, compression, palette source, dithering, alpha threshold, embedded palette where applicable).
71+
- Modern platform-native open/save/folder dialogs with standard breadcrumbs/bookmarks and robust cross-platform behavior.
7172
- 3D preview renderer selection with Vulkan/OpenGL behavior and fallback.
7273
- Fly camera controls for 3D preview (`Right Mouse + WASD`, `Q/E`, mouse wheel speed, `Shift` faster, `Ctrl` slower, `F` frame, `R`/`Home` reset).
7374
- Auto-detection and management of per-game installation profiles.
@@ -256,6 +257,7 @@ For full policy details, see `docs/RELEASES.md`.
256257
| `PAKFU_DISABLE_QT_MESSAGE_HOOK` | Disable Qt log interception for troubleshooting. |
257258
| `PAKFU_DEBUG_MEDIA` | Enable extra media diagnostics in logs. |
258259
| `PAKFU_AUTO_PLAY_ON_OPEN` | Auto-start playback when opening videos. |
260+
| `PAKFU_FORCE_QT_FILE_DIALOG` | Force Qt's non-native file dialog implementation (useful for troubleshooting platform dialog issues). |
259261
| `PAKFU_ALLOW_MULTI_INSTANCE` | Disable single-instance behavior and allow multiple app instances. |
260262
| `PAKFU_SMOKE_TABS` | Run tab smoke test automation on startup (debug/CI helper). |
261263
| `QT_MEDIA_BACKEND` | Override Qt multimedia backend selection. |

src/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ sources = files(
9191
'ui/splash_screen.cpp',
9292
'ui/about_dialog.cpp',
9393
'ui/main_window.cpp',
94+
'ui/file_dialog_utils.cpp',
9495
'ui/file_associations_dialog.cpp',
9596
'ui/game_set_dialog.cpp',
9697
'ui/game_set_editor_dialog.cpp',

src/ui/file_dialog_utils.cpp

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#include "ui/file_dialog_utils.h"
2+
3+
#include <QByteArray>
4+
#include <QDialog>
5+
#include <QDir>
6+
#include <QFileDialog>
7+
#include <QFileInfo>
8+
#include <QSet>
9+
#include <QSettings>
10+
#include <QStandardPaths>
11+
#include <QUrl>
12+
13+
namespace {
14+
constexpr int kMaxHistoryEntries = 16;
15+
constexpr char kDialogSettingsRoot[] = "ui/fileDialogs";
16+
17+
bool env_var_is_truthy(const char* name) {
18+
const QByteArray raw = qgetenv(name).trimmed().toLower();
19+
if (raw.isEmpty()) {
20+
return false;
21+
}
22+
return raw != "0" && raw != "false" && raw != "no" && raw != "off";
23+
}
24+
25+
QString directory_key(QString path) {
26+
path = QDir::cleanPath(path);
27+
#if defined(Q_OS_WIN)
28+
path = path.toLower();
29+
#endif
30+
return path;
31+
}
32+
33+
QString normalize_directory_path(const QString& path_in) {
34+
QString path = QDir::fromNativeSeparators(path_in.trimmed());
35+
if (path.isEmpty()) {
36+
return {};
37+
}
38+
path = QDir::cleanPath(path);
39+
return QFileInfo(path).absoluteFilePath();
40+
}
41+
42+
QString settings_prefix(const QString& key) {
43+
const QString clean_key = key.trimmed().isEmpty() ? "default" : key.trimmed();
44+
return QString("%1/%2").arg(QLatin1String(kDialogSettingsRoot), clean_key);
45+
}
46+
47+
bool uses_qt_dialog(const QFileDialog* dialog) {
48+
return dialog && dialog->testOption(QFileDialog::DontUseNativeDialog);
49+
}
50+
51+
QString sidebar_url_key(const QUrl& url_in) {
52+
if (!url_in.isValid()) {
53+
return {};
54+
}
55+
56+
QUrl url = url_in;
57+
if (url.isLocalFile() || url.scheme().compare("file", Qt::CaseInsensitive) == 0) {
58+
const QString path = normalize_directory_path(url.toLocalFile());
59+
if (path.isEmpty()) {
60+
return {};
61+
}
62+
return QString("file:%1").arg(directory_key(path));
63+
}
64+
65+
url = url.adjusted(QUrl::NormalizePathSegments | QUrl::StripTrailingSlash);
66+
return url.toString(QUrl::FullyEncoded);
67+
}
68+
69+
void append_unique_sidebar_url(QList<QUrl>* out, QSet<QString>* seen, const QUrl& url_in) {
70+
if (!out || !seen) {
71+
return;
72+
}
73+
74+
QUrl url = url_in;
75+
if (url.scheme().isEmpty() && !url.toString().isEmpty()) {
76+
url = QUrl::fromLocalFile(url.toString());
77+
}
78+
79+
const QString key = sidebar_url_key(url);
80+
if (key.isEmpty() || seen->contains(key)) {
81+
return;
82+
}
83+
84+
seen->insert(key);
85+
if (url.isLocalFile() || url.scheme().compare("file", Qt::CaseInsensitive) == 0) {
86+
const QString path = normalize_directory_path(url.toLocalFile());
87+
if (!path.isEmpty()) {
88+
out->push_back(QUrl::fromLocalFile(path));
89+
}
90+
return;
91+
}
92+
out->push_back(url);
93+
}
94+
95+
QList<QUrl> default_sidebar_urls() {
96+
QList<QUrl> urls;
97+
QSet<QString> seen;
98+
99+
// Keep defaults path-only and avoid probing mounted volumes here.
100+
// Synchronous volume queries can stall UI on unreachable network drives.
101+
const QList<QStandardPaths::StandardLocation> locations = {
102+
QStandardPaths::HomeLocation,
103+
QStandardPaths::DesktopLocation,
104+
QStandardPaths::DocumentsLocation,
105+
QStandardPaths::DownloadLocation,
106+
QStandardPaths::PicturesLocation,
107+
QStandardPaths::MoviesLocation,
108+
QStandardPaths::MusicLocation,
109+
};
110+
111+
for (const QStandardPaths::StandardLocation location : locations) {
112+
for (const QString& path : QStandardPaths::standardLocations(location)) {
113+
append_unique_sidebar_url(&urls, &seen, QUrl::fromLocalFile(path));
114+
}
115+
}
116+
117+
append_unique_sidebar_url(&urls, &seen, QUrl::fromLocalFile(QDir::rootPath()));
118+
return urls;
119+
}
120+
121+
QStringList normalize_history(const QStringList& history) {
122+
QStringList out;
123+
QSet<QString> seen;
124+
for (const QString& value : history) {
125+
const QString directory = normalize_directory_path(value);
126+
if (directory.isEmpty()) {
127+
continue;
128+
}
129+
130+
const QString key = directory_key(directory);
131+
if (seen.contains(key)) {
132+
continue;
133+
}
134+
seen.insert(key);
135+
out.push_back(directory);
136+
if (out.size() >= kMaxHistoryEntries) {
137+
break;
138+
}
139+
}
140+
return out;
141+
}
142+
143+
QString selected_directory_from_dialog(const QFileDialog* dialog) {
144+
if (!dialog) {
145+
return {};
146+
}
147+
148+
const QStringList selected_files = dialog->selectedFiles();
149+
if (selected_files.isEmpty()) {
150+
return {};
151+
}
152+
153+
const QString first = normalize_directory_path(selected_files.first());
154+
if (first.isEmpty()) {
155+
return {};
156+
}
157+
158+
if (dialog->fileMode() == QFileDialog::Directory) {
159+
return first;
160+
}
161+
162+
return normalize_directory_path(QFileInfo(first).absolutePath());
163+
}
164+
165+
QString resolve_initial_directory(const QString& preferred, const QString& fallback) {
166+
QString dir = normalize_directory_path(preferred);
167+
if (!dir.isEmpty()) {
168+
return dir;
169+
}
170+
171+
dir = normalize_directory_path(fallback);
172+
if (!dir.isEmpty()) {
173+
return dir;
174+
}
175+
176+
return QDir::homePath();
177+
}
178+
179+
void apply_modern_dialog_defaults(QFileDialog* dialog) {
180+
if (!dialog) {
181+
return;
182+
}
183+
184+
// Native dialogs expose platform-standard breadcrumbs/bookmarks.
185+
const bool force_qt_dialog = env_var_is_truthy("PAKFU_FORCE_QT_FILE_DIALOG");
186+
dialog->setOption(QFileDialog::DontUseNativeDialog, force_qt_dialog);
187+
dialog->setOption(QFileDialog::HideNameFilterDetails, false);
188+
dialog->setOption(QFileDialog::ReadOnly, false);
189+
dialog->setViewMode(QFileDialog::Detail);
190+
}
191+
192+
void restore_dialog_state(QFileDialog* dialog,
193+
const QString& key_prefix,
194+
const QString& fallback_directory,
195+
const QString& initial_selection) {
196+
if (!dialog) {
197+
return;
198+
}
199+
200+
QSettings settings;
201+
const bool qt_dialog = uses_qt_dialog(dialog);
202+
if (qt_dialog) {
203+
const QByteArray state = settings.value(key_prefix + "/state").toByteArray();
204+
if (!state.isEmpty()) {
205+
dialog->restoreState(state);
206+
}
207+
208+
const QStringList history = normalize_history(settings.value(key_prefix + "/history").toStringList());
209+
if (!history.isEmpty()) {
210+
dialog->setHistory(history);
211+
}
212+
}
213+
214+
if (qt_dialog) {
215+
const QString stored_directory = settings.value(key_prefix + "/lastDirectory").toString();
216+
dialog->setDirectory(resolve_initial_directory(stored_directory, fallback_directory));
217+
} else {
218+
dialog->setDirectory(resolve_initial_directory(fallback_directory, QString()));
219+
}
220+
221+
if (qt_dialog) {
222+
dialog->setSidebarUrls(default_sidebar_urls());
223+
}
224+
225+
if (!initial_selection.trimmed().isEmpty()) {
226+
dialog->selectFile(initial_selection);
227+
}
228+
229+
const QString saved_filter = settings.value(key_prefix + "/selectedNameFilter").toString();
230+
if (!saved_filter.isEmpty() && dialog->nameFilters().contains(saved_filter)) {
231+
dialog->selectNameFilter(saved_filter);
232+
}
233+
}
234+
235+
void persist_dialog_state(QFileDialog* dialog, const QString& key_prefix) {
236+
if (!dialog) {
237+
return;
238+
}
239+
240+
const bool qt_dialog = uses_qt_dialog(dialog);
241+
QSettings settings;
242+
if (qt_dialog) {
243+
settings.setValue(key_prefix + "/state", dialog->saveState());
244+
245+
const QStringList history = normalize_history(dialog->history());
246+
if (!history.isEmpty()) {
247+
settings.setValue(key_prefix + "/history", history);
248+
} else {
249+
settings.remove(key_prefix + "/history");
250+
}
251+
}
252+
settings.remove(key_prefix + "/sidebar");
253+
254+
const QString from_selection = selected_directory_from_dialog(dialog);
255+
const QString from_current_directory = normalize_directory_path(dialog->directory().absolutePath());
256+
const QString last_directory = !from_selection.isEmpty() ? from_selection : from_current_directory;
257+
if (!last_directory.isEmpty()) {
258+
settings.setValue(key_prefix + "/lastDirectory", last_directory);
259+
}
260+
261+
const QString selected_filter = dialog->selectedNameFilter();
262+
if (!selected_filter.isEmpty()) {
263+
settings.setValue(key_prefix + "/selectedNameFilter", selected_filter);
264+
}
265+
}
266+
} // namespace
267+
268+
namespace FileDialogUtils {
269+
270+
bool exec_with_state(QFileDialog* dialog,
271+
const Options& options,
272+
QStringList* selected_files,
273+
QString* selected_name_filter) {
274+
if (selected_files) {
275+
selected_files->clear();
276+
}
277+
if (selected_name_filter) {
278+
selected_name_filter->clear();
279+
}
280+
if (!dialog || !selected_files) {
281+
return false;
282+
}
283+
284+
const QString key_prefix = settings_prefix(options.settings_key);
285+
apply_modern_dialog_defaults(dialog);
286+
restore_dialog_state(dialog, key_prefix, options.fallback_directory, options.initial_selection);
287+
288+
const bool accepted = dialog->exec() == QDialog::Accepted;
289+
persist_dialog_state(dialog, key_prefix);
290+
291+
if (!accepted) {
292+
return false;
293+
}
294+
295+
*selected_files = dialog->selectedFiles();
296+
if (selected_name_filter) {
297+
*selected_name_filter = dialog->selectedNameFilter();
298+
}
299+
return !selected_files->isEmpty();
300+
}
301+
302+
} // namespace FileDialogUtils

src/ui/file_dialog_utils.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#pragma once
2+
3+
#include <QString>
4+
#include <QStringList>
5+
6+
class QFileDialog;
7+
8+
namespace FileDialogUtils {
9+
10+
struct Options {
11+
QString settings_key;
12+
QString fallback_directory;
13+
QString initial_selection;
14+
};
15+
16+
bool exec_with_state(QFileDialog* dialog,
17+
const Options& options,
18+
QStringList* selected_files,
19+
QString* selected_name_filter = nullptr);
20+
21+
} // namespace FileDialogUtils

0 commit comments

Comments
 (0)