siftcode 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,560 @@
1
+ """
2
+ AnalyzerView — runs the analysis pipeline in a background thread.
3
+
4
+ Uses Gio.Task + Gio.Cancellable to run the analyzer without freezing
5
+ the UI, and reports progress via GLib.idle_add.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import subprocess
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ import gi
16
+
17
+ gi.require_version("Gtk", "4.0")
18
+ gi.require_version("Adw", "1")
19
+
20
+ from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
21
+
22
+ from siftcode.core.analyzer import Findings, Progress, ProjectAnalyzer # noqa: E402
23
+ from siftcode.core.config import ProjectConfig # noqa: E402
24
+ from siftcode.core.llm import LLMError, OllamaClient # noqa: E402
25
+ from siftcode.gui.widgets.log_view import LogView # noqa: E402
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Default output base directory
32
+ OUTPUT_BASE = Path.home() / "siftcode-output"
33
+
34
+
35
+ class AnalyzerView(Adw.Bin):
36
+ """
37
+ A view for running analysis and showing progress.
38
+
39
+ Callbacks:
40
+ set_on_back(cb) — cb() called when Back is pressed
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+
46
+ self._config: ProjectConfig | None = None
47
+ self._on_back = None
48
+
49
+ # Analysis state
50
+ self._cancellable: Gio.Cancellable | None = None
51
+ self._running = False
52
+ self._started_at: datetime | None = None
53
+ self._output_dir: Path | None = None
54
+ self._guide_path: Path | None = None
55
+ self._findings_path: Path | None = None
56
+ self._analyzer: ProjectAnalyzer | None = None
57
+
58
+ # --- Toolbar view with header -------------------------------------
59
+ self._toolbar_view = Adw.ToolbarView()
60
+
61
+ self._header = Adw.HeaderBar()
62
+ self._title = Adw.WindowTitle(title="Analyzer", subtitle="")
63
+ self._header.set_title_widget(self._title)
64
+
65
+ # Left: back button
66
+ self._back_button = Gtk.Button.new_from_icon_name("go-previous-symbolic")
67
+ self._back_button.set_tooltip_text("Back")
68
+ self._back_button.connect("clicked", self._on_back_clicked)
69
+ self._header.pack_start(self._back_button)
70
+
71
+ # Right: open guide button (visible after completion)
72
+ self._open_guide_button = Gtk.Button(label="Open guide")
73
+ self._open_guide_button.set_tooltip_text("Preview the generated guide")
74
+ self._open_guide_button.set_visible(False)
75
+ self._open_guide_button.connect("clicked", self._on_open_guide_clicked)
76
+ self._header.pack_end(self._open_guide_button)
77
+
78
+ # Right: open folder button (visible after completion)
79
+ self._open_folder_button = Gtk.Button.new_from_icon_name(
80
+ "folder-open-symbolic"
81
+ )
82
+ self._open_folder_button.set_tooltip_text("Open output folder")
83
+ self._open_folder_button.set_visible(False)
84
+ self._open_folder_button.connect("clicked", self._on_open_folder_clicked)
85
+ self._header.pack_end(self._open_folder_button)
86
+
87
+ # Right: analyze button (visible when idle)
88
+ self._analyze_button = Gtk.Button(label="Analyze")
89
+ self._analyze_button.add_css_class("suggested-action")
90
+ self._analyze_button.connect("clicked", self._on_analyze_clicked)
91
+ self._header.pack_end(self._analyze_button)
92
+
93
+ # Right: cancel button (visible during analysis)
94
+ self._cancel_button = Gtk.Button(label="Cancel")
95
+ self._cancel_button.add_css_class("destructive-action")
96
+ self._cancel_button.set_visible(False)
97
+ self._cancel_button.connect("clicked", self._on_cancel_clicked)
98
+ self._header.pack_end(self._cancel_button)
99
+
100
+ self._toolbar_view.add_top_bar(self._header)
101
+
102
+ # --- Content ------------------------------------------------------
103
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
104
+ content.set_margin_top(24)
105
+ content.set_margin_bottom(24)
106
+ content.set_margin_start(24)
107
+ content.set_margin_end(24)
108
+
109
+ # Status group
110
+ status_group = Adw.PreferencesGroup()
111
+ status_group.set_title("Status")
112
+
113
+ self._status_row = Adw.ActionRow()
114
+ self._status_row.set_title("Ready to analyze")
115
+ self._status_row.set_subtitle("Press Analyze to start")
116
+ self._status_icon = Gtk.Image.new_from_icon_name("media-playback-start-symbolic")
117
+ self._status_icon.set_pixel_size(20)
118
+ self._status_row.add_prefix(self._status_icon)
119
+ status_group.add(self._status_row)
120
+
121
+ self._progress_row = Adw.ActionRow()
122
+ self._progress_row.set_title("Progress")
123
+
124
+ self._progress_bar = Gtk.ProgressBar()
125
+ self._progress_bar.set_valign(Gtk.Align.CENTER)
126
+ self._progress_bar.set_size_request(300, -1)
127
+ self._progress_bar.set_show_text(True)
128
+ self._progress_row.add_suffix(self._progress_bar)
129
+ status_group.add(self._progress_row)
130
+
131
+ content.append(status_group)
132
+
133
+ # Results group (hidden until analysis completes)
134
+ self._results_group = Adw.PreferencesGroup()
135
+ self._results_group.set_title("Results")
136
+ self._results_group.set_visible(False)
137
+
138
+ self._files_row = Adw.ActionRow()
139
+ self._files_row.set_title("Files analyzed")
140
+ self._files_row.set_subtitle("—")
141
+ self._files_row.add_prefix(
142
+ Gtk.Image.new_from_icon_name("text-x-generic-symbolic")
143
+ )
144
+ self._results_group.add(self._files_row)
145
+
146
+ self._directories_row = Adw.ActionRow()
147
+ self._directories_row.set_title("Directories summarized")
148
+ self._directories_row.set_subtitle("—")
149
+ self._directories_row.add_prefix(
150
+ Gtk.Image.new_from_icon_name("folder-symbolic")
151
+ )
152
+ self._results_group.add(self._directories_row)
153
+
154
+ self._errors_row = Adw.ActionRow()
155
+ self._errors_row.set_title("Errors")
156
+ self._errors_row.set_subtitle("—")
157
+ self._errors_row.add_prefix(
158
+ Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
159
+ )
160
+ self._results_group.add(self._errors_row)
161
+
162
+ self._elapsed_row = Adw.ActionRow()
163
+ self._elapsed_row.set_title("Duration")
164
+ self._elapsed_row.set_subtitle("—")
165
+ self._elapsed_row.add_prefix(
166
+ Gtk.Image.new_from_icon_name("preferences-system-time-symbolic")
167
+ )
168
+ self._results_group.add(self._elapsed_row)
169
+
170
+ self._output_row = Adw.ActionRow()
171
+ self._output_row.set_title("Output folder")
172
+ self._output_row.set_subtitle("—")
173
+ self._output_row.set_activatable(True)
174
+ self._output_row.add_prefix(
175
+ Gtk.Image.new_from_icon_name("folder-open-symbolic")
176
+ )
177
+ self._output_row.connect(
178
+ "activated", lambda *_: self._on_open_folder_clicked(None)
179
+ )
180
+ self._results_group.add(self._output_row)
181
+
182
+ content.append(self._results_group)
183
+
184
+ # Log group
185
+ log_group = Adw.PreferencesGroup()
186
+ log_group.set_title("Log")
187
+
188
+ self._log_view = LogView()
189
+ log_group.add(self._log_view)
190
+
191
+ content.append(log_group)
192
+
193
+ # Wrap in scrolled window
194
+ scrolled = Gtk.ScrolledWindow()
195
+ scrolled.set_vexpand(True)
196
+ scrolled.set_child(content)
197
+ self._toolbar_view.set_content(scrolled)
198
+
199
+ self.set_child(self._toolbar_view)
200
+
201
+ # Start with a "loaded" message
202
+ self._log_view.append("Analyzer ready", "info")
203
+
204
+ # ------------------------------------------------------------------
205
+ # Public API
206
+ # ------------------------------------------------------------------
207
+
208
+ def set_on_back(self, callback) -> None:
209
+ self._on_back = callback
210
+
211
+ def load_config(self, config: ProjectConfig) -> None:
212
+ """Prepare the view for a given project."""
213
+ if self._running:
214
+ self._cancel_analysis()
215
+
216
+ self._config = config
217
+ self._title.set_title(f"Analyzer — {config.name}")
218
+ self._title.set_subtitle(str(config.path))
219
+
220
+ self._set_status(
221
+ "Ready to analyze",
222
+ f"Model: {config.model.name}",
223
+ "media-playback-start-symbolic",
224
+ )
225
+ self._progress_bar.set_fraction(0.0)
226
+ self._progress_bar.set_text("0 %")
227
+
228
+ # Reset results
229
+ self._results_group.set_visible(False)
230
+ self._output_dir = None
231
+ self._guide_path = None
232
+ self._findings_path = None
233
+
234
+ # Reset buttons
235
+ self._analyze_button.set_visible(True)
236
+ self._cancel_button.set_visible(False)
237
+ self._open_guide_button.set_visible(False)
238
+ self._open_folder_button.set_visible(False)
239
+ self._back_button.set_sensitive(True)
240
+
241
+ self._log_view.clear()
242
+ self._log_view.append(f"Loaded project '{config.name}'", "info")
243
+ self._log_view.append(f"Path: {config.path}", "info")
244
+ self._log_view.append(f"Model: {config.model.name}", "info")
245
+
246
+ def update_progress(self, progress: Progress) -> None:
247
+ """Called from the analysis worker (via GLib.idle_add)."""
248
+ self._progress_bar.set_fraction(progress.fraction)
249
+ self._progress_bar.set_text(f"{int(progress.fraction * 100)} %")
250
+
251
+ if progress.message:
252
+ self._set_status(
253
+ progress.message,
254
+ f"Stage: {progress.stage}",
255
+ "emblem-synchronizing-symbolic",
256
+ )
257
+ self._log_view.append(progress.message, "info")
258
+
259
+ def set_finished(self, findings: Findings, output_dir: Path) -> None:
260
+ """Called when analysis completes successfully."""
261
+ elapsed_secs = 0
262
+ if self._started_at is not None:
263
+ elapsed_secs = int((datetime.now() - self._started_at).total_seconds())
264
+
265
+ self._set_status(
266
+ f"Analysis complete in {elapsed_secs} s",
267
+ f"{len(findings.files)} files, "
268
+ f"{len(findings.directories)} directories, "
269
+ f"{len(findings.errors)} errors",
270
+ "emblem-ok-symbolic",
271
+ )
272
+ self._progress_bar.set_fraction(1.0)
273
+ self._progress_bar.set_text("100 %")
274
+
275
+ # Update results group
276
+ self._files_row.set_subtitle(str(len(findings.files)))
277
+ self._directories_row.set_subtitle(str(len(findings.directories)))
278
+ self._errors_row.set_subtitle(str(len(findings.errors)))
279
+ self._elapsed_row.set_subtitle(f"{elapsed_secs} seconds")
280
+
281
+ # Show a shortened path with ~ for the home directory
282
+ display_path = str(output_dir)
283
+ home = str(Path.home())
284
+ if display_path.startswith(home):
285
+ display_path = "~" + display_path[len(home):]
286
+ self._output_row.set_subtitle(display_path)
287
+ self._results_group.set_visible(True)
288
+
289
+ # Show action buttons
290
+ self._analyze_button.set_visible(True)
291
+ self._analyze_button.set_label("Analyze again")
292
+ self._cancel_button.set_visible(False)
293
+ self._open_guide_button.set_visible(True)
294
+ self._open_folder_button.set_visible(True)
295
+
296
+ # Log
297
+ self._log_view.append(f"✓ Analysis complete in {elapsed_secs} s", "success")
298
+ self._log_view.append(f"Files: {len(findings.files)}", "info")
299
+ self._log_view.append(f"Directories: {len(findings.directories)}", "info")
300
+ self._log_view.append(f"Errors: {len(findings.errors)}", "info")
301
+ self._log_view.append(f"Output: {output_dir}", "info")
302
+
303
+ if findings.errors:
304
+ for err in findings.errors[:10]:
305
+ self._log_view.append(
306
+ f"⚠ {err.get('stage', '?')}: "
307
+ f"{err.get('path', '?')} — {err.get('error', '?')}",
308
+ "warning",
309
+ )
310
+
311
+ def set_error(self, message: str) -> None:
312
+ """Called when analysis fails."""
313
+ self._set_status("Error", message, "dialog-error-symbolic")
314
+ self._log_view.append(f"✗ {message}", "error")
315
+
316
+ self._analyze_button.set_visible(True)
317
+ self._analyze_button.set_label("Analyze")
318
+ self._cancel_button.set_visible(False)
319
+ self._open_guide_button.set_visible(False)
320
+ self._open_folder_button.set_visible(False)
321
+ self._back_button.set_sensitive(True)
322
+
323
+ # ------------------------------------------------------------------
324
+ # Analysis: start / cancel / worker
325
+ # ------------------------------------------------------------------
326
+
327
+ def _on_analyze_clicked(self, _button) -> None:
328
+ if self._config is None:
329
+ return
330
+ if self._running:
331
+ return
332
+ self._start_analysis()
333
+
334
+ def _on_cancel_clicked(self, _button) -> None:
335
+ if self._running:
336
+ self._log_view.append("Cancelling...", "warning")
337
+ self._cancel_analysis()
338
+
339
+ def _start_analysis(self) -> None:
340
+ """Launch the analysis in a background thread via Gio.Task."""
341
+ if self._config is None or self._running:
342
+ return
343
+
344
+ self._running = True
345
+ self._started_at = datetime.now()
346
+ self._cancellable = Gio.Cancellable()
347
+
348
+ # Timestamp first so folders sort chronologically:
349
+ # 2026-10-16_21-55-42_<slug>
350
+ timestamp = self._started_at.strftime("%Y-%m-%d_%H-%M-%S")
351
+ slug = "".join(
352
+ c if c.isalnum() or c in "-_" else "-"
353
+ for c in self._config.name.lower().replace(" ", "-")
354
+ )
355
+ while "--" in slug:
356
+ slug = slug.replace("--", "-")
357
+ slug = slug.strip("-") or "project"
358
+
359
+ self._output_dir = OUTPUT_BASE / f"{timestamp}_{slug}"
360
+ self._output_dir.mkdir(parents=True, exist_ok=True)
361
+ self._guide_path = self._output_dir / "guide.md"
362
+ self._findings_path = self._output_dir / "findings.json"
363
+
364
+ self._log_view.append(
365
+ f"Starting analysis (output: {self._output_dir})", "info"
366
+ )
367
+
368
+ # Update UI
369
+ self._analyze_button.set_visible(False)
370
+ self._open_guide_button.set_visible(False)
371
+ self._open_folder_button.set_visible(False)
372
+ self._cancel_button.set_visible(True)
373
+ self._back_button.set_sensitive(False)
374
+ self._results_group.set_visible(False)
375
+ self._progress_bar.set_fraction(0.0)
376
+
377
+ self._set_status(
378
+ "Starting...",
379
+ f"Model: {self._config.model.name}",
380
+ "emblem-synchronizing-symbolic",
381
+ )
382
+
383
+ task = Gio.Task.new(self, self._cancellable, self._on_task_done, None)
384
+ task.set_return_on_cancel(True)
385
+ task.run_in_thread(self._analysis_worker)
386
+
387
+ def _analysis_worker(
388
+ self,
389
+ task: Gio.Task,
390
+ source_object,
391
+ task_data,
392
+ cancellable: Gio.Cancellable,
393
+ ) -> None:
394
+ """Runs on a worker thread. Do NOT touch GTK widgets here."""
395
+ try:
396
+ if self._config is None:
397
+ task.return_error(GLib.Error("No project configured"))
398
+ return
399
+
400
+ client = OllamaClient(self._config.model)
401
+ analyzer = ProjectAnalyzer(self._config, client)
402
+ self._analyzer = analyzer
403
+
404
+ for progress in analyzer.analyze_project():
405
+ if cancellable.is_cancelled():
406
+ task.return_error(GLib.Error("Cancelled by user"))
407
+ return
408
+ GLib.idle_add(self.update_progress, progress)
409
+
410
+ # Save results
411
+ if self._output_dir is not None:
412
+ analyzer.findings.save(self._findings_path)
413
+ self._guide_path.write_text(
414
+ analyzer.findings.guide, encoding="utf-8"
415
+ )
416
+
417
+ task.return_value(analyzer.findings)
418
+
419
+ except LLMError as e:
420
+ logger.error(f"LLM error during analysis: {e}")
421
+ task.return_error(GLib.Error(str(e)))
422
+ except Exception as e:
423
+ logger.exception(f"Unexpected error during analysis: {e}")
424
+ task.return_error(GLib.Error(f"Unexpected error: {e}"))
425
+
426
+ def _on_task_done(
427
+ self,
428
+ source_object,
429
+ result: Gio.AsyncResult,
430
+ user_data,
431
+ ) -> None:
432
+ """Runs on the main thread when the worker finishes."""
433
+ self._running = False
434
+ self._cancel_button.set_visible(False)
435
+ self._back_button.set_sensitive(True)
436
+
437
+ # Check if the user cancelled before looking at the result
438
+ if self._cancellable is not None and self._cancellable.is_cancelled():
439
+ self._analyze_button.set_visible(True)
440
+ self._analyze_button.set_label("Analyze")
441
+ self._log_view.append("✗ Analysis cancelled by user", "warning")
442
+ self._set_status(
443
+ "Cancelled",
444
+ "Analysis was cancelled",
445
+ "process-stop-symbolic",
446
+ )
447
+ self._progress_bar.set_fraction(0.0)
448
+ self._progress_bar.set_text("—")
449
+ self._cancellable = None
450
+ return
451
+
452
+ try:
453
+ findings = result.propagate_value().value
454
+ except GLib.Error as e:
455
+ message = e.message
456
+ if "Cancelled" in message:
457
+ self._analyze_button.set_visible(True)
458
+ self._analyze_button.set_label("Analyze")
459
+ self._log_view.append("✗ Analysis cancelled by user", "warning")
460
+ self._set_status(
461
+ "Cancelled",
462
+ "Analysis was cancelled",
463
+ "process-stop-symbolic",
464
+ )
465
+ self._progress_bar.set_fraction(0.0)
466
+ self._progress_bar.set_text("—")
467
+ else:
468
+ self._analyze_button.set_visible(True)
469
+ self._analyze_button.set_label("Analyze")
470
+ self.set_error(message)
471
+ self._cancellable = None
472
+ return
473
+
474
+ if self._output_dir is None:
475
+ self.set_error("Analysis completed but no output directory")
476
+ self._cancellable = None
477
+ return
478
+
479
+ # Update last_run timestamp in the project config and save it
480
+ if self._config is not None:
481
+ try:
482
+ from siftcode.core.config import save_project
483
+ self._config.last_run = datetime.now().isoformat(timespec="seconds")
484
+ save_project(self._config)
485
+ logger.info(f"Updated last_run for '{self._config.name}'")
486
+ except Exception as e:
487
+ logger.warning(f"Could not save last_run: {e}")
488
+
489
+ self.set_finished(findings, self._output_dir)
490
+ self._cancellable = None
491
+
492
+ def _cancel_analysis(self) -> None:
493
+ """Request cancellation of the current analysis."""
494
+ if self._cancellable is not None:
495
+ self._cancellable.cancel()
496
+
497
+ # ------------------------------------------------------------------
498
+ # Open guide / folder
499
+ # ------------------------------------------------------------------
500
+
501
+ def _on_open_guide_clicked(self, _button) -> None:
502
+ """Open the guide in an integrated Markdown preview window."""
503
+ if self._guide_path is None or not self._guide_path.exists():
504
+ self._log_view.append("Guide file not found", "warning")
505
+ return
506
+
507
+ from siftcode.gui.guide_window import GuideWindow
508
+
509
+ title = "Guide"
510
+ if self._config is not None:
511
+ title = f"Guide — {self._config.name}"
512
+
513
+ window = GuideWindow(self.get_root(), title, self._guide_path)
514
+ window.present()
515
+
516
+ def _on_open_folder_clicked(self, _button) -> None:
517
+ """Open the output folder in the system's file manager."""
518
+ if self._output_dir is None or not self._output_dir.exists():
519
+ self._log_view.append("Output folder not found", "warning")
520
+ return
521
+ self._open_with_system_default(self._output_dir)
522
+
523
+ def _open_with_system_default(self, path: Path) -> None:
524
+ """Open a file or folder with the desktop's default application."""
525
+ try:
526
+ subprocess.Popen(
527
+ ["xdg-open", str(path)],
528
+ stdout=subprocess.DEVNULL,
529
+ stderr=subprocess.DEVNULL,
530
+ )
531
+ self._log_view.append(f"Opened: {path}", "info")
532
+ except FileNotFoundError:
533
+ self._log_view.append(
534
+ "xdg-open not found — is your desktop environment complete?",
535
+ "warning",
536
+ )
537
+ except Exception as e:
538
+ self._log_view.append(f"Could not open {path}: {e}", "error")
539
+
540
+ # ------------------------------------------------------------------
541
+ # Internal
542
+ # ------------------------------------------------------------------
543
+
544
+ def _set_status(self, title: str, subtitle: str, icon_name: str) -> None:
545
+ self._status_row.set_title(title)
546
+ self._status_row.set_subtitle(subtitle)
547
+ self._status_icon.set_from_icon_name(icon_name)
548
+
549
+ # ------------------------------------------------------------------
550
+ # Signals
551
+ # ------------------------------------------------------------------
552
+
553
+ def _on_back_clicked(self, _button) -> None:
554
+ if self._running:
555
+ self._log_view.append(
556
+ "Please cancel the analysis before leaving", "warning"
557
+ )
558
+ return
559
+ if self._on_back:
560
+ self._on_back()