Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/helm-operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 23 additions & 11 deletions src/korvid/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 19 additions & 3 deletions src/korvid/ui/widgets/helm_chart_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,27 @@ def action_manage_repos(self) -> None:
if self._on_manage_repos is not None:
self._on_manage_repos()

def _start_search(self, keyword: str) -> 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.

`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, repo_scope=repo)

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)
Expand All @@ -148,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)"
Expand Down
26 changes: 23 additions & 3 deletions src/korvid/ui/widgets/helm_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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():
Expand All @@ -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,
)
Expand All @@ -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
Comment thread
hellices marked this conversation as resolved.
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")
Expand Down Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions tests/ui/test_helm_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1266,3 +1266,153 @@ 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"


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