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);