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,430 @@
1
+ """
2
+ Settings view — edit a project's configuration.
3
+
4
+ Phase 3: basic fields, model settings, analysis settings.
5
+ Phase 4: Analyze button hands off to the analyzer view.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ import gi
14
+
15
+ gi.require_version("Gtk", "4.0")
16
+ gi.require_version("Adw", "1")
17
+
18
+ from gi.repository import Adw, Gio, Gtk # noqa: E402
19
+
20
+ from siftcode.core.config import ( # noqa: E402
21
+ ProjectConfig,
22
+ delete_project,
23
+ save_project,
24
+ )
25
+ from siftcode.gui.widgets.exclusion_list import ExclusionList # noqa: E402
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class SettingsView(Adw.Bin):
32
+ """
33
+ A view for editing one project's configuration.
34
+
35
+ Callbacks:
36
+ set_on_saved(cb) — cb(config) after Save writes to disk
37
+ set_on_cancelled(cb) — cb() when Cancel is clicked
38
+ set_on_deleted(cb) — cb(config) after Delete succeeds
39
+ set_on_analyze(cb) — cb(config) after Analyze is clicked
40
+ (settings are saved before this fires)
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+
46
+ self._config: ProjectConfig | None = None
47
+ self._on_saved = None
48
+ self._on_cancelled = None
49
+ self._on_deleted = None
50
+ self._on_analyze = None
51
+
52
+ # Root layout
53
+ self._toolbar_view = Adw.ToolbarView()
54
+
55
+ # Header bar with title + Cancel / Delete / Save / Analyze
56
+ self._header = Adw.HeaderBar()
57
+ self._title = Adw.WindowTitle(title="Settings", subtitle="")
58
+ self._header.set_title_widget(self._title)
59
+
60
+ self._cancel_button = Gtk.Button(label="Cancel")
61
+ self._cancel_button.connect("clicked", self._on_cancel_clicked)
62
+ self._header.pack_start(self._cancel_button)
63
+
64
+ # Analyze button — primary action (rightmost)
65
+ self._analyze_button = Gtk.Button(label="Analyze")
66
+ self._analyze_button.add_css_class("suggested-action")
67
+ self._analyze_button.connect("clicked", self._on_analyze_clicked)
68
+ self._header.pack_end(self._analyze_button)
69
+
70
+ # Save button
71
+ self._save_button = Gtk.Button(label="Save")
72
+ self._save_button.connect("clicked", self._on_save_clicked)
73
+ self._header.pack_end(self._save_button)
74
+
75
+ # Delete button (destructive action)
76
+ self._delete_button = Gtk.Button.new_from_icon_name("user-trash-symbolic")
77
+ self._delete_button.add_css_class("destructive-action")
78
+ self._delete_button.set_tooltip_text("Delete this project")
79
+ self._delete_button.connect("clicked", self._on_delete_clicked)
80
+ self._header.pack_end(self._delete_button)
81
+
82
+ self._toolbar_view.add_top_bar(self._header)
83
+
84
+ # Content
85
+ self._content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
86
+ self._content_box.set_margin_top(24)
87
+ self._content_box.set_margin_bottom(24)
88
+ self._content_box.set_margin_start(24)
89
+ self._content_box.set_margin_end(24)
90
+
91
+ scrolled = Gtk.ScrolledWindow()
92
+ scrolled.set_vexpand(True)
93
+ scrolled.set_child(self._content_box)
94
+ self._toolbar_view.set_content(scrolled)
95
+
96
+ self.set_child(self._toolbar_view)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Public API
100
+ # ------------------------------------------------------------------
101
+
102
+ def set_on_saved(self, callback) -> None:
103
+ self._on_saved = callback
104
+
105
+ def set_on_cancelled(self, callback) -> None:
106
+ self._on_cancelled = callback
107
+
108
+ def set_on_deleted(self, callback) -> None:
109
+ self._on_deleted = callback
110
+
111
+ def set_on_analyze(self, callback) -> None:
112
+ """Set a callback invoked when Analyze is clicked. Receives the config."""
113
+ self._on_analyze = callback
114
+
115
+ def load_config(self, config: ProjectConfig) -> None:
116
+ """Populate the view from a ProjectConfig (deep-copied)."""
117
+ self._config = ProjectConfig.from_dict(config.to_dict())
118
+
119
+ # Clear existing content
120
+ while (child := self._content_box.get_first_child()) is not None:
121
+ self._content_box.remove(child)
122
+
123
+ self._title.set_title(self._config.name or "Untitled")
124
+ self._title.set_subtitle(str(self._config.path))
125
+
126
+ self._content_box.append(self._build_identity_group())
127
+ self._content_box.append(self._build_model_group())
128
+ self._content_box.append(self._build_analysis_group())
129
+
130
+ # ------------------------------------------------------------------
131
+ # UI Groups
132
+ # ------------------------------------------------------------------
133
+
134
+ def _build_identity_group(self) -> Adw.PreferencesGroup:
135
+ group = Adw.PreferencesGroup()
136
+ group.set_title("Identity")
137
+
138
+ # Name
139
+ self._name_row = Adw.EntryRow()
140
+ self._name_row.set_title("Project name")
141
+ self._name_row.set_text(self._config.name)
142
+ self._name_row.connect("changed", self._on_name_changed)
143
+ group.add(self._name_row)
144
+
145
+ # Path (clickable — opens a folder picker)
146
+ self._path_row = Adw.ActionRow()
147
+ self._path_row.set_title("Path")
148
+ self._path_row.set_subtitle(str(self._config.path))
149
+ self._path_row.set_activatable(True)
150
+ self._path_row.connect("activated", self._on_change_path_clicked)
151
+
152
+ # "Change folder" button on the right
153
+ change_button = Gtk.Button.new_from_icon_name("document-open-symbolic")
154
+ change_button.set_tooltip_text("Change project folder")
155
+ change_button.add_css_class("flat")
156
+ change_button.set_valign(Gtk.Align.CENTER)
157
+ change_button.connect("clicked", self._on_change_path_clicked)
158
+ self._path_row.add_suffix(change_button)
159
+
160
+ group.add(self._path_row)
161
+
162
+ # Notes
163
+ notes_row = Adw.ExpanderRow()
164
+ notes_row.set_title("Notes")
165
+ notes_row.set_subtitle(self._config.notes or "No notes")
166
+ self._notes_view = Gtk.TextView()
167
+ self._notes_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
168
+ self._notes_view.get_buffer().set_text(self._config.notes or "")
169
+ self._notes_view.set_margin_top(6)
170
+ self._notes_view.set_margin_bottom(6)
171
+ self._notes_view.set_margin_start(6)
172
+ self._notes_view.set_margin_end(6)
173
+ notes_row.add_row(self._notes_view)
174
+ self._notes_row = notes_row
175
+ group.add(notes_row)
176
+
177
+ return group
178
+
179
+ def _build_model_group(self) -> Adw.PreferencesGroup:
180
+ group = Adw.PreferencesGroup()
181
+ group.set_title("Model")
182
+ group.set_description("LLM settings used when analyzing this project")
183
+
184
+ self._model_row = Adw.EntryRow()
185
+ self._model_row.set_title("Model name")
186
+ self._model_row.set_text(self._config.model.name)
187
+ group.add(self._model_row)
188
+
189
+ self._url_row = Adw.EntryRow()
190
+ self._url_row.set_title("Ollama URL")
191
+ self._url_row.set_text(self._config.model.ollama_url)
192
+ group.add(self._url_row)
193
+
194
+ self._num_ctx_row = Adw.SpinRow.new_with_range(512, 131072, 512)
195
+ self._num_ctx_row.set_title("Context length")
196
+ self._num_ctx_row.set_subtitle("Tokens per request (larger = more VRAM)")
197
+ self._num_ctx_row.set_value(self._config.model.num_ctx)
198
+ group.add(self._num_ctx_row)
199
+
200
+ self._temp_row = Adw.SpinRow.new_with_range(0.0, 2.0, 0.1)
201
+ self._temp_row.set_title("Temperature")
202
+ self._temp_row.set_subtitle("0 = deterministic, 2 = creative")
203
+ self._temp_row.set_digits(1)
204
+ self._temp_row.set_value(self._config.model.temperature)
205
+ group.add(self._temp_row)
206
+
207
+ self._timeout_row = Adw.SpinRow.new_with_range(30, 3600, 30)
208
+ self._timeout_row.set_title("Timeout")
209
+ self._timeout_row.set_subtitle("Seconds per LLM request")
210
+ self._timeout_row.set_value(self._config.model.timeout)
211
+ group.add(self._timeout_row)
212
+
213
+ return group
214
+
215
+ def _build_analysis_group(self) -> Adw.PreferencesGroup:
216
+ group = Adw.PreferencesGroup()
217
+ group.set_title("Analysis")
218
+ group.set_description("What gets analyzed and how")
219
+
220
+ self._cache_row = Adw.SwitchRow()
221
+ self._cache_row.set_title("Use cache")
222
+ self._cache_row.set_subtitle(
223
+ "Skip unchanged files when re-analyzing this project"
224
+ )
225
+ self._cache_row.set_active(self._config.analysis.use_cache)
226
+ group.add(self._cache_row)
227
+
228
+ self._max_chars_row = Adw.SpinRow.new_with_range(1000, 200000, 1000)
229
+ self._max_chars_row.set_title("File size limit")
230
+ self._max_chars_row.set_subtitle(
231
+ "Maximum characters per file (larger files are truncated)"
232
+ )
233
+ self._max_chars_row.set_value(self._config.analysis.max_file_chars)
234
+ group.add(self._max_chars_row)
235
+
236
+ exclusions_row = Adw.ExpanderRow()
237
+ exclusions_row.set_title("Excluded files and patterns")
238
+ exclusions_row.set_subtitle(
239
+ f"{len(self._config.analysis.exclude)} pattern(s)"
240
+ )
241
+
242
+ self._exclusion_list = ExclusionList()
243
+ self._exclusion_list.set_patterns(self._config.analysis.exclude)
244
+ self._exclusion_list.set_on_changed(self._on_exclusions_changed)
245
+ self._exclusion_list.set_margin_top(6)
246
+ self._exclusion_list.set_margin_bottom(6)
247
+ self._exclusion_list.set_margin_start(6)
248
+ self._exclusion_list.set_margin_end(6)
249
+
250
+ exclusions_row.add_row(self._exclusion_list)
251
+ self._exclusions_row = exclusions_row
252
+ group.add(exclusions_row)
253
+
254
+ return group
255
+
256
+ # ------------------------------------------------------------------
257
+ # Signals
258
+ # ------------------------------------------------------------------
259
+
260
+ def _on_name_changed(self, row: Adw.EntryRow) -> None:
261
+ self._title.set_title(row.get_text() or "Untitled")
262
+
263
+ def _on_exclusions_changed(self, patterns: list[str]) -> None:
264
+ if hasattr(self, "_exclusions_row"):
265
+ self._exclusions_row.set_subtitle(f"{len(patterns)} pattern(s)")
266
+
267
+ # ------------------------------------------------------------------
268
+ # Path change (folder picker)
269
+ # ------------------------------------------------------------------
270
+
271
+ def _on_change_path_clicked(self, _widget) -> None:
272
+ """Open a folder picker to change the project path."""
273
+ if self._config is None:
274
+ return
275
+
276
+ dialog = Gtk.FileDialog.new()
277
+ dialog.set_title("Select project folder")
278
+ try:
279
+ dialog.set_initial_folder(
280
+ Gio.File.new_for_path(str(self._config.path))
281
+ )
282
+ except Exception:
283
+ # Path might not exist anymore; skip initial folder
284
+ pass
285
+
286
+ dialog.select_folder(
287
+ self.get_root(), None, self._on_folder_selected
288
+ )
289
+
290
+ def _on_folder_selected(self, dialog, result) -> None:
291
+ """Callback when the user picks a new folder."""
292
+ try:
293
+ folder = dialog.select_folder_finish(result)
294
+ except Exception as e:
295
+ logger.debug(f"Folder selection cancelled: {e}")
296
+ return
297
+
298
+ if folder is None:
299
+ return
300
+
301
+ new_path = Path(folder.get_path())
302
+
303
+ if new_path == self._config.path:
304
+ return # no change
305
+
306
+ if not new_path.is_dir():
307
+ self._show_error(
308
+ "Invalid folder",
309
+ f"{new_path} is not a directory.",
310
+ )
311
+ return
312
+
313
+ # Update in-memory config and the row subtitle
314
+ self._config.path = new_path
315
+ self._path_row.set_subtitle(str(new_path))
316
+ logger.info(f"Project path changed to: {new_path}")
317
+
318
+ def _show_error(self, title: str, message: str) -> None:
319
+ """Show a simple error dialog."""
320
+ dialog = Adw.MessageDialog.new(
321
+ self.get_root(),
322
+ title,
323
+ message,
324
+ )
325
+ dialog.add_response("ok", "OK")
326
+ dialog.present()
327
+
328
+ # ------------------------------------------------------------------
329
+ # Save / Cancel / Analyze / Delete
330
+ # ------------------------------------------------------------------
331
+
332
+ def _collect_config_from_widgets(self) -> None:
333
+ """Read all widget values back into self._config."""
334
+ self._config.name = self._name_row.get_text().strip() or "Untitled"
335
+
336
+ buffer = self._notes_view.get_buffer()
337
+ start = buffer.get_start_iter()
338
+ end = buffer.get_end_iter()
339
+ self._config.notes = buffer.get_text(start, end, False).strip()
340
+
341
+ # Model settings
342
+ self._config.model.name = self._model_row.get_text().strip()
343
+ self._config.model.ollama_url = self._url_row.get_text().strip()
344
+ self._config.model.num_ctx = int(self._num_ctx_row.get_value())
345
+ self._config.model.temperature = float(self._temp_row.get_value())
346
+ self._config.model.timeout = int(self._timeout_row.get_value())
347
+
348
+ # Analysis settings
349
+ self._config.analysis.use_cache = self._cache_row.get_active()
350
+ self._config.analysis.max_file_chars = int(self._max_chars_row.get_value())
351
+ self._config.analysis.exclude = self._exclusion_list.get_patterns()
352
+
353
+ # Note: path is NOT read from a widget — it's stored in _config
354
+ # and updated by the folder picker.
355
+
356
+ def _try_save(self) -> bool:
357
+ """Collect, validate and persist. Returns True on success."""
358
+ if self._config is None:
359
+ return False
360
+
361
+ self._collect_config_from_widgets()
362
+
363
+ try:
364
+ self._config.validate()
365
+ except ValueError as e:
366
+ self._show_error("Invalid settings", str(e))
367
+ return False
368
+
369
+ try:
370
+ save_project(self._config)
371
+ logger.info(f"Saved settings for '{self._config.name}'")
372
+ except Exception as e:
373
+ logger.error(f"Failed to save settings: {e}")
374
+ self._show_error("Could not save", str(e))
375
+ return False
376
+
377
+ return True
378
+
379
+ def _on_save_clicked(self, _button) -> None:
380
+ if not self._try_save():
381
+ return
382
+ if self._on_saved:
383
+ self._on_saved(self._config)
384
+
385
+ def _on_analyze_clicked(self, _button) -> None:
386
+ """Save current settings, then hand off to the analyzer view."""
387
+ if not self._try_save():
388
+ return
389
+ if self._on_saved:
390
+ self._on_saved(self._config)
391
+ if self._on_analyze:
392
+ self._on_analyze(self._config)
393
+
394
+ def _on_cancel_clicked(self, _button) -> None:
395
+ if self._on_cancelled:
396
+ self._on_cancelled()
397
+
398
+ def _on_delete_clicked(self, _button) -> None:
399
+ if self._config is None:
400
+ return
401
+
402
+ dialog = Adw.MessageDialog.new(
403
+ self.get_root(),
404
+ "Delete project?",
405
+ f"Are you sure you want to delete '{self._config.name}'?\n\n"
406
+ f"This removes the saved configuration, not the project files.",
407
+ )
408
+ dialog.add_response("cancel", "Cancel")
409
+ dialog.add_response("delete", "Delete")
410
+ dialog.set_response_appearance(
411
+ "delete", Adw.ResponseAppearance.DESTRUCTIVE
412
+ )
413
+ dialog.set_default_response("cancel")
414
+ dialog.set_close_response("cancel")
415
+ dialog.connect("response", self._on_delete_response)
416
+ dialog.present()
417
+
418
+ def _on_delete_response(self, _dialog, response_id: str) -> None:
419
+ if response_id != "delete" or self._config is None:
420
+ return
421
+
422
+ if delete_project(self._config.name):
423
+ logger.info(f"Deleted project: {self._config.name}")
424
+ if self._on_deleted:
425
+ self._on_deleted(self._config)
426
+ else:
427
+ self._show_error(
428
+ "Could not delete",
429
+ f"Project '{self._config.name}' was not found on disk.",
430
+ )
@@ -0,0 +1 @@
1
+ """Reusable GTK widgets for Siftcode."""
@@ -0,0 +1,169 @@
1
+ """
2
+ ExclusionList widget — a tag-based editor for excluded file patterns.
3
+
4
+ Displays the current exclusions as removable tags, with an entry
5
+ field at the bottom for adding new ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gi
11
+
12
+ gi.require_version("Gtk", "4.0")
13
+ gi.require_version("Adw", "1")
14
+
15
+ from gi.repository import Adw, Gtk # noqa: E402
16
+
17
+ from siftcode.core.config import DEFAULT_EXCLUSIONS # noqa: E402
18
+
19
+
20
+ class ExclusionList(Gtk.Box):
21
+ """
22
+ A vertical box with:
23
+ - a FlowBox of removable tags
24
+ - an entry field for adding new patterns
25
+ - a "Reset to defaults" button
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
30
+
31
+ self._patterns: list[str] = []
32
+ self._on_changed = None
33
+
34
+ # Tag container (rebuilt on every change)
35
+ self._flow = Gtk.FlowBox()
36
+ self._flow.set_selection_mode(Gtk.SelectionMode.NONE)
37
+ self._flow.set_max_children_per_line(20)
38
+ self._flow.set_min_children_per_line(1)
39
+ self._flow.set_row_spacing(4)
40
+ self._flow.set_column_spacing(4)
41
+ self._flow.set_margin_top(6)
42
+ self._flow.set_margin_bottom(6)
43
+ self._flow.set_margin_start(6)
44
+ self._flow.set_margin_end(6)
45
+ self.append(self._flow)
46
+
47
+ # Empty-state label (shown when no patterns)
48
+ self._empty_label = Gtk.Label(
49
+ label="No exclusions — all files will be analyzed",
50
+ xalign=0,
51
+ )
52
+ self._empty_label.add_css_class("dim-label")
53
+ self._empty_label.add_css_class("caption")
54
+ self._empty_label.set_margin_start(6)
55
+ self.append(self._empty_label)
56
+
57
+ # Entry for adding new patterns
58
+ entry_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
59
+ entry_box.set_margin_start(6)
60
+ entry_box.set_margin_end(6)
61
+ entry_box.set_margin_bottom(6)
62
+
63
+ self._entry = Gtk.Entry()
64
+ self._entry.set_placeholder_text("Add pattern (e.g. tests, .log)")
65
+ self._entry.set_hexpand(True)
66
+ self._entry.connect("activate", self._on_entry_activated)
67
+ entry_box.append(self._entry)
68
+
69
+ add_button = Gtk.Button(label="Add")
70
+ add_button.connect("clicked", lambda *_: self._on_entry_activated(self._entry))
71
+ entry_box.append(add_button)
72
+
73
+ self.append(entry_box)
74
+
75
+ # Reset button
76
+ reset_button = Gtk.Button(label="Reset to defaults")
77
+ reset_button.add_css_class("flat")
78
+ reset_button.set_halign(Gtk.Align.START)
79
+ reset_button.set_margin_start(6)
80
+ reset_button.set_margin_bottom(6)
81
+ reset_button.connect("clicked", self._on_reset_clicked)
82
+ self.append(reset_button)
83
+
84
+ # ------------------------------------------------------------------
85
+ # Public API
86
+ # ------------------------------------------------------------------
87
+
88
+ def set_patterns(self, patterns: list[str]) -> None:
89
+ """Replace the current list of patterns."""
90
+ self._patterns = list(patterns)
91
+ self._rebuild_tags()
92
+ self._notify_changed()
93
+
94
+ def get_patterns(self) -> list[str]:
95
+ """Return the current patterns as a new list."""
96
+ return list(self._patterns)
97
+
98
+ def set_on_changed(self, callback) -> None:
99
+ """Set a callback invoked when patterns change."""
100
+ self._on_changed = callback
101
+
102
+ # ------------------------------------------------------------------
103
+ # Internal
104
+ # ------------------------------------------------------------------
105
+
106
+ def _rebuild_tags(self) -> None:
107
+ """Remove all tags and rebuild them from self._patterns."""
108
+ # Clear existing tags
109
+ while (child := self._flow.get_first_child()) is not None:
110
+ self._flow.remove(child)
111
+
112
+ # Toggle empty label
113
+ self._empty_label.set_visible(not self._patterns)
114
+
115
+ # Add a tag for each pattern
116
+ for pattern in self._patterns:
117
+ self._flow.append(self._make_tag(pattern))
118
+
119
+ def _make_tag(self, pattern: str) -> Gtk.Widget:
120
+ """Build a single removable tag."""
121
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
122
+ box.add_css_class("pill")
123
+ box.set_margin_top(2)
124
+ box.set_margin_bottom(2)
125
+
126
+ label = Gtk.Label(label=pattern)
127
+ label.set_margin_start(8)
128
+ label.set_margin_end(2)
129
+ box.append(label)
130
+
131
+ remove_button = Gtk.Button.new_from_icon_name("window-close-symbolic")
132
+ remove_button.add_css_class("flat")
133
+ remove_button.add_css_class("circular")
134
+ remove_button.set_tooltip_text(f"Remove '{pattern}'")
135
+ remove_button.connect("clicked", lambda *_: self._remove_pattern(pattern))
136
+ box.append(remove_button)
137
+
138
+ return box
139
+
140
+ def _add_pattern(self, pattern: str) -> None:
141
+ pattern = pattern.strip()
142
+ if not pattern or pattern in self._patterns:
143
+ return
144
+ self._patterns.append(pattern)
145
+ self._rebuild_tags()
146
+ self._notify_changed()
147
+
148
+ def _remove_pattern(self, pattern: str) -> None:
149
+ if pattern in self._patterns:
150
+ self._patterns.remove(pattern)
151
+ self._rebuild_tags()
152
+ self._notify_changed()
153
+
154
+ def _notify_changed(self) -> None:
155
+ if self._on_changed:
156
+ self._on_changed(self.get_patterns())
157
+
158
+ # ------------------------------------------------------------------
159
+ # Signals
160
+ # ------------------------------------------------------------------
161
+
162
+ def _on_entry_activated(self, entry: Gtk.Entry) -> None:
163
+ text = entry.get_text()
164
+ if text.strip():
165
+ self._add_pattern(text)
166
+ entry.set_text("")
167
+
168
+ def _on_reset_clicked(self, _button) -> None:
169
+ self.set_patterns(list(DEFAULT_EXCLUSIONS))