From e4a1e27206f98e69631c38d9c18559d4331eab8b Mon Sep 17 00:00:00 2001 From: Paul van den Burg <1727604+paulvandenburg@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:18:45 +0200 Subject: [PATCH 1/3] Improve loading performance of large folders and network mounts --- libnemo-private/nemo-directory-async.c | 253 +++++++++++++++++++++- libnemo-private/nemo-directory.c | 22 +- libnemo-private/nemo-file-private.h | 47 +++++ libnemo-private/nemo-file.c | 35 +++- libnemo-private/nemo-file.h | 1 + libnemo-private/nemo-icon-container.c | 62 ++++-- libnemo-private/nemo-thumbnails.c | 280 +++++++++++++++++++++++-- src/nemo-list-view.c | 20 +- 8 files changed, 674 insertions(+), 46 deletions(-) diff --git a/libnemo-private/nemo-directory-async.c b/libnemo-private/nemo-directory-async.c index e3dd98c8f7..bab6ae6dc7 100644 --- a/libnemo-private/nemo-directory-async.c +++ b/libnemo-private/nemo-directory-async.c @@ -58,6 +58,10 @@ /* Keep async. jobs down to this number for all directories. */ #define MAX_ASYNC_JOBS 10 +static void enumerate_children_callback (GObject *source_object, + GAsyncResult *res, + gpointer user_data); + struct LinkInfoReadState { NemoDirectory *directory; GCancellable *cancellable; @@ -91,6 +95,17 @@ struct DirectoryLoadState { GHashTable *load_mime_list_hash; NemoFile *load_directory_file; int load_file_count; + /* TRUE while the cheap name+type pass is running. See + * start_monitoring_file_list(). */ + gboolean fast_pass; + gint64 load_start_us; + /* Entries handed over by the enumerator so far. Unlike load_file_count this + * is accurate the moment the enumerator drains, rather than trailing the + * pending-info dequeue. Benchmark reporting only. */ + int seen_file_count; + /* How many files have been handed the preload budget for reading in an + * already-cached thumbnail. See dequeue_pending_idle_callback(). */ + int preload_count; }; struct MimeListState { @@ -876,6 +891,8 @@ dequeue_pending_idle_callback (gpointer callback_data) GFileInfo *file_info; const char *mimetype, *name; DirectoryLoadState *dir_load_state; + gboolean fast_pass; + gboolean preloaded_any = FALSE; directory = NEMO_DIRECTORY (callback_data); @@ -929,6 +946,8 @@ dequeue_pending_idle_callback (gpointer callback_data) } } + fast_pass = dir_load_state != NULL && dir_load_state->fast_pass; + /* check if the file already exists */ file = nemo_directory_find_file_by_name (directory, name); if (file != NULL) { @@ -943,6 +962,14 @@ dequeue_pending_idle_callback (gpointer callback_data) nemo_file_ref (file); file->details->is_added = TRUE; added_files = g_list_prepend (added_files, file); + } else if (fast_pass && file->details->got_file_info) { + /* Revisiting a directory whose files are still in memory. The + * structure pass knows only the name and type, which is strictly + * less than this file already has, and applying it would blank + * out its size, timestamps and -- most visibly -- the path of its + * cached thumbnail, so the thumbnail would be built again from + * scratch. Leave the file alone; the detail pass refreshes it. + */ } else if (nemo_file_update_info (file, file_info)) { /* File changed, notify about the change. */ nemo_file_ref (file); @@ -951,12 +978,53 @@ dequeue_pending_idle_callback (gpointer callback_data) } else { /* new file, create a nemo file object and add it to the list */ file = nemo_file_new_from_info (directory, file_info); - nemo_directory_add_file (directory, file); + nemo_directory_add_file (directory, file); file->details->is_added = TRUE; added_files = g_list_prepend (added_files, file); + + if (fast_pass) { + /* Built from name and type alone: the mime type is a guess and + * nothing is known yet about a cached thumbnail. */ + file->details->content_type_is_guess = TRUE; + file->details->thumbnail_path_unknown = TRUE; + } + } + + if (dir_load_state != NULL && !fast_pass && file != NULL) { + /* The detail pass supplies a mime type guessed from the filename + * rather than sniffed from the contents -- see + * nemo_file_resolve_content_type() -- but it does fetch thumbnail::*, + * so any cached thumbnail is now known about. */ + file->details->content_type_is_guess = TRUE; + file->details->thumbnail_path_unknown = FALSE; + + /* If this file already has a thumbnail in the cache, start reading it + * in now, as the detail pass streams, rather than after the whole + * pass has finished or once the file is scrolled into view. Reading a + * cached thumbnail is cheap and local, and doing it here is what + * makes returning to a folder show its thumbnails straight away. + * + * Only files with something already cached are marked -- a NULL path + * would queue generation, which is the expensive half and stays + * limited to what is on screen. + */ + if (file->details->thumbnail_path != NULL && + file->details->thumbnail == NULL && + file->details->load_deferred_attrs == NEMO_FILE_LOAD_DEFERRED_ATTRS_NO && + dir_load_state->preload_count < directory->details->max_deferred_file_count) { + file->details->load_deferred_attrs = NEMO_FILE_LOAD_DEFERRED_ATTRS_PRELOAD; + dir_load_state->preload_count += 1; + preloaded_any = TRUE; + + nemo_directory_add_file_to_work_queue (directory, file); + } } } + if (preloaded_any) { + nemo_directory_async_state_changed (directory); + } + /* If we are done loading, then we assume that any unconfirmed * files are gone. */ @@ -1044,10 +1112,80 @@ directory_load_one (NemoDirectory *directory, uri = nemo_directory_get_uri (directory); g_warning ("Got GFileInfo with NULL name in %s, ignoring. This shouldn't happen unless the gvfs backend is broken.\n", uri); g_free (uri); - + return; } - + + if (directory->details->directory_load_in_progress != NULL) { + directory->details->directory_load_in_progress->seen_file_count += 1; + } + + if (directory->details->directory_load_in_progress != NULL) { + const char *name; + char *content_type; + char *display_name; + GIcon *icon; + gsize len; + + name = g_file_info_get_name (info); + len = strlen (name); + + if (directory->details->directory_load_in_progress->fast_pass) { + /* The fast pass cannot ask for standard::is-hidden or + * standard::is-backup without giving up its stat-free path, so derive + * them from the name exactly as GLocalFileInfo would. This keeps + * dot-files from flashing into view for the moment between the two + * passes. Names listed in a .hidden file are missed here and picked + * up by the full pass. + */ + g_file_info_set_attribute_boolean (info, G_FILE_ATTRIBUTE_STANDARD_IS_HIDDEN, + name[0] == '.'); + g_file_info_set_attribute_boolean (info, G_FILE_ATTRIBUTE_STANDARD_IS_BACKUP, + len > 0 && name[len - 1] == '~'); + + /* nemo_file_update_info() reads standard::display-name, and GFileInfo + * is loud about being asked for an attribute that was never + * requested. Derive it the same way GLocalFileInfo does. */ + display_name = g_filename_display_name (name); + g_file_info_set_display_name (info, display_name); + g_file_info_set_edit_name (info, display_name); + g_free (display_name); + } + + /* Neither pass asks for standard::content-type or standard::icon, because + * resolving either makes GLib read the file to sniff it -- see + * NEMO_FILE_BULK_ENUM_ATTRIBUTES. Guess the type from the name instead, + * which costs nothing, and derive the icon from that. Both keys are set: + * nemo_get_best_guess_file_mimetype() only consults fast-content-type for + * files of known non-zero size, and the fast pass has no size, so without + * the content-type key those files end up with no mime type at all and + * nemo_can_thumbnail() refuses them. + */ + if (g_file_info_get_file_type (info) == G_FILE_TYPE_DIRECTORY) { + content_type = g_strdup ("inode/directory"); + } else { + content_type = g_content_type_guess (name, NULL, 0, NULL); + } + + if (content_type != NULL) { + g_file_info_set_attribute_string (info, + G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + content_type); + g_file_info_set_attribute_string (info, + G_FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE, + content_type); + + icon = g_content_type_get_icon (content_type); + + if (icon != NULL) { + g_file_info_set_icon (info, icon); + g_object_unref (icon); + } + + g_free (content_type); + } + } + /* Arrange for the "loading" part of the work. */ g_object_ref (info); directory->details->pending_file_info @@ -1098,8 +1236,14 @@ directory_load_done (NemoDirectory *directory, { GList *node; - directory->details->directory_loaded = TRUE; - directory->details->directory_loaded_sent_notification = FALSE; + /* The two-pass load already declares the directory loaded, and emits + * done_loading, as soon as the structure pass finishes -- see + * more_files_callback(). Don't arm a second notification here, or the views + * would run end_loading twice for one load. */ + if (!directory->details->directory_loaded) { + directory->details->directory_loaded = TRUE; + directory->details->directory_loaded_sent_notification = FALSE; + } if (error != NULL) { /* The load did not complete successfully. This means @@ -2016,8 +2160,66 @@ more_files_callback (GObject *source_object, } if (files == NULL) { - directory_load_done (directory, error); - directory_load_state_free (state); + if (state->fast_pass && error == NULL && state->directory != NULL) { + /* The cheap name+type pass is finished, so the view already has + * every row with the right folder-vs-file icon. Go round again + * with the full attribute set to fill in the details. */ + if (g_getenv ("NEMO_BENCHMARK_LOADING")) { + g_printerr ("Directory structure pass (name+type): %.3f seconds, %d entries\n", + (g_get_monotonic_time () - state->load_start_us) / 1000000.0, + state->seen_file_count); + } + + g_file_enumerator_close_async (state->enumerator, 0, NULL, NULL, NULL); + g_clear_object (&state->enumerator); + + /* Every entry in the directory is now known, so as far as the views + * are concerned the folder is loaded -- what is left is refining + * attributes on files that are already on screen. Say so now rather + * than after the detail pass, because end_loading() is what lets a + * view start fetching thumbnails and other deferred attributes for + * what the user is actually looking at. Waiting would leave a + * screenful of generic icons for as long as the detail pass takes. + */ + directory->details->directory_loaded = TRUE; + directory->details->directory_loaded_sent_notification = FALSE; + + if (directory->details->dequeue_pending_idle_id != 0) { + g_source_remove (directory->details->dequeue_pending_idle_id); + directory->details->dequeue_pending_idle_id = 0; + } + + /* Flush the files this pass turned up and emit done_loading. This + * has to happen while the state still says fast_pass, because that + * is what tells dequeue_pending_idle_callback() the files it is + * adding have no thumbnail information yet -- clearing the flag + * first would let them be thumbnailed from scratch even when a + * cached thumbnail exists. It also has to precede resetting + * load_file_count, so the directory's item count is right. + */ + dequeue_pending_idle_callback (directory); + + state->fast_pass = FALSE; + state->seen_file_count = 0; + state->load_file_count = 0; + + g_file_enumerate_children_async (directory->details->location, + NEMO_FILE_BULK_ENUM_ATTRIBUTES, + 0, /* flags */ + G_PRIORITY_DEFAULT, /* prio */ + state->cancellable, + enumerate_children_callback, + state); + } else { + if (g_getenv ("NEMO_BENCHMARK_LOADING")) { + g_printerr ("Directory detail pass (full attributes): %.3f seconds, %d entries\n", + (g_get_monotonic_time () - state->load_start_us) / 1000000.0, + state->seen_file_count); + } + + directory_load_done (directory, error); + directory_load_state_free (state); + } } else { g_file_enumerator_next_files_async (state->enumerator, DIRECTORY_LOAD_ITEMS_PER_CALLBACK, @@ -2102,6 +2304,7 @@ start_monitoring_file_list (NemoDirectory *directory) state->cancellable = g_cancellable_new (); state->load_mime_list_hash = istr_set_new (); state->load_file_count = 0; + state->load_start_us = g_get_monotonic_time (); g_assert (directory->details->location != NULL); state->load_directory_file = @@ -2113,9 +2316,32 @@ start_monitoring_file_list (NemoDirectory *directory) #endif directory->details->directory_load_in_progress = state; - + + /* Two passes. The first asks only for what readdir() already knows -- name + * and type -- which costs no per-file stat at all, so the view can be built + * with the right rows/icons and correct folder-vs-file distinction almost + * immediately. The second pass re-enumerates with the full attribute set and + * fills in sizes, times, permissions, content types and so on, updating the + * files that are already on screen. + * + * The second pass is another *bulk* enumeration rather than a per-file + * query, deliberately: file_info_start() only ever has one query in flight + * per directory, so filling in a large directory file-by-file would serialise + * thousands of round trips on a network mount and be far slower than the + * single full enumeration this replaces. + * + * Files from the first pass are deliberately left marked as having valid + * info even though only their name and type is known. The views refuse to + * display a file until NEMO_FILE_ATTRIBUTE_INFO is ready (see + * ready_to_load() in nemo-view.c), so marking them stale would hold the + * entire listing back until the detail pass finished and defeat the point + * of splitting the load. Sizes and timestamps read as unknown for the brief + * moment between the two passes, and the detail pass overwrites them. + */ + state->fast_pass = TRUE; + g_file_enumerate_children_async (directory->details->location, - NEMO_FILE_DEFAULT_ATTRIBUTES, + NEMO_FILE_FAST_ENUM_ATTRIBUTES, 0, /* flags */ G_PRIORITY_DEFAULT, /* prio */ state->cancellable, @@ -3203,6 +3429,12 @@ query_info_callback (GObject *source_object, } else { nemo_file_update_info (get_info_file, info); g_object_unref (info); + + /* This query asks for NEMO_FILE_DEFAULT_ATTRIBUTES, which includes + * standard::content-type and thumbnail::*, so the mime type is now the + * real one rather than a guess, and any cached thumbnail is known. */ + get_info_file->details->content_type_is_guess = FALSE; + get_info_file->details->thumbnail_path_unknown = FALSE; } nemo_file_changed (get_info_file); @@ -3632,6 +3864,9 @@ thumbnail_done (NemoDirectory *directory, time_t thumb_mtime = 0; file->details->thumbnail_is_up_to_date = TRUE; + /* The whole job -- generate, then read back -- is over now, whether or not + * it produced anything. See thumbnail_thread_notify_file_changed(). */ + file->details->is_thumbnailing = FALSE; file->details->thumbnail_tried_original = tried_original; if (file->details->thumbnail) { g_object_unref (file->details->thumbnail); diff --git a/libnemo-private/nemo-directory.c b/libnemo-private/nemo-directory.c index 0a11ae58dd..36d0918298 100644 --- a/libnemo-private/nemo-directory.c +++ b/libnemo-private/nemo-directory.c @@ -641,7 +641,17 @@ nemo_directory_add_file (NemoDirectory *directory, NemoFile *file) directory->details->confirmed_file_count++; if (directory->details->early_load_file_count++ < directory->details->max_deferred_file_count) { - file->details->load_deferred_attrs = NEMO_FILE_LOAD_DEFERRED_ATTRS_PRELOAD; + /* While the directory is still enumerating we only count the file. Marking + * it PRELOAD here would start thumbnail I/O and decoding immediately, in + * competition with the enumeration itself, and reflow the view as each + * thumbnail lands. The budget is handed out in + * nemo_directory_emit_done_loading() instead, once the file list is + * complete. Files that appear after the load (created, moved in) are not + * racing anything, so they preload straight away. + */ + if (directory->details->directory_loaded) { + file->details->load_deferred_attrs = NEMO_FILE_LOAD_DEFERRED_ATTRS_PRELOAD; + } } add_to_work_queue = FALSE; @@ -785,6 +795,16 @@ nemo_directory_emit_done_loading (NemoDirectory *directory) g_signal_emit (directory, signals[DONE_LOADING], 0); + + /* No blanket preload of deferred attributes here. The file list is stored in + * the order the directory was read, which has nothing to do with the sort + * order on screen, so preloading the first N of it spends the thumbnailer -- + * and, on a network share, the whole link -- on files the user is not + * looking at, ahead of the ones they are. The views promote exactly what is + * on screen as soon as end_loading runs, which for a small folder is every + * file anyway. + */ + nemo_directory_async_state_changed (directory); } void diff --git a/libnemo-private/nemo-file-private.h b/libnemo-private/nemo-file-private.h index eb65290d19..7e602c5922 100644 --- a/libnemo-private/nemo-file-private.h +++ b/libnemo-private/nemo-file-private.h @@ -35,6 +35,42 @@ #define NEMO_FILE_DEFAULT_ATTRIBUTES \ "standard::*,access::*,mountable::*,time::*,unix::*,owner::*,selinux::*,thumbnail::*,id::filesystem,trash::orig-path,trash::deletion-date,metadata::*,preview::icon" +/* The cheapest enumeration a directory listing can be built from: exactly the + * information readdir() already returns. GLocalFileEnumerator only skips the + * per-file stat when the matcher asks for nothing beyond name and type, so this + * list must not grow -- adding even standard::is-hidden costs a stat per file + * (and, on a network mount, a round trip per file). Everything else is filled + * in by the second, full-attribute pass. See start_monitoring_file_list(). + */ +#define NEMO_FILE_FAST_ENUM_ATTRIBUTES \ + "standard::name,standard::type" + +/* The attribute set the second, full pass enumerates with. Everything in + * NEMO_FILE_DEFAULT_ATTRIBUTES except standard::content-type and standard::icon, + * which are replaced by standard::fast-content-type. + * + * Asking for standard::content-type makes GLib fall back to sniffing the file's + * magic bytes whenever the name alone is not conclusive -- and on a stock + * shared-mime-info that includes every .png. In a folder of a few thousand + * images on a network share that is one read per file: 20.5s versus 0.41s here. + * standard::icon is excluded for the same reason, since deriving it resolves the + * content type; directory_load_one() supplies an icon from the guessed type + * instead. + * + * The guess comes from the filename and is right for any normally-named file. + * Where it is not -- an extensionless or mislabelled file -- the real type is + * resolved per file once it is scrolled into view, via + * nemo_file_resolve_content_type(). + */ +#define NEMO_FILE_BULK_ENUM_ATTRIBUTES \ + "standard::name,standard::type,standard::size,standard::allocated-size," \ + "standard::is-hidden,standard::is-backup,standard::is-symlink," \ + "standard::symlink-target,standard::display-name,standard::edit-name," \ + "standard::copy-name,standard::fast-content-type,standard::target-uri," \ + "standard::sort-order,standard::description," \ + "access::*,mountable::*,time::*,unix::*,owner::*,selinux::*,thumbnail::*," \ + "id::filesystem,trash::orig-path,trash::deletion-date,metadata::*,preview::icon" + /* These are in the typical sort order. Known things come first, then * things where we can't know, finally things where we don't yet know. */ @@ -166,6 +202,17 @@ struct NemoFileDetails eel_boolean_bit got_file_info : 1; eel_boolean_bit get_info_failed : 1; eel_boolean_bit file_info_is_up_to_date : 1; + /* The mime type came from the filename rather than the file's contents, + * because the info was filled in by a bulk enumeration. Cleared once a + * per-file query has supplied the real one. See + * nemo_file_resolve_content_type(). */ + eel_boolean_bit content_type_is_guess : 1; + /* Set while the only info this file has is from the structure pass, which + * does not fetch thumbnail::*. thumbnail_path being NULL therefore means + * "not looked up yet", not "no thumbnail exists", and a thumbnail must not + * be generated on the strength of it -- that would rebuild thumbnails that + * are already cached. Cleared by the detail pass and by per-file queries. */ + eel_boolean_bit thumbnail_path_unknown : 1; eel_boolean_bit got_directory_count : 1; eel_boolean_bit directory_count_failed : 1; diff --git a/libnemo-private/nemo-file.c b/libnemo-private/nemo-file.c index 31faebe31c..37dbd1ff2c 100644 --- a/libnemo-private/nemo-file.c +++ b/libnemo-private/nemo-file.c @@ -5058,17 +5058,22 @@ nemo_file_get_icon (NemoFile *file, return icon; } else if (file->details->thumbnail_path == NULL && + !file->details->thumbnail_path_unknown && file->details->can_read && !file->details->is_thumbnailing && !file->details->thumbnailing_failed) { + /* thumbnail_path_unknown means the structure pass is all we have + * so far, and it does not fetch thumbnail::*. Generating now would + * rebuild a thumbnail that is very likely already in the cache, so + * wait for the detail pass -- a fraction of a second -- to say + * whether one exists. */ if (nemo_can_thumbnail (file)) { nemo_create_thumbnail (file); } } } - if (file->details->is_thumbnailing && - flags & NEMO_FILE_ICON_FLAGS_USE_THUMBNAILS) + if ((flags & NEMO_FILE_ICON_FLAGS_USE_THUMBNAILS) && file->details->is_thumbnailing) gicon = g_themed_icon_new (ICON_NAME_THUMBNAIL_LOADING); else gicon = nemo_file_get_gicon (file, flags); @@ -8470,6 +8475,32 @@ nemo_file_get_load_deferred_attrs (NemoFile *file) return file->details->load_deferred_attrs; } +/** + * Directory enumeration fills a file's mime type in from its name, because + * asking GLib for standard::content-type during a bulk listing makes it read + * every file whose name is not conclusive, slow on a network share. The + * guess is right for any normally-named file, but not for one with no extension + * or the wrong one. + * + * Call this for files that have come into view to have the real type fetched, + * one query per file. + */ +void +nemo_file_resolve_content_type (NemoFile *file) +{ + g_return_if_fail (NEMO_IS_FILE (file)); + + if (!file->details->content_type_is_guess || file->details->is_gone) { + return; + } + + /* Clear it first: the query is asynchronous and this must not queue a second + * one for the same file in the meantime. */ + file->details->content_type_is_guess = FALSE; + + nemo_file_invalidate_attributes (file, NEMO_FILE_ATTRIBUTE_INFO); +} + void nemo_file_invalidate_attributes_internal (NemoFile *file, NemoFileAttributes file_attributes) diff --git a/libnemo-private/nemo-file.h b/libnemo-private/nemo-file.h index 12f1baf569..a594258a10 100644 --- a/libnemo-private/nemo-file.h +++ b/libnemo-private/nemo-file.h @@ -545,6 +545,7 @@ void nemo_file_set_is_favorite (NemoFile *file, gboolean favo void nemo_file_set_load_deferred_attrs (NemoFile *file, NemoFileLoadDeferredAttrs load_deferred_attrs); NemoFileLoadDeferredAttrs nemo_file_get_load_deferred_attrs (NemoFile *file); +void nemo_file_resolve_content_type (NemoFile *file); gboolean nemo_file_add_search_result_data (NemoFile *file, gpointer search_dir, FileSearchResult *result); void nemo_file_clear_search_result_data (NemoFile *file, gpointer search_dir); diff --git a/libnemo-private/nemo-icon-container.c b/libnemo-private/nemo-icon-container.c index b24d3da2b1..ccb45f6f1f 100644 --- a/libnemo-private/nemo-icon-container.c +++ b/libnemo-private/nemo-icon-container.c @@ -5653,6 +5653,7 @@ update_visible_icons_cb (NemoIconContainer *container) GList *node; NemoIcon *icon; gboolean visible; + gboolean found_visible; GtkAllocation allocation; container->details->update_visible_icons_id = 0; @@ -5672,6 +5673,8 @@ update_visible_icons_cb (NemoIconContainer *container) eel_canvas_c2w (EEL_CANVAS (container), max_x, max_y, &max_x, &max_y); + found_visible = FALSE; + for (node = g_list_last (container->details->icons); node != NULL; node = node->prev) { icon = node->data; @@ -5702,30 +5705,57 @@ update_visible_icons_cb (NemoIconContainer *container) if (visible) { nemo_icon_canvas_item_set_is_visible (icon->item, TRUE); - NemoFile *file = NEMO_FILE (icon->data); - - if (!icon->ok_to_show_thumb) { - - icon->ok_to_show_thumb = TRUE; - - if (nemo_file_get_load_deferred_attrs (file) == NEMO_FILE_LOAD_DEFERRED_ATTRS_NO) { - nemo_file_set_load_deferred_attrs (file, NEMO_FILE_LOAD_DEFERRED_ATTRS_YES); + found_visible = TRUE; + + /* Deferred attributes (thumbnails, extension info) stay + * unrequested until the directory has finished loading -- see + * nemo_icon_container_set_ok_to_load_deferred_attrs(), which + * re-queues this callback from end_loading(). Fetching them + * mid-load makes thumbnail I/O and decoding compete with the + * directory enumeration, and every thumbnail that arrives + * resizes an icon and reflows the layout under the user. + */ + if (container->details->ok_to_load_deferred_attrs) { + NemoFile *file = NEMO_FILE (icon->data); + + /* The listing supplied a mime type guessed from the name; + * now that this file is on screen, fetch the real one. */ + nemo_file_resolve_content_type (file); + + if (!icon->ok_to_show_thumb) { + + icon->ok_to_show_thumb = TRUE; + + if (nemo_file_get_load_deferred_attrs (file) == NEMO_FILE_LOAD_DEFERRED_ATTRS_NO) { + nemo_file_set_load_deferred_attrs (file, NEMO_FILE_LOAD_DEFERRED_ATTRS_YES); + } + + nemo_file_invalidate_attributes (file, NEMO_FILE_DEFERRED_ATTRIBUTES); + } else { + gchar *uri = nemo_file_get_uri (file); + nemo_thumbnail_prioritize (uri); + g_free (uri); } - nemo_file_invalidate_attributes (file, NEMO_FILE_DEFERRED_ATTRIBUTES); - } else { - gchar *uri = nemo_file_get_uri (file); - nemo_thumbnail_prioritize (uri); - g_free (uri); + nemo_icon_container_update_icon (container, icon); } - - nemo_icon_container_update_icon (container, icon); } else { nemo_icon_canvas_item_set_is_visible (icon->item, FALSE); } } } + /* A large folder can finish loading before its icons have been laid out, in + * which case nothing is positioned yet, nothing is found visible, and there + * is no scroll or adjustment change coming to trigger another pass -- so the + * icons the user is looking at would never have their thumbnails requested + * until they happened to scroll. Try again shortly; once the layout lands + * this settles on the first attempt. + */ + if (!found_visible && container->details->icons != NULL) { + queue_update_visible_icons (container, NORMAL_UPDATE_VISIBLE_DELAY); + } + return G_SOURCE_REMOVE; } @@ -8254,7 +8284,7 @@ nemo_icon_container_set_ok_to_load_deferred_attrs (NemoIconContainer *container, container->details->ok_to_load_deferred_attrs = ok; if (ok) { - queue_update_visible_icons (container, INITIAL_UPDATE_VISIBLE_DELAY); + queue_update_visible_icons (container, NORMAL_UPDATE_VISIBLE_DELAY); } } diff --git a/libnemo-private/nemo-thumbnails.c b/libnemo-private/nemo-thumbnails.c index eabd3eb542..042d62b24b 100644 --- a/libnemo-private/nemo-thumbnails.c +++ b/libnemo-private/nemo-thumbnails.c @@ -45,6 +45,13 @@ #include #include +#include +#include + +#ifdef HAVE_EXIF + #include +#endif + #define DEBUG_FLAG NEMO_DEBUG_THUMBNAILS #include @@ -52,6 +59,18 @@ #define DEBUG_THREADS 0 +/* Pixel size the factory below is created with (GNOME_DESKTOP_THUMBNAIL_SIZE_LARGE). */ +#define THUMBNAIL_PIXEL_SIZE 256 + +/* An EXIF APP1 segment is at most 64K, and starts within the first few bytes of + * the file, so this always covers it. */ +#define EMBEDDED_THUMBNAIL_PROBE_BYTES (80 * 1024) + +/* How far the embedded thumbnail's shape may differ from the dimensions EXIF + * records for the main image before we stop trusting it. Catches files that + * were cropped by something that did not refresh the embedded thumbnail. */ +#define EMBEDDED_THUMBNAIL_ASPECT_TOLERANCE 0.05 + /* Should never be a reasonable actual mtime */ #define INVALID_MTIME 0 @@ -130,15 +149,12 @@ get_max_threads (void) { gint pref = g_settings_get_int (nemo_preferences, NEMO_PREFERENCES_MAX_THUMBNAIL_THREADS); if (pref == -1) { - if (num_processors >= 8) { - max_threads = 4; - } - else if (num_processors >= 4) { - max_threads = 2; - } - else { - max_threads = 1; - } + /* Thumbnailing is CPU-bound (decode, scale, re-encode), so the pool + * should scale with the machine. Leave a couple of cores for the UI + * and the rest of the session, and cap it so that very large machines + * don't just thrash memory bandwidth and the thumbnail cache dir. + */ + max_threads = CLAMP (num_processors - 2, 1, 8); } else { max_threads = pref; } @@ -287,10 +303,39 @@ thumbnail_thread_notify_file_changed (gpointer image_uri) DEBUG ("(Thumbnail Thread) Notifying file changed file: %p uri: %s", file, (char*) image_uri); if (file != NULL) { - nemo_file_set_is_thumbnailing (file, FALSE); - nemo_file_invalidate_attributes (file, - NEMO_FILE_ATTRIBUTE_THUMBNAIL | - NEMO_FILE_ATTRIBUTE_INFO); + char *path; + + /* Ask the factory where it just wrote the thumbnail rather than + * re-reading the file's info purely to learn thumbnail::path back. That + * query is a network round trip per thumbnail on a remote share, it is + * serialised one-at-a-time per directory, and for types whose content + * type is ambiguous from the name (.png on many systems) it makes GLib + * re-read the file to sniff it. The lookup here is local: a hash of the + * uri and a stat in the thumbnail cache. Without this, thumbnails for + * the files on screen can be generated but not appear for many seconds. + */ + path = gnome_desktop_thumbnail_factory_lookup (get_thumbnail_factory (), + (char *) image_uri, + nemo_file_get_mtime (file)); + + if (path != NULL) { + g_free (file->details->thumbnail_path); + file->details->thumbnail_path = path; + + /* Leave is_thumbnailing set: the job is not finished until the + * result has been read back in, and clearing it here would drop the + * indicator to the generic icon for that moment. thumbnail_done() + * clears it once the pixbuf is in hand, or the attempt fails. */ + nemo_file_invalidate_attributes (file, NEMO_FILE_ATTRIBUTE_THUMBNAIL); + } else { + /* Nothing was produced, so there is no read to wait for. */ + nemo_file_set_is_thumbnailing (file, FALSE); + + nemo_file_invalidate_attributes (file, + NEMO_FILE_ATTRIBUTE_THUMBNAIL | + NEMO_FILE_ATTRIBUTE_INFO); + } + nemo_file_unref (file); } @@ -310,6 +355,205 @@ remove_from_hash_table (NemoThumbnailInfo *info) free_thumbnail_info (info); } +#ifdef HAVE_EXIF +static gboolean +exif_get_long_tag (ExifData *ed, + ExifTag tag, + glong *out) +{ + ExifEntry *entry; + ExifByteOrder order; + int i; + + order = exif_data_get_byte_order (ed); + + for (i = 0; i < EXIF_IFD_COUNT; i++) { + entry = exif_content_get_entry (ed->ifd[i], tag); + + if (entry == NULL) { + continue; + } + + if (entry->format == EXIF_FORMAT_SHORT) { + *out = exif_get_short (entry->data, order); + return TRUE; + } + + if (entry->format == EXIF_FORMAT_LONG) { + *out = exif_get_long (entry->data, order); + return TRUE; + } + } + + return FALSE; +} +#endif /* HAVE_EXIF */ + +/* A JPEG written by a camera or phone almost always carries a postcard-sized + * copy of itself in its EXIF header. Decoding that costs a ~20KB read instead of + * pulling the whole multi-megabyte original, which is the difference between a + * folder of photos thumbnailing in seconds and in minutes when it lives on a + * network share. + * + * Returns NULL whenever the embedded copy cannot be trusted to be as good as + * decoding the original -- wrong format, absent, too small for the thumbnail we + * are about to store, or a different shape from the image it claims to preview. + * The caller then falls back to the normal full-file thumbnailer. + */ +static GdkPixbuf * +try_embedded_thumbnail (const char *uri, + const char *mime_type, + int target_size) +{ +#ifdef HAVE_EXIF + GFile *file; + GFileInputStream *stream; + GdkPixbufLoader *loader; + GdkPixbuf *pixbuf = NULL; + GdkPixbuf *rotated; + ExifData *ed; + guchar *buf; + gssize len; + glong orientation, main_w, main_h; + int w, h; + + if (g_strcmp0 (mime_type, "image/jpeg") != 0) { + return NULL; + } + + file = g_file_new_for_uri (uri); + stream = g_file_read (file, NULL, NULL); + g_object_unref (file); + + if (stream == NULL) { + return NULL; + } + + /* Only the header is wanted. Without this the kernel reads ahead to satisfy + * a sequential-access guess, on shares with larger rsize configuration, an 80KB read + * would could drag megabytes per file over the wire and undo the entire saving. */ + if (G_IS_FILE_DESCRIPTOR_BASED (stream)) { + int fd = g_file_descriptor_based_get_fd (G_FILE_DESCRIPTOR_BASED (stream)); + + if (fd >= 0) { + posix_fadvise (fd, 0, 0, POSIX_FADV_RANDOM); + } + } + + buf = g_malloc (EMBEDDED_THUMBNAIL_PROBE_BYTES); + len = g_input_stream_read (G_INPUT_STREAM (stream), + buf, EMBEDDED_THUMBNAIL_PROBE_BYTES, NULL, NULL); + g_input_stream_close (G_INPUT_STREAM (stream), NULL, NULL); + g_object_unref (stream); + + if (len <= 0) { + g_free (buf); + return NULL; + } + + ed = exif_data_new_from_data (buf, (unsigned int) len); + g_free (buf); + + if (ed == NULL) { + return NULL; + } + + if (ed->data == NULL || ed->size == 0) { + exif_data_unref (ed); + return NULL; + } + + loader = gdk_pixbuf_loader_new_with_mime_type ("image/jpeg", NULL); + + if (loader != NULL) { + if (gdk_pixbuf_loader_write (loader, ed->data, ed->size, NULL)) { + if (gdk_pixbuf_loader_close (loader, NULL)) { + pixbuf = gdk_pixbuf_loader_get_pixbuf (loader); + + if (pixbuf != NULL) { + g_object_ref (pixbuf); + } + } + } else { + gdk_pixbuf_loader_close (loader, NULL); + } + + g_object_unref (loader); + } + + if (pixbuf == NULL) { + exif_data_unref (ed); + return NULL; + } + + w = gdk_pixbuf_get_width (pixbuf); + h = gdk_pixbuf_get_height (pixbuf); + + /* Never trade quality for speed: if the embedded copy is smaller than the + * thumbnail we would store, decode the original instead. */ + if (MAX (w, h) < target_size) { + DEBUG ("(Thumbnail Thread) Embedded thumbnail too small (%dx%d < %d): %s", + w, h, target_size, uri); + g_object_unref (pixbuf); + exif_data_unref (ed); + return NULL; + } + + if (exif_get_long_tag (ed, EXIF_TAG_PIXEL_X_DIMENSION, &main_w) && + exif_get_long_tag (ed, EXIF_TAG_PIXEL_Y_DIMENSION, &main_h) && + main_w > 0 && main_h > 0) { + double embedded_aspect = (double) w / (double) h; + double main_aspect = (double) main_w / (double) main_h; + + /* Orientation may have the two rotated relative to each other. */ + if (fabs (embedded_aspect - main_aspect) > EMBEDDED_THUMBNAIL_ASPECT_TOLERANCE * main_aspect && + fabs (embedded_aspect - 1.0 / main_aspect) > EMBEDDED_THUMBNAIL_ASPECT_TOLERANCE / main_aspect) { + DEBUG ("(Thumbnail Thread) Embedded thumbnail shape %dx%d does not match image %ldx%ld: %s", + w, h, main_w, main_h, uri); + g_object_unref (pixbuf); + exif_data_unref (ed); + return NULL; + } + } + + /* The orientation tag describes the main image; the embedded copy is stored + * the same way round, so the same rotation applies. */ + if (exif_get_long_tag (ed, EXIF_TAG_ORIENTATION, &orientation) && + orientation >= 1 && orientation <= 8) { + char value[2] = { '0' + (char) orientation, '\0' }; + + gdk_pixbuf_set_option (pixbuf, "orientation", value); + } + + exif_data_unref (ed); + + rotated = gdk_pixbuf_apply_embedded_orientation (pixbuf); + g_object_unref (pixbuf); + pixbuf = rotated; + + w = gdk_pixbuf_get_width (pixbuf); + h = gdk_pixbuf_get_height (pixbuf); + + /* Store it at the same size the factory would have produced. */ + if (MAX (w, h) > target_size) { + double scale = (double) target_size / (double) MAX (w, h); + + rotated = gdk_pixbuf_scale_simple (pixbuf, + MAX (w * scale, 1), + MAX (h * scale, 1), + GDK_INTERP_BILINEAR); + g_object_unref (pixbuf); + pixbuf = rotated; + } + + DEBUG ("(Thumbnail Thread) Used embedded thumbnail: %s", uri); + + return pixbuf; +#else + return NULL; +#endif /* HAVE_EXIF */ +} + /* Thumbnail thread */ static void thumbnail_thread (gpointer data, @@ -367,9 +611,13 @@ thumbnail_thread (gpointer data, * because of that we have to convert our path from the network URI to a local file:// URI or else any * thumbnailers that use %i wont generate thumbnails correctly */ - pixbuf = gnome_desktop_thumbnail_factory_generate_thumbnail (thumbnail_factory, - image_uri, - info->mime_type); + pixbuf = try_embedded_thumbnail (image_uri, info->mime_type, THUMBNAIL_PIXEL_SIZE); + + if (pixbuf == NULL) { + pixbuf = gnome_desktop_thumbnail_factory_generate_thumbnail (thumbnail_factory, + image_uri, + info->mime_type); + } if (free_uri) { g_free (image_uri); } diff --git a/src/nemo-list-view.c b/src/nemo-list-view.c index 1411c42eae..2fe7e4d2a0 100644 --- a/src/nemo-list-view.c +++ b/src/nemo-list-view.c @@ -1584,7 +1584,7 @@ set_ok_to_load_deferred_attrs (NemoListView *list_view, list_view->details->ok_to_load_deferred_attrs = ok; if (ok) { - queue_update_visible_icons (list_view, INITIAL_UPDATE_VISIBLE_DELAY); + queue_update_visible_icons (list_view, NORMAL_UPDATE_VISIBLE_DELAY); } } @@ -2378,6 +2378,10 @@ prioritize_visible_files (NemoListView *view) if (file != NULL && file != last_file) { last_file = file; + /* The listing supplied a mime type guessed from the name; now + * that this row is on screen, fetch the real one. */ + nemo_file_resolve_content_type (file); + if (nemo_file_get_load_deferred_attrs (file) == NEMO_FILE_LOAD_DEFERRED_ATTRS_NO) { nemo_file_set_load_deferred_attrs (file, NEMO_FILE_LOAD_DEFERRED_ATTRS_YES); } @@ -2402,9 +2406,21 @@ prioritize_visible_files (NemoListView *view) static gboolean update_visible_icons_cb (NemoListView *view) { + view->details->update_visible_icons_id = 0; + + /* Deferred attributes (thumbnails, extension info) stay unrequested until + * the directory has finished loading, see set_ok_to_load_deferred_attrs(), + * which re-queues this callback from end_loading(). Fetching them mid-load + * makes thumbnail I/O and decoding compete with the directory enumeration, + * and every thumbnail that arrives resizes a row and shifts the rows below + * it under the user. + */ + if (!view->details->ok_to_load_deferred_attrs) { + return G_SOURCE_REMOVE; + } + prioritize_visible_files (view); - view->details->update_visible_icons_id = 0; return G_SOURCE_REMOVE; } From 2b6743e6df96f572a259b1743bcaf3dce10a3e78 Mon Sep 17 00:00:00 2001 From: Paul van den Burg <1727604+paulvandenburg@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:19:17 +0200 Subject: [PATCH 2/3] Add .editorconfig --- .editorconfig | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..f3c3b9afc1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true +# https://linuxmint-developer-guide.readthedocs.io/en/latest/guidelines.html + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +tab_width = 4 +max_line_length = 120 +trim_trailing_whitespace = true +insert_final_newline = true From 84d639321d924b8abec7eddbe0507a6fe981f096 Mon Sep 17 00:00:00 2001 From: Paul van den Burg <1727604+paulvandenburg@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:46:21 +0200 Subject: [PATCH 3/3] nemo-window-manage-views.c: Fix crash freeing a location change reentrantly. free_location_change() null-checks slot->new_content_view, then calls nemo_view_stop_loading() on it. That removes the view's directory monitor, which runs the directory's async state machine and can emit signals that come back round to free_location_change() for the same slot. The reentrant call clears and unrefs slot->new_content_view, so when the outer call resumes it hands NULL to nemo_window_disconnect_content_view(), which asserts on it and aborts. --- src/nemo-window-manage-views.c | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/nemo-window-manage-views.c b/src/nemo-window-manage-views.c index 96c2e575a0..b562e89368 100644 --- a/src/nemo-window-manage-views.c +++ b/src/nemo-window-manage-views.c @@ -1661,13 +1661,26 @@ free_location_change (NemoWindowSlot *slot) } if (slot->new_content_view != NULL) { + NemoView *new_view; + + /* Detach before doing anything with it. nemo_view_stop_loading() + * removes the view's directory monitor, which runs the directory's + * async state machine and can emit signals that come back round to + * free_location_change() for the same slot -- and that reentrant call + * would clear and unref slot->new_content_view while this one is still + * using it. Holding our own reference and clearing the field first + * makes the second call a no-op instead of a NULL passed to + * nemo_window_disconnect_content_view(), which asserts on it. + */ + new_view = slot->new_content_view; + slot->new_content_view = NULL; + window->details->temporarily_ignore_view_signals = TRUE; - nemo_view_stop_loading (slot->new_content_view); + nemo_view_stop_loading (new_view); window->details->temporarily_ignore_view_signals = FALSE; - nemo_window_disconnect_content_view (window, slot->new_content_view); - g_object_unref (slot->new_content_view); - slot->new_content_view = NULL; + nemo_window_disconnect_content_view (window, new_view); + g_object_unref (new_view); } }