feat(core,gui): file browser menus, sort, history and folder-picker move (phase 4)

The My Files header gets a New menu, a grid/list split button whose menu
sorts by name, size or date (persisted as files_view in the config), and a
folder menu for Build Thumbnails and Open in File Manager. Navigation
keeps a back/forward history (Alt+Left/Right, mouse buttons, Alt+Up).
Move to… browses Drive folders and handles a whole selection, rename
preselects the stem, and files dropped from a file manager upload into
the folder on screen.
This commit is contained in:
nvrl committed 2026-09-23 02:21:39 +02:00
1 parent 792eee75a4
commit 8bc52edeb4
6 files changed
+856 -195

No files matched your search

+75
View File
@@ -124,6 +124,63 @@ pub struct AppConfig {
/// predating the field.
#[serde(default)]
pub proton_accent: Option<bool>,
/// How the desktop app's My Files page lays out and orders a folder.
/// Defaulted for configs predating the field.
#[serde(default)]
pub files_view: FilesView,
}
/// The My Files page's view choices, remembered between runs.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
pub struct FilesView {
/// Columns instead of the icon grid.
pub list: bool,
pub sort: FileSort,
pub descending: bool,
/// Folders ahead of files, whatever the sort.
pub folders_first: bool,
}
impl Default for FilesView {
fn default() -> Self {
Self {
list: false,
sort: FileSort::Name,
descending: false,
folders_first: true,
}
}
}
/// The key a My Files listing is ordered by.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum FileSort {
#[default]
Name,
Size,
Modified,
}
impl FileSort {
/// The name used in the desktop app's actions and the config file.
pub fn as_str(self) -> &'static str {
match self {
FileSort::Name => "name",
FileSort::Size => "size",
FileSort::Modified => "modified",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"name" => Some(FileSort::Name),
"size" => Some(FileSort::Size),
"modified" => Some(FileSort::Modified),
_ => None,
}
}
}
impl Default for AppConfig {
@@ -139,6 +196,7 @@ impl Default for AppConfig {
open_with: None,
prompt: None,
proton_accent: None,
files_view: FilesView::default(),
}
}
}
@@ -484,12 +542,29 @@ mod tests {
open_with: None,
prompt: None,
proton_accent: Some(true),
files_view: FilesView {
list: true,
sort: FileSort::Modified,
descending: true,
folders_first: false,
},
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(decoded.proton_accent, Some(true));
assert_eq!(decoded.files_view, config.files_view);
}
#[test]
fn a_config_without_a_files_view_gets_the_default() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.files_view, FilesView::default());
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
assert_eq!(partial.sort, FileSort::Size);
assert!(partial.folders_first);
}
#[test]
+1
View File
@@ -37,6 +37,7 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
.files-drop-active { outline: 2px dashed var(--accent-bg-color); outline-offset: -4px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 6%, transparent); }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
+36 -12
View File
@@ -44,7 +44,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::config::{AppDirs, FileSort, FilesView};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
@@ -394,7 +394,8 @@ fn build_window(app: &adw::Application) {
},
browser: BrowserState {
model: browser_widgets.model.clone(),
back: browser_widgets.back.clone(),
history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()),
crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(),
@@ -402,9 +403,10 @@ fn build_window(app: &adw::Application) {
split: browser_widgets.split.clone(),
path: RefCell::new(String::new()),
search: browser_widgets.search.clone(),
new_folder: browser_widgets.new_folder.clone(),
upload: browser_widgets.upload.clone(),
upload_folder: browser_widgets.upload_folder.clone(),
actions: browser_widgets.actions.clone(),
view_button: browser_widgets.view_button.clone(),
view: Cell::new(FilesView::default()),
listing: RefCell::new(Vec::new()),
build_thumbnails: browser_widgets.build_thumbnails.clone(),
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
@@ -420,6 +422,7 @@ fn build_window(app: &adw::Application) {
bulk_trash: browser_widgets.bulk_trash.clone(),
bulk_pin: browser_widgets.bulk_pin.clone(),
bulk_unpin: browser_widgets.bulk_unpin.clone(),
bulk_move: browser_widgets.bulk_move.clone(),
empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(),
@@ -609,13 +612,7 @@ fn build_window(app: &adw::Application) {
&browser_widgets.empty_upload,
&browser_widgets.empty_new_folder,
);
wire_browser_actions(
&ui,
&browser_widgets.new_folder,
&browser_widgets.upload,
&browser_widgets.upload_folder,
&browser_widgets.build_thumbnails,
);
wire_browser_actions(&ui, &browser_widgets.build_thumbnails);
wire_details(&ui);
wire_search(&ui);
wire_gallery(
@@ -1197,6 +1194,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
&[
("<Control>n", "New folder"),
("<Control>u", "Upload files"),
("<Alt>Left <Alt>Right", "Back / forward"),
("<Alt>Up", "Parent folder"),
("<Control>1 <Control>2", "Grid / list"),
("F2", "Rename"),
("Delete", "Move to Trash"),
("Escape", "Clear the selection"),
@@ -1260,6 +1260,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let ui = ui.clone();
controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
match key {
// Refresh works on every page, so it is matched before the
@@ -1276,6 +1277,29 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
ui.browser.search.grab_focus();
}
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::_1 if ctrl && on_browser => set_files_view(
&ui,
FilesView {
list: false,
..ui.browser.view.get()
},
),
gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
&ui,
FilesView {
list: true,
..ui.browser.view.get()
},
),
gtk4::gdk::Key::Left if alt && on_browser => {
ui.browser.actions.activate_action("back", None)
}
gtk4::gdk::Key::Right if alt && on_browser => {
ui.browser.actions.activate_action("forward", None)
}
gtk4::gdk::Key::Up if alt && on_browser => {
ui.browser.actions.activate_action("up", None)
}
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::F2 if on_browser => {
// Renaming is one name at a time; saying so beats a key that
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -726,14 +726,7 @@ fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String,
/// that can only fail. A greyed control says so up front.
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted;
ui.browser.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted);
ui.browser.build_thumbnails.set_sensitive(
mounted
&& (!ui.browser.thumbnail_build_running.get()
|| !ui.browser.thumbnail_cancel_pending.get()),
);
sync_mounted_actions(ui, mounted);
ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted);
+9
View File
@@ -32,6 +32,15 @@ Schema: **30** (unchanged).
state, storage and the account; the Settings page became a Preferences dialog (Ctrl+,), with
transfers on the Sync page and developer details in About. The app follows the system accent
colour; the Proton purple accent is a preference.
- **My Files header.** New folder and both uploads sit in one **New** menu; the view button switches
grid and list, and its menu sorts by name, size or date, reversed or not, folders first or not.
The choice is remembered. Build Thumbnails and Open in File Manager moved to the folder menu.
- **Browsing history.** Back and forward buttons (Alt+Left/Right, mouse back/forward) replace the
single Up button; Alt+Up goes to the parent folder. Back leaves a search first.
- **Move to…** picks the destination from a folder browser instead of a typed path, and works on a
whole selection. Rename selects the name without its extension.
- **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The
upload toast links to the Sync page.
## [1.11.1] — 2026-09-19