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/gui/window.py ADDED
@@ -0,0 +1,446 @@
1
+ """
2
+ Main application window for Siftcode.
3
+
4
+ Phase 4: Welcome / Projects / Settings / Analyzer views via a Gtk.Stack.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ import gi
13
+
14
+ gi.require_version("Gtk", "4.0")
15
+ gi.require_version("Adw", "1")
16
+
17
+ from gi.repository import Adw, Gio, Gtk # noqa: E402
18
+
19
+ from siftcode.core.config import ( # noqa: E402
20
+ PROJECTS_DIR,
21
+ ProjectConfig,
22
+ delete_project,
23
+ list_projects,
24
+ save_project,
25
+ )
26
+ from siftcode.gui.analyzer_view import AnalyzerView # noqa: E402
27
+ from siftcode.gui.project_list import ProjectListView # noqa: E402
28
+ from siftcode.gui.settings_view import SettingsView # noqa: E402
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class MainWindow(Adw.ApplicationWindow):
35
+ """The main window of Siftcode."""
36
+
37
+ def __init__(self, **kwargs) -> None:
38
+ super().__init__(**kwargs)
39
+
40
+ self.set_title("Siftcode")
41
+ self.set_default_size(900, 640)
42
+ self.set_size_request(600, 400)
43
+
44
+ # Active row reference for context menu actions
45
+ self._active_row = None
46
+
47
+ # --- Window-level actions for the row context menu ------------
48
+ action_group = Gio.SimpleActionGroup()
49
+
50
+ edit_row_action = Gio.SimpleAction.new("edit-row", None)
51
+ edit_row_action.connect("activate", self._on_edit_row_action)
52
+ action_group.add_action(edit_row_action)
53
+
54
+ open_guide_action = Gio.SimpleAction.new("open-latest-guide", None)
55
+ open_guide_action.connect("activate", self._on_open_latest_guide)
56
+ action_group.add_action(open_guide_action)
57
+
58
+ open_folder_action = Gio.SimpleAction.new("open-output-folder", None)
59
+ open_folder_action.connect("activate", self._on_open_output_folder)
60
+ action_group.add_action(open_folder_action)
61
+
62
+ delete_row_action = Gio.SimpleAction.new("delete-row", None)
63
+ delete_row_action.connect("activate", self._on_delete_row_action)
64
+ action_group.add_action(delete_row_action)
65
+
66
+ self.insert_action_group("window", action_group)
67
+
68
+ # Root layout
69
+ toolbar_view = Adw.ToolbarView()
70
+
71
+ # Main header bar (shown on Welcome / Projects views)
72
+ self.main_header = Adw.HeaderBar()
73
+ self.main_header.set_title_widget(
74
+ Adw.WindowTitle(title="Siftcode", subtitle="")
75
+ )
76
+
77
+ self.add_button = Gtk.Button.new_from_icon_name("list-add-symbolic")
78
+ self.add_button.set_tooltip_text("Add a project")
79
+ self.add_button.connect("clicked", self._on_add_clicked)
80
+ self.main_header.pack_start(self.add_button)
81
+
82
+ menu_button = Gtk.MenuButton()
83
+ menu_button.set_icon_name("open-menu-symbolic")
84
+ menu_button.set_tooltip_text("Main menu")
85
+
86
+ menu = Gio.Menu()
87
+ menu.append("About Siftcode", "app.about")
88
+ menu.append("Quit", "app.quit")
89
+ menu_button.set_menu_model(menu)
90
+
91
+ self.main_header.pack_end(menu_button)
92
+
93
+ toolbar_view.add_top_bar(self.main_header)
94
+
95
+ # Content: stack of views
96
+ self.stack = Gtk.Stack()
97
+ self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
98
+
99
+ # Welcome
100
+ self.stack.add_named(self._build_welcome_view(), "welcome")
101
+
102
+ # Project list
103
+ self.project_list = ProjectListView()
104
+ self.project_list.set_on_project_activated(self._on_project_activated)
105
+ self.stack.add_named(self.project_list, "projects")
106
+
107
+ # Analyzer view (created before settings, since settings hands off to it)
108
+ self.analyzer_view = AnalyzerView()
109
+ self.analyzer_view.set_on_back(self._on_analyzer_back)
110
+ self.stack.add_named(self.analyzer_view, "analyzer")
111
+
112
+ # Settings view
113
+ self.settings_view = SettingsView()
114
+ self.settings_view.set_on_saved(self._on_settings_saved)
115
+ self.settings_view.set_on_cancelled(self._on_settings_cancelled)
116
+ self.settings_view.set_on_deleted(self._on_settings_deleted)
117
+ self.settings_view.set_on_analyze(self._on_start_analysis) # ← UUSI
118
+ self.stack.add_named(self.settings_view, "settings")
119
+
120
+ try:
121
+ has_projects = len(list_projects()) > 0
122
+ except Exception:
123
+ has_projects = False
124
+
125
+ self.stack.set_visible_child_name("projects" if has_projects else "welcome")
126
+
127
+ toolbar_view.set_content(self.stack)
128
+
129
+ self.set_content(toolbar_view)
130
+
131
+ # ------------------------------------------------------------------
132
+ # Welcome view
133
+ # ------------------------------------------------------------------
134
+
135
+ def _build_welcome_view(self) -> Gtk.Widget:
136
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
137
+ box.set_margin_top(48)
138
+ box.set_margin_bottom(48)
139
+ box.set_margin_start(48)
140
+ box.set_margin_end(48)
141
+ box.set_valign(Gtk.Align.CENTER)
142
+ box.set_halign(Gtk.Align.CENTER)
143
+
144
+ icon = Gtk.Image.new_from_icon_name("system-search-symbolic")
145
+ icon.set_pixel_size(96)
146
+ icon.add_css_class("welcome-icon")
147
+ box.append(icon)
148
+
149
+ title = Gtk.Label(label="Siftcode")
150
+ title.add_css_class("welcome-title")
151
+ box.append(title)
152
+
153
+ subtitle = Gtk.Label(
154
+ label="Local LLM-powered code analysis and developer guide generator",
155
+ wrap=True,
156
+ justify=Gtk.Justification.CENTER,
157
+ )
158
+ subtitle.add_css_class("welcome-subtitle")
159
+ box.append(subtitle)
160
+
161
+ box.append(Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24))
162
+
163
+ try:
164
+ project_count = len(list_projects())
165
+ except Exception:
166
+ project_count = 0
167
+
168
+ stats = self._build_stats_row(
169
+ ("Saved projects", str(project_count)),
170
+ ("Config dir", str(PROJECTS_DIR).replace(str(Path.home()), "~")),
171
+ )
172
+ box.append(stats)
173
+
174
+ return box
175
+
176
+ def _build_stats_row(self, *pairs: tuple[str, str]) -> Gtk.Widget:
177
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=48)
178
+ row.set_halign(Gtk.Align.CENTER)
179
+ row.set_margin_top(24)
180
+
181
+ for label, value in pairs:
182
+ col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
183
+ col.set_halign(Gtk.Align.CENTER)
184
+
185
+ value_label = Gtk.Label(label=value)
186
+ value_label.add_css_class("stat-value")
187
+ col.append(value_label)
188
+
189
+ name_label = Gtk.Label(label=label)
190
+ name_label.add_css_class("stat-label")
191
+ col.append(name_label)
192
+
193
+ row.append(col)
194
+
195
+ return row
196
+
197
+ # ------------------------------------------------------------------
198
+ # Row context actions
199
+ # ------------------------------------------------------------------
200
+
201
+ def _on_edit_row_action(self, _action, _param) -> None:
202
+ row = self._active_row
203
+ if row is None:
204
+ return
205
+ config = getattr(row, "config", None)
206
+ if config is not None:
207
+ self._on_project_activated(config)
208
+
209
+ def _project_slug(self, name: str) -> str:
210
+ """Convert a project name to the same slug used in output folders."""
211
+ slug = "".join(
212
+ c if c.isalnum() or c in "-_" else "-"
213
+ for c in name.lower().replace(" ", "-")
214
+ )
215
+ while "--" in slug:
216
+ slug = slug.replace("--", "-")
217
+ return slug.strip("-") or "project"
218
+
219
+ def _find_latest_output(self, config: ProjectConfig) -> "Path | None":
220
+ """Return the most recent output folder for the given project."""
221
+ from pathlib import Path
222
+
223
+ output_base = Path.home() / "siftcode-output"
224
+ if not output_base.exists():
225
+ return None
226
+
227
+ slug = self._project_slug(config.name)
228
+
229
+ # Match both old (slug_timestamp) and new (timestamp_slug) formats
230
+ candidates = [
231
+ d for d in output_base.iterdir()
232
+ if d.is_dir() and (d.name.endswith(f"_{slug}") or d.name.startswith(f"{slug}_"))
233
+ ]
234
+ if not candidates:
235
+ return None
236
+
237
+ # Sort by name (timestamp prefix in new format, suffix in old)
238
+ candidates.sort(key=lambda d: d.name, reverse=True)
239
+ return candidates[0]
240
+
241
+ def _on_open_latest_guide(self, _action, _param) -> None:
242
+ """Open the latest guide for the active row's project."""
243
+ row = self._active_row
244
+ if row is None:
245
+ return
246
+ config = getattr(row, "config", None)
247
+ if config is None:
248
+ return
249
+
250
+ latest = self._find_latest_output(config)
251
+ if latest is None:
252
+ return
253
+
254
+ guide_path = latest / "guide.md"
255
+ if not guide_path.exists():
256
+ return
257
+
258
+ from siftcode.gui.guide_window import GuideWindow
259
+
260
+ window = GuideWindow(self, f"Guide — {config.name}", guide_path)
261
+ window.present()
262
+
263
+ def _on_open_output_folder(self, _action, _param) -> None:
264
+ """Open the latest output folder for the active row's project."""
265
+ import subprocess
266
+
267
+ row = self._active_row
268
+ if row is None:
269
+ return
270
+ config = getattr(row, "config", None)
271
+ if config is None:
272
+ return
273
+
274
+ latest = self._find_latest_output(config)
275
+ if latest is None:
276
+ return
277
+
278
+ try:
279
+ subprocess.Popen(
280
+ ["xdg-open", str(latest)],
281
+ stdout=subprocess.DEVNULL,
282
+ stderr=subprocess.DEVNULL,
283
+ )
284
+ except Exception:
285
+ pass
286
+
287
+ def _on_delete_row_action(self, _action, _param) -> None:
288
+ row = self._active_row
289
+ if row is None:
290
+ return
291
+ config = getattr(row, "config", None)
292
+ if config is None:
293
+ return
294
+
295
+ dialog = Adw.MessageDialog.new(
296
+ self,
297
+ "Delete project?",
298
+ f"Are you sure you want to delete '{config.name}'?\n\n"
299
+ f"This removes the saved configuration, not the project files.",
300
+ )
301
+ dialog.add_response("cancel", "Cancel")
302
+ dialog.add_response("delete", "Delete")
303
+ dialog.set_response_appearance(
304
+ "delete", Adw.ResponseAppearance.DESTRUCTIVE
305
+ )
306
+ dialog.set_default_response("cancel")
307
+ dialog.set_close_response("cancel")
308
+ dialog.connect("response", self._on_delete_row_response, config)
309
+ dialog.present()
310
+
311
+ def _on_delete_row_response(self, _dialog, response_id: str, config) -> None:
312
+ if response_id != "delete":
313
+ return
314
+
315
+ if delete_project(config.name):
316
+ logger.info(f"Deleted project: {config.name}")
317
+ self.project_list.refresh()
318
+ else:
319
+ error = Adw.MessageDialog.new(
320
+ self,
321
+ "Could not delete",
322
+ f"Project '{config.name}' was not found on disk.",
323
+ )
324
+ error.add_response("ok", "OK")
325
+ error.present()
326
+
327
+ # ------------------------------------------------------------------
328
+ # View callbacks
329
+ # ------------------------------------------------------------------
330
+
331
+ def _on_project_activated(self, config: ProjectConfig) -> None:
332
+ logger.info(f"Project activated: {config.name}")
333
+ # Hide the main header — SettingsView has its own
334
+ self.main_header.set_visible(False)
335
+ self.settings_view.load_config(config)
336
+ self.stack.set_visible_child_name("settings")
337
+
338
+ def _on_settings_saved(self, config: ProjectConfig) -> None:
339
+ logger.info(f"Settings saved for: {config.name}")
340
+ # Restore the main header
341
+ self.main_header.set_visible(True)
342
+ self.project_list.refresh()
343
+ self.stack.set_visible_child_name("projects")
344
+
345
+ def _on_settings_cancelled(self) -> None:
346
+ # Restore the main header
347
+ self.main_header.set_visible(True)
348
+ self.stack.set_visible_child_name("projects")
349
+
350
+ def _on_settings_deleted(self, config: ProjectConfig) -> None:
351
+ logger.info(f"Settings view deleted: {config.name}")
352
+ # Restore the main header
353
+ self.main_header.set_visible(True)
354
+ self.project_list.refresh()
355
+ self.stack.set_visible_child_name("projects")
356
+
357
+ def _on_analyzer_back(self) -> None:
358
+ """Called when the Back button in the analyzer is pressed."""
359
+ # Refresh the project list so last_run updates are visible
360
+ self.project_list.refresh()
361
+ # Return to the project list
362
+ self.main_header.set_visible(True)
363
+ self.stack.set_visible_child_name("projects")
364
+
365
+ def _on_start_analysis(self, config: ProjectConfig) -> None:
366
+ """Called when the user clicks Analyze in settings."""
367
+ logger.info(f"Starting analysis for: {config.name}")
368
+ self.main_header.set_visible(False)
369
+ self.analyzer_view.load_config(config)
370
+ self.stack.set_visible_child_name("analyzer")
371
+
372
+ # ------------------------------------------------------------------
373
+ # Add project
374
+ # ------------------------------------------------------------------
375
+
376
+ def _on_add_clicked(self, _button) -> None:
377
+ dialog = Gtk.FileDialog.new()
378
+ dialog.set_title("Select a project directory")
379
+ dialog.select_folder(self, None, self._on_folder_selected)
380
+
381
+ def _on_folder_selected(self, dialog, result) -> None:
382
+ try:
383
+ folder = dialog.select_folder_finish(result)
384
+ except Exception as e:
385
+ logger.debug(f"Folder selection cancelled or failed: {e}")
386
+ return
387
+
388
+ if folder is None:
389
+ return
390
+
391
+ path = Path(folder.get_path())
392
+ logger.info(f"Adding project: {path}")
393
+ self._ask_for_project_name(path)
394
+
395
+ def _ask_for_project_name(self, path: Path) -> None:
396
+ entry = Gtk.Entry()
397
+ entry.set_text(path.name)
398
+ entry.set_activates_default(True)
399
+
400
+ dialog = Adw.MessageDialog.new(
401
+ self,
402
+ "Name this project",
403
+ f"Directory: {path}",
404
+ )
405
+ dialog.set_extra_child(entry)
406
+ dialog.add_response("cancel", "Cancel")
407
+ dialog.add_response("add", "Add")
408
+ dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
409
+ dialog.set_default_response("add")
410
+ dialog.set_close_response("cancel")
411
+
412
+ dialog.connect("response", self._on_name_response, path, entry)
413
+ dialog.present()
414
+
415
+ def _on_name_response(
416
+ self,
417
+ _dialog,
418
+ response_id: str,
419
+ path: Path,
420
+ entry: Gtk.Entry,
421
+ ) -> None:
422
+ if response_id != "add":
423
+ return
424
+
425
+ name = entry.get_text().strip()
426
+ if not name:
427
+ name = path.name
428
+
429
+ config = ProjectConfig(name=name, path=path)
430
+
431
+ try:
432
+ save_project(config)
433
+ logger.info(f"Saved project: {name}")
434
+ except Exception as e:
435
+ logger.error(f"Failed to save project: {e}")
436
+ error = Adw.MessageDialog.new(
437
+ self,
438
+ "Could not save project",
439
+ str(e),
440
+ )
441
+ error.add_response("ok", "OK")
442
+ error.present()
443
+ return
444
+
445
+ self.project_list.refresh()
446
+ self.stack.set_visible_child_name("projects")