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,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))
|