From 701c2023ea090dd494166b9c98f9840a0a93b2e3 Mon Sep 17 00:00:00 2001 From: hellices Date: Sun, 2 Aug 2026 00:12:00 +0900 Subject: [PATCH 1/3] feat: repo-centric chart browsing - Enter on a repo browses its charts (#137) The repo screen was management-only: after adding a repository, seeing what it serves meant backing out and typing the repoName/ prefix by hand. Enter on a repository row now dismisses the repo screen with that repo's name, and the chart picker underneath scopes its search to it (the repoName/ prefix convention, typed for you, searched immediately). Esc keeps its meaning - close without touching the keyword underneath. The keyword-first flow is unchanged; a pending add/update still owns the screen until it finishes. Fixes #137 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/helm-operators.md | 4 ++ src/korvid/ui/app.py | 34 ++++++++---- src/korvid/ui/widgets/helm_chart_search.py | 7 +++ src/korvid/ui/widgets/helm_repos.py | 26 +++++++-- tests/ui/test_helm_actions.py | 62 ++++++++++++++++++++++ 5 files changed, 119 insertions(+), 14 deletions(-) diff --git a/docs/helm-operators.md b/docs/helm-operators.md index 0b7b0696..7b951b1d 100644 --- a/docs/helm-operators.md +++ b/docs/helm-operators.md @@ -77,6 +77,10 @@ empty search lists everything, and a loading indicator shows while `helm search repo` runs. Repository management only touches the local helm configuration (`helm repo add`/`update` never talk to the cluster), so it is a typed form rather than an approval dialog. +Pressing Enter on a repository row browses that repo's charts: the +picker underneath scopes its search to the repo (the `repoName/` +prefix, typed for you) — the natural "what does this repo serve?" +step right after adding one. Install and upgrade render a preview before the confirmation dialog: install and upgrade run `--dry-run` (with `--hide-secret`, helm diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index 89214ab5..41d5820a 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -6690,26 +6690,38 @@ def _chosen(choices: HelmReleaseChoices | None) -> None: self.push_screen(HelmInstallPrompt(hit, namespace=namespace, release=release), _chosen) title = f"Upgrade {release} with chart:" if release else "Install helm chart" - self.push_screen( - HelmChartSearchScreen( - helm.search_repo, - title=title, - initial=initial, - on_manage_repos=lambda: self._helm_open_repos(helm), - ), - _picked, + search_screen = HelmChartSearchScreen( + helm.search_repo, + title=title, + initial=initial, + on_manage_repos=lambda: self._helm_open_repos(helm, browse_in=search_screen), ) + self.push_screen(search_screen, _picked) - def _helm_open_repos(self, helm: HelmCLI) -> None: + def _helm_open_repos( + self, helm: HelmCLI, *, browse_in: HelmChartSearchScreen | None = None + ) -> None: """Chart repository management (list/add/update). `helm repo` writes local helm config only — never the cluster — so the typed form in - the screen is the confirmation, not the write-approval gate.""" + the screen is the confirmation, not the write-approval gate. + + Enter on a repo row hands its name back (issue #137): the chart + picker in *browse_in* — when it is still the screen underneath — + scopes its search to that repository.""" + + def _picked(repo: str | None) -> None: + if repo is None or browse_in is None: + return + if self.screen is browse_in: + browse_in.browse_repo(repo) + self.push_screen( HelmRepoScreen( repo_list=helm.repo_list, repo_add=helm.repo_add, repo_update=helm.repo_update, - ) + ), + _picked, ) async def _helm_confirm_change( diff --git a/src/korvid/ui/widgets/helm_chart_search.py b/src/korvid/ui/widgets/helm_chart_search.py index 5d04eae0..09d246e8 100644 --- a/src/korvid/ui/widgets/helm_chart_search.py +++ b/src/korvid/ui/widgets/helm_chart_search.py @@ -121,6 +121,13 @@ def action_manage_repos(self) -> None: if self._on_manage_repos is not None: self._on_manage_repos() + def browse_repo(self, repo: str) -> None: + """Scope the search to one repository (issue #137): the `repo/` + prefix convention, typed for you, searched immediately.""" + keyword = f"{repo}/" + self.query_one("#chart-keyword", Input).value = keyword + self._start_search(keyword) + def _start_search(self, keyword: str) -> None: self._search_seq += 1 self.run_worker( diff --git a/src/korvid/ui/widgets/helm_repos.py b/src/korvid/ui/widgets/helm_repos.py index 23fa08ad..8dd7957e 100644 --- a/src/korvid/ui/widgets/helm_repos.py +++ b/src/korvid/ui/widgets/helm_repos.py @@ -26,8 +26,13 @@ RepoUpdateFn = Callable[[], Awaitable[str]] -class HelmRepoScreen(ModalScreen[None]): - """List / add / update helm chart repositories.""" +class HelmRepoScreen(ModalScreen[str | None]): + """List / add / update helm chart repositories. + + Enter on a repository row dismisses with that repo's name — the chart + picker underneath scopes its search to the repo (issue #137). Esc + dismisses with None (management only, nothing picked). + """ BINDINGS: ClassVar[list[Binding | tuple[str, str] | tuple[str, str, str]]] = [ Binding("escape", "close", "Close", show=True), @@ -92,6 +97,9 @@ def __init__( #: (which cancels its workers and kills the subprocess) is rejected #: until it finishes. A read-only list may be abandoned freely. self._mutating = False + #: rows currently shown in #repo-list, in display order — maps a + #: selected option index back to its repository. + self._repos: list[HelmRepo] = [] def compose(self) -> ComposeResult: with VerticalScroll(): @@ -106,7 +114,7 @@ def compose(self) -> ComposeResult: id="repo-url", ) yield Static( - "Enter: add — Ctrl-R: update indexes — Esc: close", + "Enter on a repo: browse its charts — Enter: add — Ctrl-R: update — Esc: close", id="repo-status", markup=False, ) @@ -124,6 +132,17 @@ def on_input_submitted(self, event: Input.Submitted) -> None: return self._start(self._add_repo(name, url)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Enter on a repo row: hand the repo to the chart picker below + (issue #137) — browsing is read-only, but a pending mutation still + owns the screen until it finishes.""" + event.stop() + if self._mutating: + self._status("still working — wait for the current operation to finish") + return + if 0 <= event.option_index < len(self._repos): + self.dismiss(self._repos[event.option_index].name) + def action_close(self) -> None: if self._mutating: self._status("still working — wait for the current operation to finish") @@ -166,6 +185,7 @@ async def _refresh_list(self) -> None: self._loading(False) listing = self.query_one("#repo-list", OptionList) listing.clear_options() + self._repos = list(repos) if not repos: self._status("no repositories configured — add one below") return diff --git a/tests/ui/test_helm_actions.py b/tests/ui/test_helm_actions.py index 9e44ba42..e66616e6 100644 --- a/tests/ui/test_helm_actions.py +++ b/tests/ui/test_helm_actions.py @@ -1266,3 +1266,65 @@ async def test_stale_progress_cleanup_cannot_clear_the_replacements_label( second.__exit__(None, None, None) await pilot.pause() assert "second preview" not in str(app.query_one(StatusBar).render()) + + +async def test_enter_on_a_repo_row_browses_that_repos_charts(tmp_path: Path) -> None: + """Repo-centric browsing (issue #137): Enter on a repository row closes + the repo screen and scopes the chart search to that repo (the + `repoName/` prefix convention, typed for you).""" + helm = FakeHelm() + app = make_app(helm=helm, audit_path=tmp_path / "audit.jsonl") + async with app.run_test() as pilot: + await _navigate(pilot, "helm", "helmreleases") + await pilot.press("i") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="chart search" + ) + await pilot.press("ctrl+r") + await until(pilot, lambda: isinstance(app.screen, HelmRepoScreen), label="repo screen") + await until( + pilot, + lambda: app.screen.query_one(OptionList).option_count == 1, + label="repos listed", + ) + app.screen.query_one(OptionList).focus() + await pilot.press("down") # highlight the bitnami repo row + await pilot.press("enter") # pick it + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="back to search" + ) + await until( + pilot, lambda: ("search", "bitnami/") in helm.calls, label="repo-scoped search ran" + ) + from textual.widgets import Input + + assert app.screen.query_one("#chart-keyword", Input).value == "bitnami/" + # the scoped results are pickable exactly like a keyword search + await until( + pilot, + lambda: app.screen.query_one(OptionList).option_count == 1, + label="charts listed", + ) + + +async def test_escape_on_repo_screen_keeps_the_search_keyword(tmp_path: Path) -> None: + """Closing the repo screen without picking a repo must not rewrite the + search keyword underneath.""" + helm = FakeHelm() + app = make_app(helm=helm, audit_path=tmp_path / "audit.jsonl") + async with app.run_test() as pilot: + await _navigate(pilot, "helm", "helmreleases") + await pilot.press("i") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="chart search" + ) + from textual.widgets import Input + + app.screen.query_one("#chart-keyword", Input).value = "nginx" + await pilot.press("ctrl+r") + await until(pilot, lambda: isinstance(app.screen, HelmRepoScreen), label="repo screen") + await pilot.press("escape") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="back to search" + ) + assert app.screen.query_one("#chart-keyword", Input).value == "nginx" From d8b3c1532b055e173992d2cf1e6e8c1ec8550936 Mon Sep 17 00:00:00 2001 From: hellices Date: Sun, 2 Aug 2026 00:33:34 +0900 Subject: [PATCH 2/3] fix: browse filters to the exact repo prefix; pin the pending-mutation guard Review round 1 on #150: - helm search repo substring-matches, so browsing 'stable' also surfaced 'my-stable/...' charts. browse_repo now passes a repo scope through _start_search and the results are filtered to the exact 'repo/' name prefix; manual re-searches stay unfiltered. RED test: test_repo_browse_filters_to_the_exact_repo_prefix. - the mutation guard on row selection had no test teeth: a gated repo_add test now proves Enter mid-mutation keeps the screen open and the add completes (test_repo_pick_is_rejected_while_a_mutation_is_pending). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/widgets/helm_chart_search.py | 19 +++-- tests/ui/test_helm_actions.py | 88 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/korvid/ui/widgets/helm_chart_search.py b/src/korvid/ui/widgets/helm_chart_search.py index 09d246e8..c8e6b655 100644 --- a/src/korvid/ui/widgets/helm_chart_search.py +++ b/src/korvid/ui/widgets/helm_chart_search.py @@ -123,18 +123,25 @@ def action_manage_repos(self) -> None: def browse_repo(self, repo: str) -> None: """Scope the search to one repository (issue #137): the `repo/` - prefix convention, typed for you, searched immediately.""" + prefix convention, typed for you, searched immediately. + + `helm search repo` substring-matches, so `stable/` would also + surface `my-stable/...` charts: the results are filtered to the + exact name prefix. A manual re-search is unfiltered again. + """ keyword = f"{repo}/" self.query_one("#chart-keyword", Input).value = keyword - self._start_search(keyword) + self._start_search(keyword, repo_scope=repo) - def _start_search(self, keyword: str) -> None: + def _start_search(self, keyword: str, *, repo_scope: str | None = None) -> None: self._search_seq += 1 self.run_worker( - self._run_search(keyword, self._search_seq), exclusive=True, group="helm-chart-search" + self._run_search(keyword, self._search_seq, repo_scope), + exclusive=True, + group="helm-chart-search", ) - async def _run_search(self, keyword: str, seq: int) -> None: + async def _run_search(self, keyword: str, seq: int, repo_scope: str | None = None) -> None: loading = self.query_one("#chart-loading", LoadingIndicator) status = self.query_one("#chart-status", Static) results = self.query_one("#chart-results", OptionList) @@ -155,6 +162,8 @@ async def _run_search(self, keyword: str, seq: int) -> None: loading.display = False if seq != self._search_seq: return + if repo_scope is not None: + hits = [hit for hit in hits if hit.name.startswith(f"{repo_scope}/")] if not hits: status.update( f"no charts matched {keyword!r} — try another keyword or add a repository (Ctrl-R)" diff --git a/tests/ui/test_helm_actions.py b/tests/ui/test_helm_actions.py index e66616e6..c514bc8a 100644 --- a/tests/ui/test_helm_actions.py +++ b/tests/ui/test_helm_actions.py @@ -1328,3 +1328,91 @@ async def test_escape_on_repo_screen_keeps_the_search_keyword(tmp_path: Path) -> pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="back to search" ) assert app.screen.query_one("#chart-keyword", Input).value == "nginx" + + +async def test_repo_browse_filters_to_the_exact_repo_prefix(tmp_path: Path) -> None: + """`helm search repo` substring-matches, so browsing `stable` would also + surface `my-stable/...` charts: the browse must filter hits to the exact + `repo/` name prefix.""" + helm = FakeHelm() + helm.hits = [ + ChartHit("stable/good", "1.0.0", "1.0", "in-scope"), + ChartHit("my-stable/sneaky", "2.0.0", "2.0", "substring match, other repo"), + ] + helm.repos = [HelmRepo(name="stable", url="https://charts.example/stable")] + app = make_app(helm=helm, audit_path=tmp_path / "audit.jsonl") + async with app.run_test() as pilot: + await _navigate(pilot, "helm", "helmreleases") + await pilot.press("i") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="chart search" + ) + await pilot.press("ctrl+r") + await until(pilot, lambda: isinstance(app.screen, HelmRepoScreen), label="repo screen") + await until( + pilot, + lambda: app.screen.query_one(OptionList).option_count == 1, + label="repos listed", + ) + app.screen.query_one(OptionList).focus() + await pilot.press("down", "enter") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="back to search" + ) + await until( + pilot, + lambda: app.screen.query_one(OptionList).option_count == 1, + label="only in-scope chart listed", + ) + options = app.screen.query_one(OptionList) + assert "stable/good" in str(options.get_option_at_index(0).prompt) + # a manual keyword search afterwards is unfiltered again + await pilot.press("enter") # picks stable/good -> wizard; close it + await until(pilot, lambda: isinstance(app.screen, HelmInstallPrompt), label="wizard") + await pilot.press("escape") + + +async def test_repo_pick_is_rejected_while_a_mutation_is_pending(tmp_path: Path) -> None: + """A pending `helm repo add` owns the screen: Enter on a repo row must + not dismiss it mid-mutation (dismissal cancels the worker and kills the + subprocess).""" + import asyncio as aio + + gate: aio.Event = aio.Event() + + class GatedRepoAddHelm(FakeHelm): + async def repo_add(self, name: str, url: str) -> str: + await gate.wait() + return await super().repo_add(name, url) + + helm = GatedRepoAddHelm() + app = make_app(helm=helm, audit_path=tmp_path / "audit.jsonl") + async with app.run_test() as pilot: + await _navigate(pilot, "helm", "helmreleases") + await pilot.press("i") + await until( + pilot, lambda: isinstance(app.screen, HelmChartSearchScreen), label="chart search" + ) + await pilot.press("ctrl+r") + await until(pilot, lambda: isinstance(app.screen, HelmRepoScreen), label="repo screen") + await until( + pilot, + lambda: app.screen.query_one(OptionList).option_count == 1, + label="repos listed", + ) + from textual.widgets import Input + + app.screen.query_one("#repo-name", Input).value = "extra" + app.screen.query_one("#repo-url", Input).value = "https://charts.example/extra" + app.screen.query_one("#repo-url", Input).focus() + await pilot.press("enter") # add starts, gated -> mutation pending + app.screen.query_one(OptionList).focus() + await pilot.press("down", "enter") # browse attempt mid-mutation + await pilot.pause() + assert isinstance(app.screen, HelmRepoScreen) # still owned by the add + gate.set() + await until( + pilot, + lambda: ("repo-add", "extra", "https://charts.example/extra") in helm.calls, + label="mutation completed", + ) From 0495ad2ec8e7e4824093529bec39806768fe49d6 Mon Sep 17 00:00:00 2001 From: hellices Date: Sun, 2 Aug 2026 10:39:45 +0900 Subject: [PATCH 3/3] ci: retrigger checks after self-hosted runner outage