44import sys
55import shutil
66import webbrowser
7+ import threading
78from datetime import datetime
89from pathlib import Path
910from tkinter import filedialog , messagebox
@@ -76,55 +77,88 @@ def choose_folder(app) -> None:
7677def toggle_subfolders (app , enabled : bool ) -> None :
7778 app .ui_state .include_subfolders = enabled
7879
79- def toggle_move_by_ext (app , enabled : bool ) -> None :
80- app .ui_state .move_by_ext = enabled
81-
8280def scan_and_plan (app ) -> None :
83- """Scan folder and build virtual plan without Find/Replace rules ."""
81+ """Scan folder and build virtual plan in a background thread ."""
8482 root = getattr (app .ui_state , "root" , None )
8583 if not root :
8684 messagebox .showwarning ("BulkFolder" , "Please choose a folder first." )
8785 return
8886
8987 app .set_status ("Scanning..." )
88+ app .topbar_view .show_loading (True )
9089
91- # 1. Scan drive
92- scanned = scan_folder (app .ui_state .root , include_subfolders = app .ui_state .include_subfolders )
93- app .last_scan = scanned
94-
95- # 2. Apply Move rule only (Find/Replace removed)
96- move_rule = MoveByExtensionRule (DEFAULT_MAPPING ) if app .ui_state .move_by_ext else None
97- plan = build_plan (app .ui_state .root , scanned , replace_rule = None , move_rule = move_rule )
98- app .last_plan = plan
99-
100- # 3. Calculate statistics
101- files_count = sum (1 for x in scanned if x .is_file )
102- planned = sum (1 for it in plan .items if it .action .value != "skip" )
103- conflicts = sum (1 for it in plan .items if it .conflict )
104- total_size = sum (x .size for x in scanned if x .is_file )
105-
106- # 4. Update UI
107- app .cards_view .set_values (str (files_count ), str (planned ), str (conflicts ), app .human_bytes (total_size ))
108- app .preview_view .render (plan )
109- app .dashboard_view .render (scanned , mapping = DEFAULT_MAPPING )
110-
111- app .organizer_panel .set_apply_enabled ((not plan .has_conflicts ) and planned > 0 )
112- app .organizer_panel .set_undo_enabled (True )
113- app .tabs .set ("Preview" )
90+ def run_scan ():
91+ try :
92+ # 1. Heavy Scanning Logic
93+ scanned = scan_folder (app .ui_state .root , include_subfolders = app .ui_state .include_subfolders )
94+ move_rule = MoveByExtensionRule (DEFAULT_MAPPING ) if app .ui_state .move_by_ext else None
95+ plan = build_plan (app .ui_state .root , scanned , replace_rule = None , move_rule = move_rule )
96+
97+ # Statistics calculation
98+ files_count = sum (1 for x in scanned if x .is_file )
99+ planned = sum (1 for it in plan .items if it .action .value != "skip" )
100+ conflicts = sum (1 for it in plan .items if it .conflict )
101+ total_size = sum (x .size for x in scanned if x .is_file )
102+
103+ # 2. Update UI back on main thread
104+ app .after (0 , lambda : finalize_scan (scanned , plan , files_count , planned , conflicts , total_size ))
105+
106+ except Exception as e :
107+ app .after (0 , lambda : handle_scan_error (str (e )))
108+
109+ def finalize_scan (scanned , plan , files_count , planned , conflicts , total_size ):
110+ app .last_scan = scanned
111+ app .last_plan = plan
112+ app .cards_view .set_values (str (files_count ), str (planned ), str (conflicts ), app .human_bytes (total_size ))
113+ app .preview_view .render (plan )
114+ app .dashboard_view .render (scanned , mapping = DEFAULT_MAPPING )
115+
116+ app .organizer_panel .set_apply_enabled ((not plan .has_conflicts ) and planned > 0 )
117+ app .organizer_panel .set_undo_enabled (True )
118+ app .tabs .set ("Preview" )
119+
120+ app .topbar_view .show_loading (False )
121+ app .set_status ("Ready" )
122+ app .log (f"Scan complete: { files_count } files found." , level = "INFO" )
123+
124+ def handle_scan_error (error_msg : str ):
125+ app .topbar_view .show_loading (False )
126+ app .set_status ("Error during scan." )
127+ messagebox .showerror ("BulkFolder" , f"Scan failed: { error_msg } " )
128+
129+ # Start the background thread
130+ threading .Thread (target = run_scan , daemon = True ).start ()
114131
115132def apply_plan (app ) -> None :
116- """Physically apply changes."""
133+ """Physically apply changes in background ."""
117134 if not app .last_plan or sum (1 for it in app .last_plan .items if it .action .value != "skip" ) == 0 : return
118135 if app .last_plan .has_conflicts :
119136 messagebox .showerror ("BulkFolder" , "Plan has conflicts." )
120137 return
138+
121139 if _ask_confirm (app , "BulkFolder" , "Apply changes?" ):
122- try :
123- n = exec_apply_plan (app .last_plan , allow_conflicts = False )
124- app .set_status ("Applied." )
125- scan_and_plan (app )
126- except Exception as e :
127- messagebox .showerror ("BulkFolder" , f"Apply failed:\n { e } " )
140+ app .set_status ("Applying changes..." )
141+ app .topbar_view .show_loading (True )
142+
143+ def run_apply ():
144+ try :
145+ # Execution with our custom logging callback (UIState.log)
146+ n = exec_apply_plan (app .last_plan , allow_conflicts = False , on_progress = app .log )
147+ app .after (0 , lambda : finalize_apply ())
148+ except Exception as e :
149+ app .after (0 , lambda : handle_apply_error (str (e )))
150+
151+ def finalize_apply ():
152+ app .topbar_view .show_loading (False )
153+ app .set_status ("Ready" )
154+ scan_and_plan (app ) # Refresh after apply
155+
156+ def handle_apply_error (error_msg : str ):
157+ app .topbar_view .show_loading (False )
158+ app .set_status ("Error applying." )
159+ messagebox .showerror ("BulkFolder" , f"Apply failed: { error_msg } " )
160+
161+ threading .Thread (target = run_apply , daemon = True ).start ()
128162
129163def undo_last_ops (app ) -> None :
130164 """Revert last applied operations."""
@@ -137,27 +171,29 @@ def undo_last_ops(app) -> None:
137171 except Exception as e :
138172 messagebox .showerror ("BulkFolder" , f"Undo failed:\n { e } " )
139173
140- # Duplicates action remains in core but no longer called from Organizer Panel
141174def find_duplicates_action (app ) -> None :
142- """Scans for duplicate files."""
175+ """Scans for duplicate files in background ."""
143176 root = getattr (app .ui_state , "root" , None )
144177 if not root : return
145178
146- min_kb = int (app .settings .duplicate_min_size_kb ) if getattr (app , "settings" , None ) else 1
147- scanned = app .last_scan or scan_folder (app .ui_state .root , include_subfolders = app .ui_state .include_subfolders )
148- app .last_scan = scanned
149-
150- groups = find_duplicates (scanned , min_size_bytes = min_kb * 1024 )
151- ui_groups = [[str (p ) for p in grp ] for grp in groups ]
152-
153- app .duplicates_view .render (ui_groups )
154- app .tabs .set ("Duplicates" )
179+ app .set_status ("Finding duplicates..." )
180+ app .topbar_view .show_loading (True )
155181
182+ def run_duplicates ():
183+ min_kb = int (app .settings .duplicate_min_size_kb ) if getattr (app , "settings" , None ) else 1
184+ scanned = app .last_scan or scan_folder (app .ui_state .root , include_subfolders = app .ui_state .include_subfolders )
185+ groups = find_duplicates (scanned , min_size_bytes = min_kb * 1024 )
186+ ui_groups = [[str (p ) for p in grp ] for grp in groups ]
187+
188+ app .after (0 , lambda : finalize_duplicates (ui_groups ))
156189
157- # ==========================================
158- # OTHER PAGES (CHUNKERS, FLATTENER, ETC.)
159- # ==========================================
160- # (Logic remains identical to original)
190+ def finalize_duplicates (ui_groups ):
191+ app .duplicates_view .render (ui_groups )
192+ app .tabs .set ("Duplicates" )
193+ app .topbar_view .show_loading (False )
194+ app .set_status ("Ready" )
195+
196+ threading .Thread (target = run_duplicates , daemon = True ).start ()
161197
162198def chunker_choose_folder (app ) -> None :
163199 path = filedialog .askdirectory ()
@@ -190,14 +226,22 @@ def chunker_apply(app) -> None:
190226 if not plan or not root : return
191227 if _ask_confirm (app , "Folder Splitter" , f"Split this folder into { len (plan )} parts?" ):
192228 app .set_status ("Splitting folder..." )
193- app .update ()
194- success , errors = apply_chunks (root , plan )
195- if errors :
196- messagebox .showwarning ("Partial Success" , f"Moved: { success } \n Errors: { len (errors )} " )
197- else :
198- messagebox .showinfo ("Success" , f"Successfully chunked folder!" )
199- app .chunker_page .render_preview ([])
200- app .chunker_plan = []
229+ app .topbar_view .show_loading (True )
230+
231+ def run_chunk ():
232+ success , errors = apply_chunks (root , plan )
233+ app .after (0 , lambda : finalize_chunk (success , errors ))
234+
235+ def finalize_chunk (success , errors ):
236+ app .topbar_view .show_loading (False )
237+ app .set_status ("Ready" )
238+ if errors :
239+ messagebox .showwarning ("Partial Success" , f"Moved: { success } \n Errors: { len (errors )} " )
240+ else :
241+ messagebox .showinfo ("Success" , f"Successfully chunked folder!" )
242+ app .chunker_page .render_preview ([])
243+
244+ threading .Thread (target = run_chunk , daemon = True ).start ()
201245
202246def flattener_choose_folder (app ) -> None :
203247 path = filedialog .askdirectory ()
0 commit comments