Просмотр исходного кода

fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field

      First-attempt fix (d196cfc5) was wrong about the cause. Real root,
      traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
      support bundle:

      mqtt_server.py:1296 was passing the slicer's bare subtask_name
      (e.g. "Model_Name") into on_print_command, which stashed under
      that key. _add_to_print_queue looked up under file_path.name
      (the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
      two strings never matched. pop returned None, the 2s wait fired
      against a key the stash side never signaled, every captured
      slicer field silently fell back to settings defaults.

      Affected EVERY Bambu Studio "Send" upload across EVERY model —
      not just H2C nozzle_mapping. bed_leveling / flow_cali /
      vibration_cali / layer_inspect / timelapse from the original
      #1403 capture have been silently ignored since BambuStudio
      started splitting subtask_name (bare) from file (with extension).

      Unit tests passed because fixtures called on_print_command with
      file_path.name directly, bypassing the broken caller.

      Fix in manager.py::on_print_command: derive
      stash_key = data.get("file") or filename and use it for both
      _slicer_print_options and the event lookup. filename
      (subtask_name) still flows unchanged to _schedule_finish_release
      — push_status echoes it back as gcode_file / subtask_name and
      the slicer matches against its own subtask_name there, so
      re-routing that path was a separate regression I caught and
      reverted mid-audit.

      Also: nozzles_info field was a wrong guess in d196cfc5 —
      BambuStudio never sends it (confirmed via wire capture). Drop
      the capture, dispatch, schema, kwarg, and route paths. DB
      column stays nullable so old rows still load; nothing reads
      or writes it.

      Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
      options after the 2s wait, including the looked-up key and the
      actual cache keys present. Future stash/lookup mismatches will
      be obvious from a log line instead of needing a wire capture.

      Behaviour change worth flagging: users on Bambu Studio whose
      slicer-side bed-leveling / flow-cali / vibration-cali /
      layer-inspect / timelapse differ from Bambuddy's
      default-workflow settings will see their slicer choices
      honored now instead of silently overridden. Restores #1403's
      original intent.
maziggy 2 месяцев назад
Родитель
Сommit
30c2e263dd

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 60 - 16
backend/app/api/routes/library.py

@@ -750,11 +750,24 @@ async def list_folders(
     )
     )
     file_counts = dict(file_counts_result.all())
     file_counts = dict(file_counts_result.all())
 
 
+    # Latest immediate-child file activity per folder (#1770). Sibling of the
+    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
+    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
+    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    latest_file_activity_result = await db.execute(
+        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
+        .group_by(LibraryFile.folder_id)
+    )
+    latest_file_activity = dict(latest_file_activity_result.all())
+
     # Build tree structure
     # Build tree structure
     folder_map = {}
     folder_map = {}
     root_folders = []
     root_folders = []
 
 
     for folder, project_name, archive_name in rows:
     for folder, project_name, archive_name in rows:
+        latest_file = latest_file_activity.get(folder.id)
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
         folder_item = FolderTreeItem(
         folder_item = FolderTreeItem(
             id=folder.id,
             id=folder.id,
             name=folder.name,
             name=folder.name,
@@ -767,6 +780,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
             file_count=file_counts.get(folder.id, 0),
+            latest_activity_at=latest_activity_at,
             children=[],
             children=[],
         )
         )
         folder_map[folder.id] = folder_item
         folder_map[folder.id] = folder_item
@@ -804,14 +818,19 @@ async def get_folders_by_project(
 
 
     folders = []
     folders = []
     for folder, project_name in rows:
     for folder, project_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
             )
             )
         )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -827,6 +846,7 @@ async def get_folders_by_project(
                 external_readonly=folder.external_readonly,
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
             )
             )
@@ -857,14 +877,19 @@ async def get_folders_by_archive(
 
 
     folders = []
     folders = []
     for folder, archive_name in rows:
     for folder, archive_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
             )
             )
         )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -880,6 +905,7 @@ async def get_folders_by_archive(
                 external_readonly=folder.external_readonly,
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
                 updated_at=folder.updated_at,
             )
             )
@@ -943,6 +969,9 @@ async def create_folder(
         external_readonly=folder.external_readonly,
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
         file_count=0,
+        # New folder has no files yet — fall back to the folder's own
+        # updated_at so this matches the list-route semantics (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
     )
     )
@@ -973,14 +1002,19 @@ async def get_folder(
 
 
     folder, project_name, archive_name = row
     folder, project_name, archive_name = row
 
 
-    # Get file count
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) in one trip
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
             LibraryFile.deleted_at.is_(None),
         )
         )
     )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
 
     return FolderResponse(
     return FolderResponse(
         id=folder.id,
         id=folder.id,
@@ -995,6 +1029,7 @@ async def get_folder(
         external_readonly=folder.external_readonly,
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
     )
     )
@@ -1064,14 +1099,19 @@ async def update_folder(
     await db.commit()
     await db.commit()
     await db.refresh(folder)
     await db.refresh(folder)
 
 
-    # Get file count and names
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) and names
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
             LibraryFile.deleted_at.is_(None),
         )
         )
     )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
 
     # Get project and archive names
     # Get project and archive names
     project_name = None
     project_name = None
@@ -1096,6 +1136,7 @@ async def update_folder(
         external_readonly=folder.external_readonly,
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
     )
     )
@@ -1365,6 +1406,9 @@ async def create_external_folder(
         external_readonly=folder.external_readonly,
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
         file_count=0,
+        # Newly-created external folder hasn't been scanned yet — fall back
+        # to the folder's own updated_at (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
         updated_at=folder.updated_at,
     )
     )

+ 4 - 13
backend/app/api/routes/print_queue.py

@@ -142,22 +142,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             filament_overrides_parsed = None
             filament_overrides_parsed = None
 
 
-    # Parse nozzle_mapping + nozzles_info from JSON string (#1780 — H2C rack
-    # slicer-pick preservation). Both are nullable opaque JSON blobs stored
-    # verbatim from BambuStudio's project_file; surface them parsed for the
-    # response model and any future "edit print → nozzle" UI.
+    # Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick
+    # preservation). Nullable opaque JSON blob stored verbatim from
+    # BambuStudio's project_file; surface it parsed for the response model
+    # and any future "edit print → nozzle" UI.
     nozzle_mapping_parsed = None
     nozzle_mapping_parsed = None
     if item.nozzle_mapping:
     if item.nozzle_mapping:
         try:
         try:
             nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
             nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
             nozzle_mapping_parsed = None
-    nozzles_info_parsed = None
-    if item.nozzles_info:
-        try:
-            nozzles_info_parsed = json.loads(item.nozzles_info)
-        except json.JSONDecodeError:
-            nozzles_info_parsed = None
 
 
     # Create response with parsed ams_mapping
     # Create response with parsed ams_mapping
     item_dict = {
     item_dict = {
@@ -203,7 +197,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
         "nozzle_mapping": nozzle_mapping_parsed,
-        "nozzles_info": nozzles_info_parsed,
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
@@ -1035,8 +1028,6 @@ async def update_queue_item(
         update_data["nozzle_mapping"] = (
         update_data["nozzle_mapping"] = (
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
         )
-    if "nozzles_info" in update_data:
-        update_data["nozzles_info"] = json.dumps(update_data["nozzles_info"]) if update_data["nozzles_info"] else None
 
 
     for field, value in update_data.items():
     for field, value in update_data.items():
         setattr(item, field, value)
         setattr(item, field, value)

+ 8 - 5
backend/app/core/database.py

@@ -969,11 +969,14 @@ async def run_migrations(conn):
         await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
         await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
 
 
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
-    # slicer-pick preservation (#1780). Opaque JSON-string columns carrying
-    # BambuStudio's per-filament physical nozzle position IDs and the
-    # per-extruder rack metadata, forwarded straight from the VP intake to
-    # the dispatcher's project_file MQTT command. NULL on every other model.
-    # Nullable TEXT — no Postgres / SQLite divergence here.
+    # slicer-pick preservation (#1780). Opaque JSON-string column carrying
+    # BambuStudio's per-filament physical nozzle position IDs, forwarded
+    # straight from the VP intake to the dispatcher's project_file MQTT
+    # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
+    # divergence here. `nozzles_info` shipped in the original #1780 attempt
+    # but BambuStudio never actually sends it (verified via wire capture on
+    # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows
+    # still load; nothing reads or writes to it anymore.
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
 
 

+ 7 - 6
backend/app/models/print_queue.py

@@ -67,12 +67,13 @@ class PrintQueueItem(Base):
 
 
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
-    # carries per-filament physical nozzle position IDs in `nozzle_mapping`
-    # and per-extruder rack metadata in `nozzles_info`. Both are forwarded
-    # verbatim through the queue and replayed by the dispatcher so the
-    # firmware honours the user's pick instead of falling back to
-    # "last matching nozzle type" auto-pick. Stored as opaque JSON strings
-    # (list[int] and list[dict] respectively); NULL on every other model.
+    # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
+    # forwarded verbatim through the queue and replayed by the dispatcher so
+    # the firmware honours the user's pick instead of falling back to
+    # "last matching nozzle type" auto-pick. Stored as opaque JSON string
+    # (list[int]); NULL on every other model. `nozzles_info` is a deprecated
+    # column from the original #1780 attempt — kept nullable so old rows still
+    # load; never written to or read from.
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 
 

+ 8 - 0
backend/app/schemas/library.py

@@ -50,6 +50,12 @@ class FolderResponse(BaseModel):
     external_readonly: bool = False
     external_readonly: bool = False
     external_show_hidden: bool = False
     external_show_hidden: bool = False
     file_count: int = 0  # Computed field
     file_count: int = 0  # Computed field
+    # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
+    # File Manager folder tree's "sort by recent activity" mode (#1770) so that
+    # adding a file inside a folder bubbles it up — folder.updated_at alone only
+    # tracks rename/move events. Recursion across subfolders is intentionally
+    # left out to keep the route a single GROUP BY rather than a recursive CTE.
+    latest_activity_at: datetime | None = None
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 
@@ -71,6 +77,8 @@ class FolderTreeItem(BaseModel):
     external_path: str | None = None
     external_path: str | None = None
     external_readonly: bool = False
     external_readonly: bool = False
     file_count: int = 0
     file_count: int = 0
+    # See FolderResponse.latest_activity_at — #1770 folder sort source.
+    latest_activity_at: datetime | None = None
     children: list["FolderTreeItem"] = []
     children: list["FolderTreeItem"] = []
 
 
     class Config:
     class Config:

+ 3 - 7
backend/app/schemas/print_queue.py

@@ -82,13 +82,10 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_offset_cali: bool | None = None
     nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
-    # H2C dual-nozzle-rack slicer pick (#1780). Both fields are opaque
-    # JSON-encoded structures BambuStudio sends in its project_file MQTT
-    # body; sent back to the printer verbatim on dispatch. list[int] for
-    # nozzle_mapping (per-filament physical nozzle position IDs), list[dict]
-    # for nozzles_info (per-extruder rack metadata).
+    # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
+    # physical nozzle position IDs from BambuStudio's project_file MQTT
+    # body; sent back to the printer verbatim on dispatch.
     nozzle_mapping: list[int] | None = None
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
 
 
 
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
@@ -174,7 +171,6 @@ class PrintQueueItemResponse(BaseModel):
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # uploads from BambuStudio.
     # uploads from BambuStudio.
     nozzle_mapping: list[int] | None = None
     nozzle_mapping: list[int] | None = None
-    nozzles_info: list[dict] | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 18 - 30
backend/app/services/bambu_mqtt.py

@@ -3502,7 +3502,6 @@ class BambuMQTTClient:
         use_ams: bool = True,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ):
     ):
         """Start a print job on the printer.
         """Start a print job on the printer.
 
 
@@ -3528,9 +3527,6 @@ class BambuMQTTClient:
                 firmware honours the user's slicer pick instead of falling
                 firmware honours the user's slicer pick instead of falling
                 back to "last matching nozzle" auto-pick. Silently ignored
                 back to "last matching nozzle" auto-pick. Silently ignored
                 on single-nozzle printers.
                 on single-nozzle printers.
-            nozzles_info: Opaque JSON string for the per-extruder rack
-                metadata BambuStudio's project_file carries alongside
-                `nozzle_mapping` (#1780). Same dual-nozzle gating.
         """
         """
         if self._client and self.state.connected:
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
             # Bambu print command format — matches Bambu Studio's format.
@@ -3690,32 +3686,24 @@ class BambuMQTTClient:
 
 
             # H2C dual-nozzle-rack slicer-pick preservation (#1780).
             # H2C dual-nozzle-rack slicer-pick preservation (#1780).
             # `nozzle_mapping` carries per-filament physical nozzle position
             # `nozzle_mapping` carries per-filament physical nozzle position
-            # IDs (`list[int]`), `nozzles_info` carries per-extruder rack
-            # metadata (`list[dict]`). Both are JSON-string-encoded when
-            # they leave the queue item; parse here so the wire ships
-            # arrays/objects, matching BambuStudio's project_file shape.
-            # Gate by `is_dual_nozzle` defensively — single-nozzle firmwares
-            # would ignore them but we err on the side of not emitting
-            # unrecognised fields. A parse failure is logged but never
-            # blocks the dispatch — the firmware will fall back to its
-            # auto-pick path, which is the pre-fix behaviour.
-            if is_dual_nozzle:
-                for src_str, json_key in (
-                    (nozzle_mapping, "nozzle_mapping"),
-                    (nozzles_info, "nozzles_info"),
-                ):
-                    if not src_str:
-                        continue
-                    try:
-                        command["print"][json_key] = json.loads(src_str)
-                    except json.JSONDecodeError:
-                        logger.warning(
-                            "[%s] Invalid %s JSON on dispatch, omitting from "
-                            "project_file (firmware will auto-pick): %r",
-                            self.serial_number,
-                            json_key,
-                            src_str,
-                        )
+            # IDs (`list[int]`), JSON-string-encoded when it leaves the queue
+            # item; parse here so the wire ships an array, matching
+            # BambuStudio's project_file shape. Gate by `is_dual_nozzle`
+            # defensively — single-nozzle firmwares would ignore the field
+            # but we err on the side of not emitting unrecognised fields. A
+            # parse failure is logged but never blocks the dispatch — the
+            # firmware will fall back to its auto-pick path, which is the
+            # pre-fix behaviour.
+            if is_dual_nozzle and nozzle_mapping:
+                try:
+                    command["print"]["nozzle_mapping"] = json.loads(nozzle_mapping)
+                except json.JSONDecodeError:
+                    logger.warning(
+                        "[%s] Invalid nozzle_mapping JSON on dispatch, omitting from "
+                        "project_file (firmware will auto-pick): %r",
+                        self.serial_number,
+                        nozzle_mapping,
+                    )
 
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)

+ 4 - 5
backend/app/services/print_scheduler.py

@@ -2325,10 +2325,10 @@ class PrintScheduler:
         effective_timelapse = bool(item.timelapse)
         effective_timelapse = bool(item.timelapse)
 
 
         # Start the print with AMS mapping, plate_id and print options.
         # Start the print with AMS mapping, plate_id and print options.
-        # nozzle_mapping / nozzles_info ride through verbatim — JSON strings
-        # captured from Bambu Studio's project_file on VP intake (#1780); the
-        # MQTT layer parses + injects them only for dual-nozzle models so a
-        # null on every other model is a transparent pass-through.
+        # nozzle_mapping rides through verbatim — JSON string captured from
+        # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
+        # parses + injects it only for dual-nozzle models so a null on every
+        # other model is a transparent pass-through.
         started = printer_manager.start_print(
         started = printer_manager.start_print(
             item.printer_id,
             item.printer_id,
             remote_filename,
             remote_filename,
@@ -2342,7 +2342,6 @@ class PrintScheduler:
             use_ams=item.use_ams,
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_offset_cali=item.nozzle_offset_cali,
             nozzle_mapping=item.nozzle_mapping,
             nozzle_mapping=item.nozzle_mapping,
-            nozzles_info=item.nozzles_info,
         )
         )
 
 
         if started:
         if started:

+ 4 - 7
backend/app/services/printer_manager.py

@@ -566,15 +566,13 @@ class PrinterManager:
         use_ams: bool = True,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
         nozzle_offset_cali: bool = False,
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
-        nozzles_info: str | None = None,
     ) -> bool:
     ) -> bool:
         """Start a print on a connected printer.
         """Start a print on a connected printer.
 
 
-        ``nozzle_mapping`` and ``nozzles_info`` are opaque JSON strings
-        captured from BambuStudio's project_file MQTT command (H2C rack-swap
-        slicer pick preservation, #1780). They ride through to the MQTT
-        client untouched; the dispatch builder there parses + injects them
-        only on dual-nozzle models.
+        ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's
+        project_file MQTT command (H2C rack-swap slicer pick preservation,
+        #1780). It rides through to the MQTT client untouched; the dispatch
+        builder there parses + injects it only on dual-nozzle models.
         """
         """
         caller = traceback.extract_stack(limit=3)[0]
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
         logger.info(
@@ -598,7 +596,6 @@ class PrinterManager:
                 use_ams=use_ams,
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
                 nozzle_mapping=nozzle_mapping,
-                nozzles_info=nozzles_info,
             )
             )
         return False
         return False
 
 

+ 56 - 36
backend/app/services/virtual_printer/manager.py

@@ -289,9 +289,10 @@ class VirtualPrinterInstance:
         """Handle print command from MQTT.
         """Handle print command from MQTT.
 
 
         Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
         Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
-        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
-        VP-queue path can inherit them when adding the item to the queue,
-        rather than falling back to the global default settings (#1403).
+        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the
+        H2C rack-pick `nozzle_mapping`) so the VP-queue path can inherit them
+        when adding the item to the queue, rather than falling back to the
+        global default settings (#1403, #1780).
         Only queue mode consumes the capture; archive / review / proxy
         Only queue mode consumes the capture; archive / review / proxy
         modes ignore the print command, so we skip the stash there to keep
         modes ignore the print command, so we skip the stash there to keep
         the dict from accumulating one entry per print over the VP's
         the dict from accumulating one entry per print over the VP's
@@ -301,6 +302,16 @@ class VirtualPrinterInstance:
         moment after the synthetic project_file ack — for every non-proxy
         moment after the synthetic project_file ack — for every non-proxy
         mode — so the slicer's "Downloading" UI releases on the slicer's
         mode — so the slicer's "Downloading" UI releases on the slicer's
         FTP-first-then-MQTT send order.
         FTP-first-then-MQTT send order.
+
+        ``filename`` is the slicer's ``subtask_name`` (bare model name, no
+        extension) — used verbatim for `_schedule_finish_release` because
+        push_status echoes it back to the slicer as gcode_file / subtask_name.
+        The queue-side stash key is derived from ``data["file"]`` (the FTP
+        filename with extension) so `_add_to_print_queue`'s
+        ``file_path.name`` lookup matches; falls back to ``filename`` when
+        ``data["file"]`` is absent (legacy slicers / non-3MF uploads).
+        Stash/lookup mismatch was the #1780 root cause — every captured field
+        silently fell back to settings defaults on every Bambu Studio "Send".
         """
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         mode = normalize_vp_mode(self.mode)
         mode = normalize_vp_mode(self.mode)
@@ -308,6 +319,12 @@ class VirtualPrinterInstance:
             self._schedule_finish_release(filename)
             self._schedule_finish_release(filename)
         if mode != VP_MODE_QUEUE:
         if mode != VP_MODE_QUEUE:
             return
             return
+        # Stash key must match `_add_to_print_queue`'s lookup, which uses
+        # `file_path.name` (FTP filename WITH extension). The slicer's
+        # `subtask_name` (== this method's `filename` arg) is the bare model
+        # name, no extension — using it as the stash key was the #1780 root
+        # cause.
+        stash_key = data.get("file") or filename
         # Drop the oldest stash if the cache is growing — happens when the
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # slicer sends project_file for a filename whose FTP upload was
         # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
         # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
@@ -321,8 +338,8 @@ class VirtualPrinterInstance:
                 logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
                 logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
             except StopIteration:
             except StopIteration:
                 pass
                 pass
-        self._slicer_print_options[filename] = dict(data)
-        event = self._slicer_print_options_events.get(filename)
+        self._slicer_print_options[stash_key] = dict(data)
+        event = self._slicer_print_options_events.get(stash_key)
         if event:
         if event:
             event.set()
             event.set()
 
 
@@ -525,6 +542,18 @@ class VirtualPrinterInstance:
                 slicer_opts = None
                 slicer_opts = None
             finally:
             finally:
                 self._slicer_print_options_events.pop(file_path.name, None)
                 self._slicer_print_options_events.pop(file_path.name, None)
+        # If the cache still misses, queued workflow flags / nozzle pick will
+        # silently fall back to settings defaults. Surface the missed key so a
+        # future stash/lookup mismatch (the #1780 root cause) is obvious in
+        # the log instead of needing a wire capture to diagnose.
+        if slicer_opts is None:
+            logger.debug(
+                "[VP %s] No slicer options cached for %r (cache keys: %s); "
+                "workflow flags + nozzle pick will fall back to settings defaults.",
+                self.name,
+                file_path.name,
+                sorted(self._slicer_print_options.keys()),
+            )
 
 
         try:
         try:
             import json
             import json
@@ -575,46 +604,38 @@ class VirtualPrinterInstance:
 
 
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # BambuStudio's project_file MQTT command for rack-swap models
                 # BambuStudio's project_file MQTT command for rack-swap models
-                # (O1C2 today) carries:
-                #   `nozzle_mapping` — per-filament array of physical nozzle
-                #     position IDs (`list[int]`).
-                #   `nozzles_info`   — per-extruder rack metadata
-                #     (`list[dict]`, fields: id / type / flowSize / diameter).
-                # Forward both verbatim onto the queue item so the dispatcher
-                # can replay them in its own project_file command. Without
-                # this the H2C firmware falls back to "last matching nozzle"
-                # auto-pick and ignores the user's Bambu Studio choice. Every
-                # other model has these absent from slicer_opts, so the
-                # capture is a transparent no-op there.
+                # (O1C2 today) carries `nozzle_mapping` — a per-filament array
+                # of physical nozzle position IDs (`list[int]`). Forward it
+                # verbatim onto the queue item so the dispatcher can replay it
+                # in its own project_file command. Without this the H2C
+                # firmware falls back to "last matching nozzle" auto-pick and
+                # ignores the user's Bambu Studio choice. Every other model
+                # has it absent from slicer_opts, so the capture is a
+                # transparent no-op there. (`nozzles_info` was also captured
+                # in the original fix but BambuStudio never actually sends it
+                # — verified via wire capture on H2C — so only `nozzle_mapping`
+                # is forwarded now.)
                 nozzle_mapping_json: str | None = None
                 nozzle_mapping_json: str | None = None
-                nozzles_info_json: str | None = None
                 if slicer_opts is not None:
                 if slicer_opts is not None:
-                    for src_key in ("nozzle_mapping", "nozzles_info"):
-                        raw = slicer_opts.get(src_key)
-                        if raw is None:
-                            continue
-                        # BambuStudio's NetworkAgent should embed these as
-                        # parsed JSON in the project_file body (matching the
-                        # ams_mapping / ams_mapping2 shape Bambuddy already
-                        # consumes as list[int] / list[dict]). Accept a
-                        # JSON-encoded string defensively in case any path
-                        # arrives stringified.
+                    raw = slicer_opts.get("nozzle_mapping")
+                    if raw is not None:
+                        # BambuStudio's NetworkAgent embeds this as parsed
+                        # JSON in the project_file body (matching the
+                        # ams_mapping shape Bambuddy already consumes as
+                        # list[int]). Accept a JSON-encoded string defensively
+                        # in case any path arrives stringified.
                         if isinstance(raw, str):
                         if isinstance(raw, str):
                             try:
                             try:
                                 raw = json.loads(raw)
                                 raw = json.loads(raw)
                             except json.JSONDecodeError:
                             except json.JSONDecodeError:
                                 logger.warning(
                                 logger.warning(
-                                    "[VP %s] Slicer %s is unparseable JSON, dropping: %r",
+                                    "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r",
                                     self.name,
                                     self.name,
-                                    src_key,
                                     raw,
                                     raw,
                                 )
                                 )
-                                continue
-                        encoded = json.dumps(raw)
-                        if src_key == "nozzle_mapping":
-                            nozzle_mapping_json = encoded
-                        else:
-                            nozzles_info_json = encoded
+                                raw = None
+                        if raw is not None:
+                            nozzle_mapping_json = json.dumps(raw)
 
 
                 service = ArchiveService(db)
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                 archive = await service.archive_print(
@@ -723,7 +744,6 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
                             nozzle_mapping=nozzle_mapping_json,
-                            nozzles_info=nozzles_info_json,
                         )
                         )
                         db.add(queue_item)
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging

+ 8 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -1293,6 +1293,14 @@ class SimpleMQTTServer:
                     file_3mf = print_data.get("file", filename)
                     file_3mf = print_data.get("file", filename)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     if self.on_print_command:
                     if self.on_print_command:
+                        # `filename` is the slicer's `subtask_name` (bare model
+                        # name, no extension). Pass it through verbatim — the
+                        # `_schedule_finish_release` chain echoes it back as
+                        # gcode_file + subtask_name in push_status, and the
+                        # slicer matches against its own subtask_name there.
+                        # The FTP filename (with extension) is in print_data
+                        # under "file" for the queue-stash side to use as its
+                        # own key matching `_add_to_print_queue`'s lookup.
                         await self._notify_print_command(filename, print_data)
                         await self._notify_print_command(filename, print_data)
                     handled_locally = True
                     handled_locally = True
 
 

+ 55 - 0
backend/tests/integration/test_library_api.py

@@ -44,6 +44,61 @@ class TestLibraryFoldersAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json() == []
         assert response.json() == []
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_exposes_latest_activity_at_from_files(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
+        MAX(immediate-child file.updated_at)) so the frontend can sort by
+        recent activity. Adding a file with a later updated_at must bubble it.
+        """
+        from datetime import datetime, timedelta
+
+        from backend.app.models.library import LibraryFile
+
+        folder = await folder_factory(name="Active Folder")
+        # File whose updated_at is well after the folder's. Activity should
+        # surface this timestamp, not the folder's stale one.
+        future = datetime.utcnow() + timedelta(hours=24)
+        db_session.add(
+            LibraryFile(
+                folder_id=folder.id,
+                filename="model.3mf",
+                file_path="library/model.3mf",
+                file_type="3mf",
+                file_size=123,
+                updated_at=future,
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        assert item["id"] == folder.id
+        assert item["latest_activity_at"] is not None
+        # latest_activity_at should be at least the future stamp we set.
+        assert item["latest_activity_at"] >= future.isoformat()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: a folder with no files reports its own updated_at, not null —
+        otherwise the activity sort would dump every empty folder to one end."""
+        await folder_factory(name="Empty Folder")
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        # latest_activity_at == folder.updated_at when there are no files
+        assert item["latest_activity_at"] is not None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_create_folder(self, async_client: AsyncClient, db_session):
     async def test_create_folder(self, async_client: AsyncClient, db_session):

+ 22 - 46
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5082,14 +5082,17 @@ class TestStartPrintRecordsDispatchedPlate:
 
 
 
 
 class TestStartPrintNozzleMappingDispatch:
 class TestStartPrintNozzleMappingDispatch:
-    """H2C dual-nozzle-rack (#1780) — nozzle_mapping + nozzles_info on dispatch.
+    """H2C dual-nozzle-rack (#1780) — nozzle_mapping on dispatch.
 
 
     BambuStudio's project_file MQTT command for O1C2 carries a per-filament
     BambuStudio's project_file MQTT command for O1C2 carries a per-filament
-    physical nozzle position ID array (`nozzle_mapping`) and a per-extruder
-    rack metadata array (`nozzles_info`). Without forwarding both, the H2C
-    firmware falls back to "last matching nozzle type" auto-pick and ignores
-    the user's slicer choice. Tests pin the gate, the parse, the no-op cases,
-    and the malformed-JSON safety net.
+    physical nozzle position ID array (`nozzle_mapping`). Without forwarding
+    it, the H2C firmware falls back to "last matching nozzle type" auto-pick
+    and ignores the user's slicer choice. Tests pin the gate, the parse, the
+    no-op cases, and the malformed-JSON safety net.
+
+    The original #1780 attempt also captured `nozzles_info` but a wire capture
+    on H2C confirmed BambuStudio never sends that field — the capture/dispatch
+    paths for it were dropped in the same release.
     """
     """
 
 
     @pytest.fixture
     @pytest.fixture
@@ -5111,29 +5114,23 @@ class TestStartPrintNozzleMappingDispatch:
         call_args = mqtt_client._client.publish.call_args
         call_args = mqtt_client._client.publish.call_args
         return json.loads(call_args[0][1])["print"]
         return json.loads(call_args[0][1])["print"]
 
 
-    def test_dual_nozzle_includes_nozzle_mapping_and_nozzles_info(self, mqtt_client):
-        """Dual-nozzle + both fields present → parsed JSON arrays injected
+    def test_dual_nozzle_includes_nozzle_mapping(self, mqtt_client):
+        """Dual-nozzle + nozzle_mapping present → parsed JSON array injected
         verbatim onto the dispatched project_file command."""
         verbatim onto the dispatched project_file command."""
         mqtt_client._is_dual_nozzle = True
         mqtt_client._is_dual_nozzle = True
-        nozzles_info = [
-            {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
-            {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
-        ]
 
 
         mqtt_client.start_print(
         mqtt_client.start_print(
             "test.3mf",
             "test.3mf",
-            nozzle_mapping=json.dumps([16, 0, 19]),
-            nozzles_info=json.dumps(nozzles_info),
+            nozzle_mapping=json.dumps([16, -1, -1, 1, -1, -1, -1, -1]),
         )
         )
 
 
         cmd = self._published_print_cmd(mqtt_client)
         cmd = self._published_print_cmd(mqtt_client)
-        # Lists, not strings — the wire shape must match BambuStudio's.
-        assert cmd["nozzle_mapping"] == [16, 0, 19]
-        assert cmd["nozzles_info"] == nozzles_info
+        # List, not string — the wire shape must match BambuStudio's.
+        assert cmd["nozzle_mapping"] == [16, -1, -1, 1, -1, -1, -1, -1]
 
 
     def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client):
     def test_single_nozzle_omits_nozzle_mapping_even_if_set(self, mqtt_client):
-        """A single-nozzle printer must NOT emit the rack fields even if the
-        caller passes them (defense-in-depth — the queue item could legitimately
+        """A single-nozzle printer must NOT emit the rack field even if the
+        caller passes it (defense-in-depth — the queue item could legitimately
         carry a stale capture from before a model change)."""
         carry a stale capture from before a model change)."""
         mqtt_client._is_dual_nozzle = False
         mqtt_client._is_dual_nozzle = False
         mqtt_client.model = "P1S"  # single-nozzle
         mqtt_client.model = "P1S"  # single-nozzle
@@ -5141,41 +5138,22 @@ class TestStartPrintNozzleMappingDispatch:
         mqtt_client.start_print(
         mqtt_client.start_print(
             "test.3mf",
             "test.3mf",
             nozzle_mapping=json.dumps([16, 0, 19]),
             nozzle_mapping=json.dumps([16, 0, 19]),
-            nozzles_info=json.dumps([{"id": 1}]),
         )
         )
 
 
         cmd = self._published_print_cmd(mqtt_client)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
 
 
-    def test_dual_nozzle_no_fields_no_injection(self, mqtt_client):
+    def test_dual_nozzle_no_field_no_injection(self, mqtt_client):
         """Dual-nozzle printer + no slicer pick (NULL on queue item) → command
         """Dual-nozzle printer + no slicer pick (NULL on queue item) → command
-        carries no nozzle_mapping / nozzles_info. The firmware then runs its
-        normal auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
+        carries no nozzle_mapping. The firmware then runs its normal
+        auto-pick, which is the pre-fix behaviour for any non-O1C2 dual-
         nozzle model that has no rack to disambiguate against anyway."""
         nozzle model that has no rack to disambiguate against anyway."""
         mqtt_client._is_dual_nozzle = True
         mqtt_client._is_dual_nozzle = True
 
 
-        mqtt_client.start_print("test.3mf", nozzle_mapping=None, nozzles_info=None)
+        mqtt_client.start_print("test.3mf", nozzle_mapping=None)
 
 
         cmd = self._published_print_cmd(mqtt_client)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
-
-    def test_dual_nozzle_partial_only_mapping(self, mqtt_client):
-        """Half-populated case: nozzle_mapping carried but nozzles_info NULL.
-        Forward what we have; firmware tolerates a missing rack metadata
-        field and resolves against its own state."""
-        mqtt_client._is_dual_nozzle = True
-
-        mqtt_client.start_print(
-            "test.3mf",
-            nozzle_mapping=json.dumps([16]),
-            nozzles_info=None,
-        )
-
-        cmd = self._published_print_cmd(mqtt_client)
-        assert cmd["nozzle_mapping"] == [16]
-        assert "nozzles_info" not in cmd
 
 
     def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog):
     def test_malformed_nozzle_mapping_is_logged_and_omitted(self, mqtt_client, caplog):
         """Invalid JSON on the queue item must NOT block the dispatch. Log a
         """Invalid JSON on the queue item must NOT block the dispatch. Log a
@@ -5189,7 +5167,6 @@ class TestStartPrintNozzleMappingDispatch:
             result = mqtt_client.start_print(
             result = mqtt_client.start_print(
                 "test.3mf",
                 "test.3mf",
                 nozzle_mapping="not valid json {",
                 nozzle_mapping="not valid json {",
-                nozzles_info=None,
             )
             )
 
 
         assert result is True  # dispatch still proceeded
         assert result is True  # dispatch still proceeded
@@ -5197,17 +5174,16 @@ class TestStartPrintNozzleMappingDispatch:
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
         assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
         assert any("Invalid nozzle_mapping" in rec.message for rec in caplog.records)
 
 
-    def test_empty_string_fields_are_treated_as_absent(self, mqtt_client):
+    def test_empty_string_field_is_treated_as_absent(self, mqtt_client):
         """An empty-string column value (legacy data, or a NOT NULL DB
         """An empty-string column value (legacy data, or a NOT NULL DB
         recovery shim) must behave the same as NULL — no injection, no
         recovery shim) must behave the same as NULL — no injection, no
         parse error log."""
         parse error log."""
         mqtt_client._is_dual_nozzle = True
         mqtt_client._is_dual_nozzle = True
 
 
-        mqtt_client.start_print("test.3mf", nozzle_mapping="", nozzles_info="")
+        mqtt_client.start_print("test.3mf", nozzle_mapping="")
 
 
         cmd = self._published_print_cmd(mqtt_client)
         cmd = self._published_print_cmd(mqtt_client)
         assert "nozzle_mapping" not in cmd
         assert "nozzle_mapping" not in cmd
-        assert "nozzles_info" not in cmd
 
 
 
 
 class TestFilamentTrackSwitchDetection:
 class TestFilamentTrackSwitchDetection:

+ 0 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -379,7 +379,6 @@ class TestPrinterManager:
             use_ams=True,
             use_ams=True,
             nozzle_offset_cali=False,
             nozzle_offset_cali=False,
             nozzle_mapping=None,
             nozzle_mapping=None,
-            nozzles_info=None,
         )
         )
         assert result is True
         assert result is True
 
 

+ 126 - 29
backend/tests/unit/services/test_virtual_printer.py

@@ -1580,13 +1580,13 @@ class TestVirtualPrinterInstance:
         assert all(q.manual_start for q in added_items)
         assert all(q.manual_start for q in added_items)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_add_to_print_queue_captures_nozzle_mapping_and_nozzles_info(self, tmp_path):
+    async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path):
         """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
         """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
-        per-filament physical nozzle position IDs in `nozzle_mapping` and
-        per-extruder rack metadata in `nozzles_info`. VP intake must store
-        both as JSON strings on the queue item so the dispatcher can replay
-        them. Without this the H2C firmware falls back to "last matching
-        nozzle" auto-pick and ignores the user's slicer choice.
+        per-filament physical nozzle position IDs in `nozzle_mapping`. VP
+        intake must store it as a JSON string on the queue item so the
+        dispatcher can replay it. Without this the H2C firmware falls back
+        to "last matching nozzle" auto-pick and ignores the user's slicer
+        choice.
         """
         """
         import json as _json
         import json as _json
 
 
@@ -1617,18 +1617,16 @@ class TestVirtualPrinterInstance:
         file_path.write_bytes(b"fake3mf")
         file_path.write_bytes(b"fake3mf")
 
 
         # Pre-populate as if BS's project_file arrived. Wire shape matches
         # Pre-populate as if BS's project_file arrived. Wire shape matches
-        # BambuStudio's PrintJob params: nozzle_mapping = array of per-
-        # filament physical nozzle position IDs, nozzles_info = array of
-        # per-extruder rack-side metadata.
+        # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
+        # per-filament physical nozzle position IDs (verified via H2C wire
+        # capture). The slicer-side `nozzles_info` field that the original
+        # #1780 attempt also looked for was never actually sent — it has
+        # been dropped from the capture path entirely.
         await inst.on_print_command(
         await inst.on_print_command(
             file_path.name,
             file_path.name,
             {
             {
                 "command": "project_file",
                 "command": "project_file",
-                "nozzle_mapping": [16, 0, 19],
-                "nozzles_info": [
-                    {"id": 1, "type": None, "flowSize": "High Flow", "diameter": 0.4},
-                    {"id": 2, "type": None, "flowSize": "Standard", "diameter": 0.4},
-                ],
+                "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
             },
             },
         )
         )
 
 
@@ -1653,18 +1651,14 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         item = added_items[0]
         item = added_items[0]
         assert item.nozzle_mapping is not None
         assert item.nozzle_mapping is not None
-        assert _json.loads(item.nozzle_mapping) == [16, 0, 19]
-        assert item.nozzles_info is not None
-        parsed_info = _json.loads(item.nozzles_info)
-        assert parsed_info[0]["flowSize"] == "High Flow"
-        assert parsed_info[1]["flowSize"] == "Standard"
+        assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_add_to_print_queue_no_nozzle_fields_when_slicer_omits(self, tmp_path):
-        """#1780: every model other than O1C2 sends no nozzle_mapping /
-        nozzles_info — the queue item must carry NULL on both, not an empty
-        list. NULL is what the dispatch layer keys off of to skip the
-        injection entirely on non-rack-swap printers.
+    async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path):
+        """#1780: every model other than O1C2 sends no nozzle_mapping — the
+        queue item must carry NULL, not an empty list. NULL is what the
+        dispatch layer keys off of to skip the injection entirely on non-
+        rack-swap printers.
         """
         """
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
 
@@ -1719,13 +1713,12 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         item = added_items[0]
         item = added_items[0]
         assert item.nozzle_mapping is None
         assert item.nozzle_mapping is None
-        assert item.nozzles_info is None
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
         """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
         """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
-        same nozzle_mapping / nozzles_info on every plate's queue item, not
-        only the first. Mirrors the per-plate stamping for gcode_injection,
+        same nozzle_mapping on every plate's queue item, not only the first.
+        Mirrors the per-plate stamping for gcode_injection,
         filament_overrides, etc.
         filament_overrides, etc.
         """
         """
         import json as _json
         import json as _json
@@ -1767,7 +1760,6 @@ class TestVirtualPrinterInstance:
             {
             {
                 "command": "project_file",
                 "command": "project_file",
                 "nozzle_mapping": [16, 0],
                 "nozzle_mapping": [16, 0],
-                "nozzles_info": [{"id": 1, "flowSize": "High Flow", "diameter": 0.4}],
             },
             },
         )
         )
 
 
@@ -1792,7 +1784,6 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 3
         assert len(added_items) == 3
         for item in added_items:
         for item in added_items:
             assert _json.loads(item.nozzle_mapping) == [16, 0]
             assert _json.loads(item.nozzle_mapping) == [16, 0]
-            assert _json.loads(item.nozzles_info)[0]["flowSize"] == "High Flow"
 
 
 
 
 class TestVirtualPrinterManager:
 class TestVirtualPrinterManager:
@@ -3491,3 +3482,109 @@ class TestSSDPProxyName:
         rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
         rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
 
 
         assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
         assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
+
+
+class TestVPProjectFileStashKey:
+    """Regression: `on_print_command` MUST stash slicer options under the
+    FTP filename (`data["file"]`, with extension), NOT under `filename`
+    (the slicer's `subtask_name`, bare).
+
+    #1780 root cause (real bundle, 2026-06-21): BambuStudio sends
+    `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"`
+    (with extension). `_add_to_print_queue` looks up the stash under
+    `file_path.name` from the FTP receive side, which always has the
+    extension. If the stash uses `subtask_name`, lookup misses → every
+    captured slicer field (bed_leveling, flow_cali, vibration_cali,
+    layer_inspect, timelapse, nozzle_mapping) silently falls back to
+    settings defaults on every Bambu Studio "Send" upload.
+
+    `filename` (subtask_name) must still flow to `_schedule_finish_release`
+    untouched — push_status echoes it back as gcode_file / subtask_name and
+    the slicer matches against its own local subtask_name there. So
+    `on_print_command` keeps `filename` for state-feedback but derives the
+    stash key from `data["file"]`.
+    """
+
+    @pytest.fixture
+    def instance(self, tmp_path):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        return VirtualPrinterInstance(
+            vp_id=99,
+            name="StashKeyTest",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="999999999",
+            base_dir=tmp_path,
+        )
+
+    @pytest.mark.asyncio
+    async def test_stash_key_uses_file_field_not_subtask_name(self, instance):
+        """BambuStudio's real wire shape: `subtask_name` ≠ `file`.
+        on_print_command must stash under `data["file"]` so the FTP-side
+        `_add_to_print_queue` lookup matches.
+        """
+        # mqtt_server.py:_handle_publish hands the bare subtask_name as
+        # `filename` and the full print_data body as `data`. The FTP filename
+        # lives in `data["file"]`.
+        await instance.on_print_command(
+            "Filament_Track_Switch_Holder",  # subtask_name (bare)
+            {
+                "command": "project_file",
+                "subtask_name": "Filament_Track_Switch_Holder",
+                "file": "Filament_Track_Switch_Holder.gcode.3mf",
+                "nozzle_mapping": [16, -1, -1, 1],
+            },
+        )
+
+        # Stash MUST be under the FTP filename, not the bare subtask_name.
+        # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)`
+        # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf".
+        assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options
+        assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options
+        # Body must carry nozzle_mapping verbatim.
+        stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"]
+        assert stashed["nozzle_mapping"] == [16, -1, -1, 1]
+
+    @pytest.mark.asyncio
+    async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance):
+        """Defensive fallback: a slicer that omits the `file` field entirely
+        (legacy / non-3MF) must fall back to `filename` (subtask_name), not
+        leave the stash unkeyed."""
+        await instance.on_print_command(
+            "BareName",
+            {
+                "command": "project_file",
+                "subtask_name": "BareName",
+                # no "file" field
+            },
+        )
+
+        assert "BareName" in instance._slicer_print_options
+
+    @pytest.mark.asyncio
+    async def test_stash_key_signals_event_under_file_key(self, instance):
+        """`_add_to_print_queue` registers a wait-event under `file_path.name`
+        when the slicer's project_file arrives late. on_print_command must
+        signal THAT event (keyed by the FTP filename), not one keyed by
+        subtask_name — else the waiter times out even though the stash is
+        present and addressable."""
+        import asyncio
+
+        ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf"
+        event = asyncio.Event()
+        instance._slicer_print_options_events[ftp_filename] = event
+
+        await instance.on_print_command(
+            "Filament_Track_Switch_Holder",  # bare subtask_name
+            {
+                "command": "project_file",
+                "subtask_name": "Filament_Track_Switch_Holder",
+                "file": ftp_filename,
+            },
+        )
+
+        # Event keyed by FTP filename must fire even though on_print_command
+        # was called with the bare subtask_name.
+        assert event.is_set()

+ 4 - 0
frontend/src/api/client.ts

@@ -6314,6 +6314,9 @@ export interface LibraryFolderTree {
   external_path: string | null;
   external_path: string | null;
   external_readonly: boolean;
   external_readonly: boolean;
   file_count: number;
   file_count: number;
+  // max(folder.updated_at, max(immediate-child file.updated_at)). Used by
+  // the File Manager folder tree's "sort by recent activity" mode (#1770).
+  latest_activity_at: string | null;
   children: LibraryFolderTree[];
   children: LibraryFolderTree[];
 }
 }
 
 
@@ -6330,6 +6333,7 @@ export interface LibraryFolder {
   external_readonly: boolean;
   external_readonly: boolean;
   external_show_hidden: boolean;
   external_show_hidden: boolean;
   file_count: number;
   file_count: number;
+  latest_activity_at: string | null;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -3320,6 +3320,9 @@ export default {
     collapse: 'Einklappen',
     collapse: 'Einklappen',
     collapseFoldersByDefault: 'Ordner standardmäßig einklappen',
     collapseFoldersByDefault: 'Ordner standardmäßig einklappen',
     expandFoldersByDefault: 'Ordner standardmäßig ausklappen',
     expandFoldersByDefault: 'Ordner standardmäßig ausklappen',
+    folderSort: 'Ordner sortieren',
+    folderSortByName: 'Nach Name',
+    folderSortByActivity: 'Nach letzter Aktivität',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     searchFiles: 'Dateien suchen...',
     searchFiles: 'Dateien suchen...',
     allTypes: 'Alle Typen',
     allTypes: 'Alle Typen',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -3335,6 +3335,9 @@ export default {
     collapse: 'Collapse',
     collapse: 'Collapse',
     collapseFoldersByDefault: 'Collapse folders by default',
     collapseFoldersByDefault: 'Collapse folders by default',
     expandFoldersByDefault: 'Expand folders by default',
     expandFoldersByDefault: 'Expand folders by default',
+    folderSort: 'Sort folders',
+    folderSortByName: 'By name',
+    folderSortByActivity: 'By recent activity',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     searchFiles: 'Search files...',
     searchFiles: 'Search files...',
     allTypes: 'All types',
     allTypes: 'All types',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -3323,6 +3323,9 @@ export default {
     collapse: 'Contraer',
     collapse: 'Contraer',
     collapseFoldersByDefault: 'Contraer las carpetas de forma predeterminada',
     collapseFoldersByDefault: 'Contraer las carpetas de forma predeterminada',
     expandFoldersByDefault: 'Expandir las carpetas de forma predeterminada',
     expandFoldersByDefault: 'Expandir las carpetas de forma predeterminada',
+    folderSort: 'Ordenar carpetas',
+    folderSortByName: 'Por nombre',
+    folderSortByActivity: 'Por actividad reciente',
     dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer',
     dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer',
     searchFiles: 'Buscar archivos...',
     searchFiles: 'Buscar archivos...',
     allTypes: 'Todos los tipos',
     allTypes: 'Todos los tipos',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -3309,6 +3309,9 @@ export default {
     collapse: 'Réduire',
     collapse: 'Réduire',
     collapseFoldersByDefault: 'Réduire les dossiers par défaut',
     collapseFoldersByDefault: 'Réduire les dossiers par défaut',
     expandFoldersByDefault: 'Développer les dossiers par défaut',
     expandFoldersByDefault: 'Développer les dossiers par défaut',
+    folderSort: 'Trier les dossiers',
+    folderSortByName: 'Par nom',
+    folderSortByActivity: 'Par activité récente',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     searchFiles: 'Chercher fichiers...',
     searchFiles: 'Chercher fichiers...',
     allTypes: 'Tous types',
     allTypes: 'Tous types',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -3308,6 +3308,9 @@ export default {
     collapse: 'Comprimi',
     collapse: 'Comprimi',
     collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',
     collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',
     expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',
     expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',
+    folderSort: 'Ordina cartelle',
+    folderSortByName: 'Per nome',
+    folderSortByActivity: 'Per attività recente',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     searchFiles: 'Cerca file...',
     searchFiles: 'Cerca file...',
     allTypes: 'Tutti i tipi',
     allTypes: 'Tutti i tipi',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -3320,6 +3320,9 @@ export default {
     collapse: '折りたたむ',
     collapse: '折りたたむ',
     collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
     collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',
     expandFoldersByDefault: 'フォルダをデフォルトで展開する',
     expandFoldersByDefault: 'フォルダをデフォルトで展開する',
+    folderSort: 'フォルダの並べ替え',
+    folderSortByName: '名前順',
+    folderSortByActivity: '最終更新順',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
     searchFiles: 'ファイルを検索...',
     allTypes: 'すべての種類',
     allTypes: 'すべての種類',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -3133,6 +3133,9 @@ export default {
     collapse: '접기',
     collapse: '접기',
     collapseFoldersByDefault: '기본적으로 폴더 접기',
     collapseFoldersByDefault: '기본적으로 폴더 접기',
     expandFoldersByDefault: '기본적으로 폴더 펼치기',
     expandFoldersByDefault: '기본적으로 폴더 펼치기',
+    folderSort: '폴더 정렬',
+    folderSortByName: '이름순',
+    folderSortByActivity: '최근 활동순',
     dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화',
     dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화',
     searchFiles: '파일 검색...',
     searchFiles: '파일 검색...',
     allTypes: '모든 유형',
     allTypes: '모든 유형',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3308,6 +3308,9 @@ export default {
     collapse: 'Recolher',
     collapse: 'Recolher',
     collapseFoldersByDefault: 'Recolher pastas por padrão',
     collapseFoldersByDefault: 'Recolher pastas por padrão',
     expandFoldersByDefault: 'Expandir pastas por padrão',
     expandFoldersByDefault: 'Expandir pastas por padrão',
+    folderSort: 'Ordenar pastas',
+    folderSortByName: 'Por nome',
+    folderSortByActivity: 'Por atividade recente',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     searchFiles: 'Pesquisar arquivos...',
     searchFiles: 'Pesquisar arquivos...',
     allTypes: 'Todos os tipos',
     allTypes: 'Todos os tipos',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -3315,6 +3315,9 @@ export default {
     collapse: 'Daralt',
     collapse: 'Daralt',
     collapseFoldersByDefault: 'Klasörleri varsayılan olarak daralt',
     collapseFoldersByDefault: 'Klasörleri varsayılan olarak daralt',
     expandFoldersByDefault: 'Klasörleri varsayılan olarak genişlet',
     expandFoldersByDefault: 'Klasörleri varsayılan olarak genişlet',
+    folderSort: 'Klasörleri sırala',
+    folderSortByName: 'Ada göre',
+    folderSortByActivity: 'Son etkinliğe göre',
     dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın',
     dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın',
     searchFiles: 'Dosyalarda ara...',
     searchFiles: 'Dosyalarda ara...',
     allTypes: 'Tüm türler',
     allTypes: 'Tüm türler',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3308,6 +3308,9 @@ export default {
     collapse: '折叠',
     collapse: '折叠',
     collapseFoldersByDefault: '默认折叠文件夹',
     collapseFoldersByDefault: '默认折叠文件夹',
     expandFoldersByDefault: '默认展开文件夹',
     expandFoldersByDefault: '默认展开文件夹',
+    folderSort: '文件夹排序',
+    folderSortByName: '按名称',
+    folderSortByActivity: '按最近活动',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
     searchFiles: '搜索文件...',
     allTypes: '所有类型',
     allTypes: '所有类型',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3308,6 +3308,9 @@ export default {
     collapse: '折疊',
     collapse: '折疊',
     collapseFoldersByDefault: '預設折疊資料夾',
     collapseFoldersByDefault: '預設折疊資料夾',
     expandFoldersByDefault: '預設展開資料夾',
     expandFoldersByDefault: '預設展開資料夾',
+    folderSort: '資料夾排序',
+    folderSortByName: '依名稱',
+    folderSortByActivity: '依最近活動',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
     searchFiles: '搜尋檔案...',
     allTypes: '所有類型',
     allTypes: '所有類型',

+ 86 - 7
frontend/src/pages/FileManagerPage.tsx

@@ -978,6 +978,17 @@ export function FileManagerPage() {
   const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
   const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {
     return localStorage.getItem('library-collapse-folders') === 'true';
     return localStorage.getItem('library-collapse-folders') === 'true';
   });
   });
+  // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour);
+  // 'activity' = most recent file activity inside the folder first. Persisted
+  // independently from the file-side sort so each can be tuned to taste.
+  const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => {
+    const saved = localStorage.getItem('library-folder-sort-field');
+    return saved === 'activity' ? 'activity' : 'name';
+  });
+  const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => {
+    const saved = localStorage.getItem('library-folder-sort-direction');
+    return saved === 'desc' ? 'desc' : 'asc';
+  });
 
 
   // Resizable sidebar state
   // Resizable sidebar state
   const [sidebarWidth, setSidebarWidth] = useState(() => {
   const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -1060,6 +1071,42 @@ export function FileManagerPage() {
     queryFn: () => api.getLibraryFolders(),
     queryFn: () => api.getLibraryFolders(),
   });
   });
 
 
+  // Recursive folder tree sort (#1770). Applies the same comparator to the
+  // top-level list AND to each level of `children`, so sort order is uniform
+  // at every depth of nesting. When sorting by activity, the comparator falls
+  // back to a created-at fallback for folders with no files (`latest_activity_at`
+  // is null) so they stay grouped at the end / start of the bucket instead of
+  // randomly interspersed.
+  const sortedFolders = useMemo(() => {
+    if (!folders) return folders;
+    const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => {
+      const sorted = [...items].sort((a, b) => {
+        let comparison = 0;
+        if (folderSortField === 'name') {
+          comparison = a.name.localeCompare(b.name);
+        } else {
+          // activity: newest first on 'desc', oldest first on 'asc'.
+          // Folders with no activity timestamp sort to the end regardless
+          // of direction so an empty folder doesn't elbow a recently-used one.
+          const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null;
+          const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null;
+          if (aTs === null && bTs === null) {
+            comparison = a.name.localeCompare(b.name);
+          } else if (aTs === null) {
+            return 1;
+          } else if (bTs === null) {
+            return -1;
+          } else {
+            comparison = aTs - bTs;
+          }
+        }
+        return folderSortDirection === 'asc' ? comparison : -comparison;
+      });
+      return sorted.map((f) => ({ ...f, children: sortLevel(f.children) }));
+    };
+    return sortLevel(folders);
+  }, [folders, folderSortField, folderSortDirection]);
+
   // Trash count for the header badge (#1008). Empty/error are silently treated
   // Trash count for the header badge (#1008). Empty/error are silently treated
   // as zero so a broken trash endpoint doesn't break the File Manager.
   // as zero so a broken trash endpoint doesn't break the File Manager.
   const { data: trashCount } = useQuery({
   const { data: trashCount } = useQuery({
@@ -1606,7 +1653,7 @@ export function FileManagerPage() {
             {folders?.some((f) => f.is_external) && (
             {folders?.some((f) => f.is_external) && (
               <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
               <option value="__top:external">🔗 {t('fileManager.allExternal')}</option>
             )}
             )}
-            {folders && (() => {
+            {sortedFolders && (() => {
               // Flatten folder tree for mobile selector
               // Flatten folder tree for mobile selector
               const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
               const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
                 const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
                 const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
@@ -1618,7 +1665,7 @@ export function FileManagerPage() {
                 }
                 }
                 return result;
                 return result;
               };
               };
-              return flattenFolders(folders).map((folder) => (
+              return flattenFolders(sortedFolders).map((folder) => (
                 <option key={folder.id} value={folder.id}>
                 <option key={folder.id} value={folder.id}>
                   {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
                   {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
                 </option>
                 </option>
@@ -1658,6 +1705,35 @@ export function FileManagerPage() {
           <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
           <div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
             <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
             <h2 className="text-sm font-medium text-white">{t('fileManager.folders')}</h2>
             <div className="flex items-center gap-1">
             <div className="flex items-center gap-1">
+              {/* Folder tree sort (#1770). Dropdown drives the comparator;
+                  direction button flips asc/desc. Both persist to localStorage
+                  on change so the choice survives reloads. */}
+              <select
+                value={folderSortField}
+                onChange={(e) => {
+                  const v = e.target.value === 'activity' ? 'activity' : 'name';
+                  setFolderSortField(v);
+                  localStorage.setItem('library-folder-sort-field', v);
+                }}
+                className="text-xs px-1 py-0.5 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray focus:outline-none focus:border-bambu-green"
+                title={t('fileManager.folderSort')}
+                aria-label={t('fileManager.folderSort')}
+              >
+                <option value="name">{t('fileManager.folderSortByName')}</option>
+                <option value="activity">{t('fileManager.folderSortByActivity')}</option>
+              </select>
+              <button
+                onClick={() => {
+                  const newValue = folderSortDirection === 'asc' ? 'desc' : 'asc';
+                  setFolderSortDirection(newValue);
+                  localStorage.setItem('library-folder-sort-direction', newValue);
+                }}
+                className="text-bambu-gray hover:text-white hover:bg-bambu-dark p-1 rounded transition-colors"
+                title={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
+                aria-label={folderSortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}
+              >
+                {folderSortDirection === 'asc' ? <SortAsc className="w-3.5 h-3.5" /> : <SortDesc className="w-3.5 h-3.5" />}
+              </button>
               <button
               <button
                 onClick={() => {
                 onClick={() => {
                   const newValue = !collapseFoldersByDefault;
                   const newValue = !collapseFoldersByDefault;
@@ -1732,7 +1808,7 @@ export function FileManagerPage() {
             {/* Folder tree — re-key on the collapse toggle so flipping it
             {/* Folder tree — re-key on the collapse toggle so flipping it
                 remounts every FolderTreeItem, which re-reads defaultExpanded
                 remounts every FolderTreeItem, which re-reads defaultExpanded
                 and makes the preference take effect immediately. */}
                 and makes the preference take effect immediately. */}
-            {folders?.map((folder) => (
+            {sortedFolders?.map((folder) => (
               <FolderTreeItem
               <FolderTreeItem
                 key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
                 key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}
                 folder={folder}
                 folder={folder}
@@ -2086,9 +2162,12 @@ export function FileManagerPage() {
                   column couldn't fit (#1325 follow-up reported in chat). */}
                   column couldn't fit (#1325 follow-up reported in chat). */}
               <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto">
               <div className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-x-auto">
                 {/* List header - hidden on mobile, show simplified on small screens.
                 {/* List header - hidden on mobile, show simplified on small screens.
-                    The trailing column is `min-content` so it sizes to the widest
-                    action-icon strip across all rows (sliced 3MF = 7 icons ~220px). */}
-                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]' : 'grid-cols-[auto_1fr_100px_100px_100px_min-content]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
+                    Trailing actions column is fixed at 220px (sliced 3MF = 7 icons
+                    ~220px). It used to be `min-content`, but header + body are sibling
+                    grids that compute `min-content` independently — the header's empty
+                    trailing div resolved to 0px, leaving body columns shifted left of
+                    their headers. Fixed width keeps header and body in lockstep. */}
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
                   <div>{t('common.name')}</div>
                   {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
@@ -2101,7 +2180,7 @@ export function FileManagerPage() {
                 {filteredAndSortedFiles.map((file) => (
                 {filteredAndSortedFiles.map((file) => (
                   <div
                   <div
                     key={file.id}
                     key={file.id}
-                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]' : 'grid-cols-[auto_1fr_100px_100px_100px_min-content]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     }`}
                     onClick={() => handleFileSelect(file.id)}
                     onClick={() => handleFileSelect(file.id)}

Некоторые файлы не были показаны из-за большого количества измененных файлов