docker-orb 1.0.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,692 @@
1
+ """
2
+ Three-tab wizard:
3
+ Tab 1 · Targets — project, services, log mode
4
+ Tab 2 · Strings — filters, highlights, monitors
5
+ Tab 3 · Options — health, display, save config, launch
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
12
+ from textual.content import Content
13
+ from textual.screen import Screen
14
+ from textual.widgets import (
15
+ Button,
16
+ Checkbox,
17
+ Input,
18
+ Label,
19
+ RadioButton,
20
+ RadioSet,
21
+ Rule,
22
+ Select,
23
+ Static,
24
+ TabbedContent,
25
+ TabPane,
26
+ )
27
+
28
+ from ..models import HealthConfig, LogMode, SessionConfig
29
+
30
+
31
+ def _section(title: str) -> Static:
32
+ return Static(title, classes="section-title")
33
+
34
+
35
+ class SinglePageWizard(Screen[SessionConfig | None]):
36
+ """Three-tab setup wizard. Class name kept for compatibility with wizard/app.py."""
37
+
38
+ BINDINGS = [
39
+ Binding("ctrl+q", "cancel", "Cancel", show=True),
40
+ ]
41
+
42
+ def __init__(self, initial_project: str = "default") -> None:
43
+ super().__init__()
44
+ self._initial_project = initial_project
45
+ self._services: list[str] = []
46
+ self._container_counts: dict[str, int] = {}
47
+ # Which services should start checked on the *next* checkbox mount.
48
+ # None = default all-checked. Set by _apply_saved_config so the mount
49
+ # (which may happen asynchronously, racing the caller) reflects the
50
+ # saved config instead of blindly checking everything. Consumed once.
51
+ self._pending_services: set[str] | None = None
52
+ # Project most recently passed to _refresh_services(). Lets
53
+ # on_select_changed skip a redundant refresh when Select.Changed for
54
+ # proj-select is still queued after _apply_saved_config already
55
+ # refreshed for that same project itself.
56
+ self._last_refreshed_project: str | None = None
57
+ self._saved_filters: list[str] = []
58
+ self._saved_highlights: list[str] = []
59
+ self._saved_monitors: list[str] = []
60
+ self._saved_configs: list[str] = []
61
+ self._known_projects: list[str] = []
62
+ self._initializing_ns = False
63
+
64
+ # ── Layout ────────────────────────────────────────────────────────────────
65
+
66
+ def compose(self) -> ComposeResult:
67
+ yield Static("docker-orb · setup", id="wizard-title")
68
+
69
+ with TabbedContent(id="wizard-tabs"):
70
+
71
+ # ── Tab 1: Targets ────────────────────────────────────────────────
72
+ with TabPane("Targets", id="tab-targets"):
73
+ with ScrollableContainer(id="targets-scroll"):
74
+
75
+ yield _section("Load saved config")
76
+ yield Select(
77
+ options=[],
78
+ prompt="Select a saved config to pre-fill …",
79
+ id="cfg-select",
80
+ allow_blank=True,
81
+ )
82
+
83
+ yield Rule()
84
+ yield _section("Project")
85
+ yield Static(
86
+ f"compose context: {self._initial_project}",
87
+ id="proj-header",
88
+ classes="field-hint",
89
+ )
90
+ yield Select(
91
+ options=[(self._initial_project, self._initial_project)],
92
+ id="proj-select",
93
+ allow_blank=False,
94
+ value=self._initial_project,
95
+ )
96
+ yield Input(placeholder="Or type a project manually …", id="proj-input")
97
+
98
+ yield Rule()
99
+ yield _section("Services to watch")
100
+ with Horizontal(classes="row"):
101
+ yield Button("Select all", id="dep-all", classes="btn-small")
102
+ yield Button("Clear all", id="dep-clear", classes="btn-small")
103
+ yield ScrollableContainer(
104
+ Vertical(id="dep-checks"),
105
+ id="dep-scroll",
106
+ )
107
+ yield Static(
108
+ "No services found — select a project and click Refresh",
109
+ id="dep-empty",
110
+ classes="field-warn",
111
+ )
112
+
113
+ yield Rule()
114
+ yield _section("Log mode")
115
+ with RadioSet(id="mode-radio"):
116
+ yield RadioButton("Stream — live tail", value=True, id="mode-stream")
117
+ yield RadioButton("Dump — fetch existing logs then stop", id="mode-dump")
118
+ with Vertical(id="dump-opts"):
119
+ yield Label("Last N lines:", classes="field-label")
120
+ yield Input(placeholder="e.g. 500", id="dump-tail")
121
+ yield Label("— or since:", classes="field-label")
122
+ yield Input(placeholder="e.g. 1h, 30m", id="dump-since")
123
+ with Vertical(id="stream-opts"):
124
+ yield Label("Since:", classes="field-label")
125
+ yield Input(placeholder="e.g. 1h, 30m — leave blank for new lines only", id="stream-since")
126
+ yield Static(
127
+ "[dim]Leave blank (or enter 0) to only collect new lines from the "
128
+ "moment the stream starts. Set a duration to also pull in recent "
129
+ "history when it starts.[/dim]\n"
130
+ "[dim]Note: with a duration set, nothing is displayed until every "
131
+ "container's backlog is fetched and sorted into order — a long duration "
132
+ "or high-volume containers can mean a delay of several seconds before the "
133
+ "stream first appears.[/dim]",
134
+ classes="field-hint",
135
+ markup=True,
136
+ )
137
+
138
+ with Horizontal(classes="row nav-row"):
139
+ yield Button("Cancel", variant="error", id="cancel-1", classes="btn-cancel")
140
+ yield Button("Next →", variant="primary", id="next-1", classes="btn-next")
141
+
142
+ # ── Tab 2: Patterns ───────────────────────────────────────────────
143
+ with TabPane("Patterns", id="tab-strings"):
144
+ with ScrollableContainer(id="strings-scroll"):
145
+
146
+ with Horizontal(classes="row", id="patterns-edit-row"):
147
+ yield Static(
148
+ "Saved patterns are stored in ~/.config/docker-orb/strings.yaml",
149
+ classes="field-hint",
150
+ id="patterns-file-hint",
151
+ )
152
+ yield Button("Edit in text editor", id="edit-patterns-file", classes="btn-small", variant="primary")
153
+ yield Button("↺ Reload", id="reload-patterns", classes="btn-small")
154
+
155
+ yield Rule()
156
+ yield _section("Filters — hide matching lines")
157
+ yield Label("Saved (check to activate):", classes="field-label saved-label")
158
+ yield ScrollableContainer(Vertical(id="filter-checks"), id="filter-scroll")
159
+ yield Label("Add new:", classes="field-label new-label")
160
+ yield Input(placeholder="e.g. ERROR, /5[0-9]{2}/ (comma-separated)", id="filter-input")
161
+ with Horizontal(classes="row option-row"):
162
+ yield Checkbox("Save new to list", id="filter-save", value=True)
163
+ yield Checkbox("Ignore case", id="filter-icase", value=False)
164
+
165
+ yield Rule()
166
+ yield _section("Highlights — emphasise matching lines")
167
+ yield Label("Saved (check to activate):", classes="field-label saved-label")
168
+ yield ScrollableContainer(Vertical(id="hl-checks"), id="hl-scroll")
169
+ yield Label("Add new:", classes="field-label new-label")
170
+ yield Input(placeholder="e.g. WARN, brute force (comma-separated)", id="hl-input")
171
+ with Horizontal(classes="row option-row"):
172
+ yield Checkbox("Save new to list", id="hl-save", value=True)
173
+ yield Checkbox("Ignore case", id="hl-icase", value=False)
174
+
175
+ with Vertical(id="monitor-section"):
176
+ yield Rule()
177
+ yield _section("Monitors — copy matches to monitor panel")
178
+ yield Label("Saved (check to activate):", classes="field-label saved-label")
179
+ yield ScrollableContainer(Vertical(id="mon-checks"), id="mon-scroll")
180
+ yield Label("Add new:", classes="field-label new-label")
181
+ yield Input(placeholder="e.g. job failed, OOM (comma-separated)", id="mon-input")
182
+ with Horizontal(classes="row option-row"):
183
+ yield Checkbox("Save new to list", id="mon-save", value=True)
184
+ yield Checkbox("Ignore case", id="mon-icase", value=False)
185
+
186
+ with Horizontal(classes="row nav-row"):
187
+ yield Button("Cancel", variant="error", id="cancel-2", classes="btn-cancel")
188
+ yield Button("← Back", variant="default", id="back-2", classes="btn-back")
189
+ yield Button("Next →", variant="primary", id="next-2", classes="btn-next")
190
+
191
+ # ── Tab 3: Options ────────────────────────────────────────────────
192
+ with TabPane("Options", id="tab-options"):
193
+ with ScrollableContainer(id="options-scroll"):
194
+
195
+ with Vertical(id="health-section"):
196
+ yield _section("Container health monitoring")
197
+ yield Checkbox("Enable health panel", id="health-enable", value=False)
198
+ with Vertical(id="health-opts"):
199
+ yield Label("Check interval (minutes, min 1):", classes="field-label")
200
+ yield Input("5", id="health-interval")
201
+
202
+ yield Rule()
203
+ yield _section("Display")
204
+ yield Checkbox(
205
+ "Color full line (default: color container name only)",
206
+ id="color-full-line",
207
+ value=False,
208
+ )
209
+ yield Checkbox(
210
+ "Line wrap (default: on)",
211
+ id="line-wrap",
212
+ value=True,
213
+ )
214
+ yield Checkbox(
215
+ "Format JSON logs (extract level/message/time; raw otherwise)",
216
+ id="json-format",
217
+ value=False,
218
+ )
219
+ yield Checkbox(
220
+ "Collapse repeated lines (consecutive identical lines from the "
221
+ "same container become 'last line repeated N times')",
222
+ id="collapse-repeats",
223
+ value=False,
224
+ )
225
+
226
+ yield Rule()
227
+ yield _section("Save config for reuse")
228
+ yield Label("Name — leave blank to skip:", classes="field-label")
229
+ yield Input(placeholder="e.g. prod-debug", id="save-name")
230
+
231
+ with Horizontal(classes="row nav-row"):
232
+ yield Button("Cancel", variant="error", id="cancel", classes="btn-cancel")
233
+ yield Button("← Back", variant="default", id="back-3", classes="btn-back")
234
+ yield Button("Launch →", variant="primary", id="launch", classes="btn-next")
235
+
236
+ yield Static("Ctrl+Q to cancel at any time", id="wizard-footer")
237
+
238
+ # ── Mount ─────────────────────────────────────────────────────────────────
239
+
240
+ def on_mount(self) -> None:
241
+ self.query_one("#dump-opts").display = False
242
+ self.query_one("#stream-opts").display = True
243
+ self.query_one("#health-opts").display = False
244
+ self._load_saved_strings()
245
+ self._populate_projects()
246
+ # Explicitly focus the first form field so the wizard opens ready to use
247
+ # rather than leaving focus on the TabbedContent tab strip.
248
+ self.call_after_refresh(lambda: self.set_focus(self.query_one("#cfg-select", Select)))
249
+
250
+ # ── Project helpers ─────────────────────────────────────────────────────
251
+
252
+ def _populate_projects(self) -> None:
253
+ from .. import compose as k
254
+ try:
255
+ projects = k.get_projects()
256
+ except Exception:
257
+ projects = [self._initial_project]
258
+
259
+ self._known_projects = projects
260
+ self._initializing_ns = True
261
+ sel = self.query_one("#proj-select", Select)
262
+ options = [(proj, proj) for proj in projects]
263
+ sel.set_options(options)
264
+
265
+ if self._initial_project in projects:
266
+ sel.value = self._initial_project
267
+ elif projects:
268
+ sel.value = projects[0]
269
+
270
+ self.query_one("#proj-header", Static).update(
271
+ f"compose context: {self._initial_project}"
272
+ )
273
+
274
+ def _after_init() -> None:
275
+ self._initializing_ns = False
276
+ self._refresh_services()
277
+ self._refresh_saved_configs()
278
+
279
+ self.call_after_refresh(_after_init)
280
+
281
+ def _active_project(self) -> str:
282
+ manual = self.query_one("#proj-input", Input).value.strip()
283
+ if manual:
284
+ return manual
285
+ val = self.query_one("#proj-select", Select).value
286
+ if val and val is not Select.BLANK:
287
+ return str(val)
288
+ return self._initial_project
289
+
290
+ def _refresh_services(self) -> None:
291
+ from .. import compose as k
292
+ proj = self._active_project()
293
+ self._last_refreshed_project = proj
294
+ self._services = []
295
+ self._container_counts = {}
296
+ self.query_one("#dep-checks", Vertical).remove_children()
297
+
298
+ # Consume immediately — this is the one mount this pending selection
299
+ # was set for. Any later, unrelated refresh should default to all-checked.
300
+ pending = self._pending_services
301
+ self._pending_services = None
302
+
303
+ try:
304
+ deps = k.get_services(proj)
305
+ self._services = [d.name for d in deps]
306
+ self._container_counts = {d.name: d.container_count for d in deps}
307
+ except Exception as exc:
308
+ self.query_one("#dep-empty", Static).update(f"Error: {exc}")
309
+ self.query_one("#dep-empty").display = True
310
+ return
311
+
312
+ if not self._services:
313
+ self.query_one("#dep-empty", Static).update(
314
+ f"No services found in '{proj}'"
315
+ )
316
+ self.query_one("#dep-empty").display = True
317
+ return
318
+
319
+ self.query_one("#dep-empty").display = False
320
+
321
+ names = list(self._services)
322
+ counts = dict(self._container_counts)
323
+ self.run_worker(
324
+ self._mount_dep_checks(names, counts, pending),
325
+ exclusive=True,
326
+ name="dep-mount",
327
+ )
328
+
329
+ async def _mount_dep_checks(
330
+ self, names: list[str], counts: dict[str, int], pending: set[str] | None
331
+ ) -> None:
332
+ dep_container = self.query_one("#dep-checks", Vertical)
333
+ await dep_container.remove_children()
334
+ for name in names:
335
+ count = counts.get(name, 0)
336
+ checked = True if pending is None else (name in pending)
337
+ await dep_container.mount(
338
+ Checkbox(
339
+ f"{name} ({count} container{'s' if count != 1 else ''})",
340
+ value=checked,
341
+ id=f"dep-{name}",
342
+ )
343
+ )
344
+
345
+
346
+ # ── String helpers ────────────────────────────────────────────────────────
347
+
348
+ def _load_saved_strings(self) -> None:
349
+ from ..config import load_saved_strings
350
+ saved = load_saved_strings()
351
+ self._saved_filters = saved.filters
352
+ self._saved_highlights = saved.highlights
353
+ self._saved_monitors = saved.monitors
354
+ self._populate_string_checks("filter-checks", self._saved_filters, [])
355
+ self._populate_string_checks("hl-checks", self._saved_highlights, [])
356
+ self._populate_string_checks("mon-checks", self._saved_monitors, [])
357
+
358
+ def _populate_string_checks(
359
+ self,
360
+ container_id: str,
361
+ strings: list[str],
362
+ active: list[str],
363
+ ) -> None:
364
+ container = self.query_one(f"#{container_id}", Vertical)
365
+ container.remove_children()
366
+
367
+ def _do_mount() -> None:
368
+ for s in strings:
369
+ # Content(s) (not the raw str) — Checkbox labels parse Textual
370
+ # markup by default, and a pattern like "[debug]" is valid
371
+ # (empty) markup syntax that would otherwise render blank.
372
+ # No ID needed — these are never queried individually.
373
+ container.mount(Checkbox(Content(s), value=(s in active)))
374
+
375
+ self.call_after_refresh(_do_mount)
376
+
377
+ # ── Saved config helpers ──────────────────────────────────────────────────
378
+
379
+ def _refresh_saved_configs(self) -> None:
380
+ from ..config import list_all_saved_configs
381
+ all_configs = list_all_saved_configs()
382
+ sel = self.query_one("#cfg-select", Select)
383
+ options = [(f"{name} [{proj}]", f"{proj}::{name}") for proj, name in all_configs]
384
+ sel.set_options(options if options else [("(no saved configs)", "__none__")])
385
+
386
+ def _apply_saved_config(self, proj: str, name: str) -> None:
387
+ from ..config import load_session_config
388
+ cfg = load_session_config(proj, name)
389
+ if cfg is None:
390
+ return
391
+
392
+ # Set before triggering any service refresh, so whichever refresh
393
+ # actually mounts the checkboxes (sync or async) picks up the saved
394
+ # selection instead of defaulting every service to checked.
395
+ self._pending_services = set(cfg.services)
396
+
397
+ # Restore project. Setting the dropdown may also (asynchronously)
398
+ # fire on_select_changed, which would call _refresh_services()
399
+ # itself — but that handler skips redundant refreshes via
400
+ # _last_refreshed_project, so our explicit call below always wins.
401
+ ns_sel = self.query_one("#proj-select", Select)
402
+ if proj in self._known_projects:
403
+ ns_sel.value = proj
404
+ self.query_one("#proj-input", Input).value = ""
405
+ else:
406
+ # Unknown project — use manual input instead of the dropdown
407
+ self.query_one("#proj-input", Input).value = proj
408
+ self._refresh_services()
409
+
410
+ self._populate_string_checks("filter-checks", self._saved_filters, cfg.filters)
411
+ self._populate_string_checks("hl-checks", self._saved_highlights, cfg.highlights)
412
+ self._populate_string_checks("mon-checks", self._saved_monitors, cfg.monitors)
413
+
414
+ buttons = list(self.query_one("#mode-radio", RadioSet).query(RadioButton))
415
+ target = 1 if cfg.mode == LogMode.DUMP else 0
416
+ if target < len(buttons):
417
+ buttons[target].value = True
418
+
419
+ if cfg.tail:
420
+ self.query_one("#dump-tail", Input).value = str(cfg.tail)
421
+ if cfg.since:
422
+ self.query_one("#dump-since", Input).value = cfg.since
423
+ self.query_one("#stream-since", Input).value = cfg.since
424
+
425
+ self.query_one("#health-enable", Checkbox).value = cfg.health.enabled
426
+ self.query_one("#health-interval", Input).value = str(cfg.health.interval_minutes)
427
+ self.query_one("#color-full-line", Checkbox).value = cfg.color_full_line
428
+ self.query_one("#line-wrap", Checkbox).value = cfg.line_wrap
429
+ self.query_one("#json-format", Checkbox).value = cfg.json_format
430
+ self.query_one("#collapse-repeats", Checkbox).value = cfg.collapse_repeats
431
+ self.query_one("#filter-icase", Checkbox).value = cfg.filters_ignore_case
432
+ self.query_one("#hl-icase", Checkbox).value = cfg.highlights_ignore_case
433
+ self.query_one("#mon-icase", Checkbox).value = cfg.monitors_ignore_case
434
+ if cfg.name:
435
+ self.query_one("#save-name", Input).value = cfg.name
436
+
437
+ def _reset_to_defaults(self) -> None:
438
+ """
439
+ Undo _apply_saved_config: restore the project/service pickers and
440
+ every option to their initial defaults. Called when the "Load saved
441
+ config" dropdown is switched back to blank, so a previously-applied
442
+ config (which may have pointed at a project that no longer exists)
443
+ doesn't just sit there unrefreshed.
444
+ """
445
+ self._pending_services = None
446
+
447
+ self.query_one("#proj-input", Input).value = ""
448
+ proj_sel = self.query_one("#proj-select", Select)
449
+ if self._initial_project in self._known_projects:
450
+ proj_sel.value = self._initial_project
451
+ elif self._known_projects:
452
+ proj_sel.value = self._known_projects[0]
453
+ self._refresh_services()
454
+
455
+ self._populate_string_checks("filter-checks", self._saved_filters, [])
456
+ self._populate_string_checks("hl-checks", self._saved_highlights, [])
457
+ self._populate_string_checks("mon-checks", self._saved_monitors, [])
458
+ self.query_one("#filter-input", Input).value = ""
459
+ self.query_one("#hl-input", Input).value = ""
460
+ self.query_one("#mon-input", Input).value = ""
461
+ self.query_one("#filter-icase", Checkbox).value = False
462
+ self.query_one("#hl-icase", Checkbox).value = False
463
+ self.query_one("#mon-icase", Checkbox).value = False
464
+
465
+ buttons = list(self.query_one("#mode-radio", RadioSet).query(RadioButton))
466
+ if buttons:
467
+ buttons[0].value = True
468
+
469
+ self.query_one("#dump-tail", Input).value = ""
470
+ self.query_one("#dump-since", Input).value = ""
471
+ self.query_one("#stream-since", Input).value = ""
472
+
473
+ self.query_one("#health-enable", Checkbox).value = False
474
+ self.query_one("#health-interval", Input).value = "5"
475
+ self.query_one("#color-full-line", Checkbox).value = False
476
+ self.query_one("#line-wrap", Checkbox).value = True
477
+ self.query_one("#json-format", Checkbox).value = False
478
+ self.query_one("#collapse-repeats", Checkbox).value = False
479
+ self.query_one("#save-name", Input).value = ""
480
+
481
+ # ── Events ────────────────────────────────────────────────────────────────
482
+
483
+ def _switch_tab(self, tab_id: str) -> None:
484
+ from textual.widgets import Tab
485
+ from textual.widgets._tabbed_content import ContentTab, ContentTabs
486
+ content_tabs = self.query_one("#wizard-tabs", TabbedContent).query_one(ContentTabs)
487
+ tab = content_tabs.query_one(f"#{ContentTab.add_prefix(tab_id)}", Tab)
488
+ tab.post_message(Tab.Clicked(tab))
489
+
490
+ def _open_patterns_file(self) -> None:
491
+ import os
492
+ import subprocess
493
+ from pathlib import Path
494
+ path = Path.home() / ".config" / "docker-orb" / "strings.yaml"
495
+ path.parent.mkdir(parents=True, exist_ok=True)
496
+ if not path.exists():
497
+ path.write_text("filters: []\nhighlights: []\nmonitors: []\n")
498
+ editor = os.environ.get("EDITOR", "")
499
+ try:
500
+ if editor:
501
+ subprocess.Popen([editor, str(path)])
502
+ elif os.uname().sysname == "Darwin":
503
+ subprocess.Popen(["open", "-t", str(path)])
504
+ else:
505
+ subprocess.Popen(["xdg-open", str(path)])
506
+ self.notify(f"Opened {path}", severity="information")
507
+ except Exception as exc:
508
+ self.notify(f"Could not open editor: {exc}", severity="error")
509
+
510
+ def on_button_pressed(self, event: Button.Pressed) -> None:
511
+ match event.button.id:
512
+ case "edit-patterns-file":
513
+ self._open_patterns_file()
514
+ case "reload-patterns":
515
+ self._load_saved_strings()
516
+ self.notify("Saved patterns reloaded")
517
+ case "dep-all":
518
+ for cb in self.query("#dep-checks Checkbox"):
519
+ cb.value = True
520
+ case "dep-clear":
521
+ for cb in self.query("#dep-checks Checkbox"):
522
+ cb.value = False
523
+ case "next-1":
524
+ self._switch_tab("tab-strings")
525
+ case "next-2":
526
+ self._switch_tab("tab-options")
527
+ case "back-2":
528
+ self._switch_tab("tab-targets")
529
+ case "back-3":
530
+ self._switch_tab("tab-strings")
531
+ case "launch":
532
+ self._launch()
533
+ case "cancel" | "cancel-1" | "cancel-2":
534
+ self.action_cancel()
535
+
536
+ def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
537
+ is_dump = event.index == 1
538
+ self.query_one("#dump-opts").display = is_dump
539
+ self.query_one("#stream-opts").display = not is_dump
540
+ self.query_one("#monitor-section").display = not is_dump
541
+ self.query_one("#health-section").display = not is_dump
542
+
543
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
544
+ if event.checkbox.id == "health-enable":
545
+ self.query_one("#health-opts").display = event.value
546
+
547
+ def on_input_submitted(self, event: Input.Submitted) -> None:
548
+ if event.input.id == "proj-input" and event.value.strip():
549
+ self._refresh_services()
550
+ self._refresh_saved_configs()
551
+
552
+ def on_select_changed(self, event: Select.Changed) -> None:
553
+ if event.select.id == "proj-select":
554
+ if self._initializing_ns:
555
+ return
556
+ self.query_one("#proj-input", Input).value = ""
557
+ if self._active_project() == self._last_refreshed_project:
558
+ # _apply_saved_config already refreshed for this project
559
+ # itself; this is a queued Select.Changed catching up. Skip
560
+ # it so it doesn't remount checkboxes without the saved
561
+ # service selection applied.
562
+ return
563
+ self._refresh_services()
564
+ self._refresh_saved_configs()
565
+ elif event.select.id == "cfg-select":
566
+ value = event.value
567
+ if value and value != "__none__" and value is not Select.BLANK:
568
+ proj, _, name = str(value).partition("::")
569
+ self._apply_saved_config(proj, name)
570
+ else:
571
+ # Switched back to "no saved config" — undo whatever
572
+ # _apply_saved_config left behind (a manually-typed project
573
+ # that may no longer exist, a stale service selection, etc.)
574
+ # rather than leaving those fields stuck on the last config's
575
+ # values with nothing to repopulate them.
576
+ self._reset_to_defaults()
577
+
578
+ # ── Launch ────────────────────────────────────────────────────────────────
579
+
580
+ def _launch(self) -> None:
581
+ from ..config import add_to_saved_strings, parse_string_input
582
+
583
+ proj = self._active_project()
584
+ if not proj:
585
+ self.notify("Select or enter a project.", severity="warning")
586
+ return
587
+
588
+ services = [
589
+ cb.label.plain.split(" (")[0]
590
+ for cb in self.query("#dep-checks Checkbox")
591
+ if cb.value
592
+ ]
593
+ if not services:
594
+ self.notify("Select at least one service.", severity="warning")
595
+ return
596
+
597
+ rs = self.query_one("#mode-radio", RadioSet)
598
+ is_dump = rs.pressed_index == 1
599
+ mode = LogMode.DUMP if is_dump else LogMode.STREAM
600
+
601
+ tail: int | None = None
602
+ since: str | None = None
603
+ if is_dump:
604
+ tail_raw = self.query_one("#dump-tail", Input).value.strip()
605
+ since_raw = self.query_one("#dump-since", Input).value.strip()
606
+ if tail_raw:
607
+ try:
608
+ tail = int(tail_raw)
609
+ except ValueError:
610
+ self.notify("Tail must be a number.", severity="warning")
611
+ return
612
+ if since_raw:
613
+ since = since_raw
614
+ else:
615
+ since_raw = self.query_one("#stream-since", Input).value.strip()
616
+ if since_raw:
617
+ since = since_raw
618
+
619
+ def collect(
620
+ checks_id: str, saved: list[str], input_id: str, save_id: str
621
+ ) -> list[str]:
622
+ active = [
623
+ s for cb, s in zip(self.query(f"#{checks_id} Checkbox"), saved)
624
+ if cb.value
625
+ ]
626
+ raw = self.query_one(f"#{input_id}", Input).value.strip()
627
+ new = parse_string_input(raw) if raw else []
628
+ merged = active + [s for s in new if s not in active]
629
+ if new and self.query_one(f"#{save_id}", Checkbox).value:
630
+ truly_new = [s for s in new if s not in saved]
631
+ if truly_new:
632
+ cat = save_id.split("-")[0]
633
+ cat_map = {"filter": "filters", "hl": "highlights", "mon": "monitors"}
634
+ add_to_saved_strings(cat_map[cat], truly_new)
635
+ return merged
636
+
637
+ filters = collect("filter-checks", self._saved_filters, "filter-input", "filter-save")
638
+ highlights = collect("hl-checks", self._saved_highlights, "hl-input", "hl-save")
639
+ monitors: list[str] = []
640
+ if not is_dump:
641
+ monitors = collect("mon-checks", self._saved_monitors, "mon-input", "mon-save")
642
+
643
+ filters_ignore_case = self.query_one("#filter-icase", Checkbox).value
644
+ highlights_ignore_case = self.query_one("#hl-icase", Checkbox).value
645
+ monitors_ignore_case = self.query_one("#mon-icase", Checkbox).value if not is_dump else False
646
+
647
+ health = HealthConfig()
648
+ if not is_dump:
649
+ health.enabled = self.query_one("#health-enable", Checkbox).value
650
+ if health.enabled:
651
+ try:
652
+ health.interval_minutes = max(
653
+ 1, int(self.query_one("#health-interval", Input).value.strip() or "5")
654
+ )
655
+ except ValueError:
656
+ self.notify("Health interval must be a number.", severity="warning")
657
+ return
658
+
659
+ color_full_line = self.query_one("#color-full-line", Checkbox).value
660
+ line_wrap = self.query_one("#line-wrap", Checkbox).value
661
+ json_format = self.query_one("#json-format", Checkbox).value
662
+ collapse_repeats = self.query_one("#collapse-repeats", Checkbox).value
663
+ save_name = self.query_one("#save-name", Input).value.strip() or None
664
+
665
+ config = SessionConfig(
666
+ project=proj,
667
+ services=services,
668
+ mode=mode,
669
+ tail=tail,
670
+ since=since,
671
+ filters=filters,
672
+ highlights=highlights,
673
+ monitors=monitors,
674
+ filters_ignore_case=filters_ignore_case,
675
+ highlights_ignore_case=highlights_ignore_case,
676
+ monitors_ignore_case=monitors_ignore_case,
677
+ health=health,
678
+ color_full_line=color_full_line,
679
+ line_wrap=line_wrap,
680
+ json_format=json_format,
681
+ collapse_repeats=collapse_repeats,
682
+ name=save_name,
683
+ )
684
+
685
+ if save_name:
686
+ from ..config import save_session_config
687
+ save_session_config(config)
688
+
689
+ self.dismiss(config)
690
+
691
+ def action_cancel(self) -> None:
692
+ self.dismiss(None)