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.
- siftcode/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|