Skip to content
Closed
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
157 changes: 138 additions & 19 deletions src/gantry/widgets.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Custom widgets for Gantry TUI."""

import logging
from typing import Any, Dict, List, Optional, Callable
from datetime import datetime
from typing import Any, Dict, List, Optional, Callable, Tuple
from textual.widgets import DataTable, Static, Input
from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.events import Key
from textual.css.query import NoMatches
from rich.text import Text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm rich is not declared as a direct dependency in pyproject.toml
fd -t f 'pyproject.toml' --max-depth 3 --exec cat {}
echo "---"
fd -t f 'pyproject.toml' --max-depth 3 --exec rg -n '\brich\b' {} \;

Repository: iamtanbirahmed/gantry

Length of output: 468


Declare rich as a direct dependency.

rich.text.Text is imported and used at module level, but rich is not listed in dependencies within pyproject.toml. While rich is available transitively through textual, relying on this is fragile—a future Textual release could drop or replace it. Add rich to pyproject.toml's dependencies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gantry/widgets.py` at line 11, The module imports rich.text.Text at
top-level (see import of Text in src/gantry/widgets.py), but rich is not
declared in pyproject.toml dependencies; add "rich" (compatible version, e.g.
same major as current transitively used by textual) to the
[project]/dependencies in pyproject.toml so the package declares a direct
dependency rather than relying on a transitive dependency; update the version
specifier to match your compatibility policy and run dependency tooling
(poetry/pip) to refresh lockfiles.


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -45,6 +47,100 @@ def __init__(self, *args, **kwargs):
self._all_rows: Dict[str, List[Any]] = {}
self._search_term: str = ""
self._columns: List[str] = []
self._column_keys: List[str] = []
# Each entry is (column_index, reverse); index 0 = primary sort
self._sort_columns: List[Tuple[int, bool]] = []
self._shift_held: bool = False

def on_mouse_down(self, event) -> None:
"""Capture shift modifier state so header clicks can use it."""
self._shift_held = getattr(event, "shift", False)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _compute_next_sort(self, col_idx: int, shift: bool) -> None:
"""Update _sort_columns based on a header click.

Args:
col_idx: Index of the clicked column.
shift: Whether Shift was held (adds secondary sort).
"""
existing_pos = next(
(i for i, (idx, _) in enumerate(self._sort_columns) if idx == col_idx),
None,
)
if shift:
if existing_pos is not None:
idx, rev = self._sort_columns[existing_pos]
self._sort_columns[existing_pos] = (idx, not rev)
else:
self._sort_columns.append((col_idx, False))
if len(self._sort_columns) > 3:
self._sort_columns.pop(0)
Comment on lines +70 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Shift+Click overflow silently demotes the primary sort column.

When _sort_columns already has 3 entries and the user shift-clicks a new column, pop(0) discards the primary sort key, promoting what was the secondary to primary. This is surprising UX — the user's most-significant sort silently disappears while they were trying to add a tertiary. Most multi-sort UIs either ignore the addition past the cap or drop the least significant key (last secondary), preserving the user's primary intent.

♻️ Proposed fix — drop the least-significant sort instead of the primary
         if shift:
             if existing_pos is not None:
                 idx, rev = self._sort_columns[existing_pos]
                 self._sort_columns[existing_pos] = (idx, not rev)
             else:
-                self._sort_columns.append((col_idx, False))
-                if len(self._sort_columns) > 3:
-                    self._sort_columns.pop(0)
+                if len(self._sort_columns) >= 3:
+                    # At cap: drop the least-significant key, keep primary intent.
+                    self._sort_columns.pop()
+                self._sort_columns.append((col_idx, False))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gantry/widgets.py` around lines 70 - 77, The current shift-click branch
appends a new sort tuple to self._sort_columns and when len > 3 uses pop(0),
which discards the primary sort; change the overflow removal to drop the
least-significant pre-existing key instead of the primary by popping the
second-to-last entry after the append (e.g. replace self._sort_columns.pop(0)
with self._sort_columns.pop(-2)) so primary remains at index 0; adjust only the
overflow branch in the shift handling around existing_pos/col_idx/_sort_columns.

else:
if existing_pos == 0:
# Clicking current primary sort: toggle direction, clear secondaries
_, rev = self._sort_columns[0]
self._sort_columns = [(col_idx, not rev)]
else:
# New primary sort column: start ascending
self._sort_columns = [(col_idx, False)]

def on_data_table_header_selected(self, event) -> None:
"""Handle column header click to set sort order."""
ck = event.column_key
col_key_str = str(getattr(ck, "value", ck))
if col_key_str not in self._column_keys:
return
col_idx = self._column_keys.index(col_key_str)
self._compute_next_sort(col_idx, self._shift_held)
self._shift_held = False
self._update_column_labels()
self._apply_filter(self._search_term)

def _build_column_label_text(self, col_name: str, col_idx: int) -> str:
"""Return column label text with sort indicator appended if applicable."""
sort_map = {idx: (rank, rev) for rank, (idx, rev) in enumerate(self._sort_columns)}
multi = len(self._sort_columns) > 1
if col_idx in sort_map:
rank, rev = sort_map[col_idx]
indicator = "▼" if rev else "▲"
suffix = f" {indicator}{rank + 1}" if multi else f" {indicator}"
return col_name + suffix
return col_name

def _coerce_sort_value(self, value: Any) -> tuple:
"""Return a (type_tag, coerced_value) tuple for type-aware, cross-type-safe sorting.

Ordering: numbers (0) < datetimes (1) < strings (2).
Ensures no TypeError when a column contains mixed types.
"""
if value is None:
return (2, "")
s = str(value).strip()
if not s:
return (2, "")
try:
return (0, float(s))
except (ValueError, TypeError):
pass
try:
return (1, datetime.fromisoformat(s.replace("Z", "+00:00")))
except (ValueError, TypeError):
return (2, s.lower())

def _update_column_labels(self) -> None:
"""Rewrite column header labels in-place to show sort indicators."""
for col_key_obj, column in self.columns.items():
key_str = str(getattr(col_key_obj, "value", col_key_obj))
if not key_str.startswith("col_"):
continue
try:
i = int(key_str.split("_")[1])
except (ValueError, IndexError):
continue
if i >= len(self._columns):
continue
column.label = Text(self._build_column_label_text(self._columns[i], i))
self.refresh()

def populate_resources(
self,
Expand All @@ -55,29 +151,33 @@ def populate_resources(
"""
Populate the table with resources.

Maintains current sort when column set is unchanged; resets sort when
columns change (e.g., switching resource type).

Args:
resources: List of resource dictionaries from K8s API.
columns: List of column headers to display.
column_keys: List of keys to extract from each resource dict.
"""
old_columns = self._columns[:]
self.clear(columns=True)
self._all_rows.clear()
self._columns = columns
self._columns = list(columns)
self._column_keys = [f"col_{i}" for i in range(len(columns))]

# Add columns
for col in columns:
self.add_column(col)
if list(columns) != old_columns:
self._sort_columns.clear()

for i, col in enumerate(columns):
key = f"col_{i}"
self.add_column(self._build_column_label_text(col, i), key=key)

# Add rows
for i, resource in enumerate(resources):
row_key = f"row-{i}"
row_values = [str(resource.get(key, "")) for key in column_keys]
self.add_row(*row_values, key=row_key)
row_values = [resource.get(k, "") for k in column_keys]
self._all_rows[row_key] = row_values

# Apply current search filter
if self._search_term:
self._apply_filter(self._search_term)
self._apply_filter(self._search_term)

def filter_by_search(self, search_term: str) -> None:
"""
Expand All @@ -89,19 +189,38 @@ def filter_by_search(self, search_term: str) -> None:
self._search_term = search_term.lower()
self._apply_filter(self._search_term)

def _sort_items(
self, items: List[Tuple[str, List[Any]]]
) -> List[Tuple[str, List[Any]]]:
"""Return items sorted according to _sort_columns (stable multi-key)."""
if not self._sort_columns:
return items
sorted_items = list(items)
# Stable sort: apply from least to most significant column
for col_idx, reverse in reversed(self._sort_columns):
sorted_items.sort(
key=lambda item, c=col_idx: self._coerce_sort_value(item[1][c])
if c < len(item[1])
else (2, ""),
reverse=reverse,
)
return sorted_items

def _apply_filter(self, search_term: str) -> None:
"""Apply the search filter to the table."""
"""Apply the search filter and current sort order to the table."""
self.clear() # Keeps columns; only clears rows

if not search_term:
# Show all rows
for row_key, row_values in self._all_rows.items():
self.add_row(*row_values, key=row_key)
visible_items = list(self._all_rows.items())
else:
# Filter rows by search term
for row_key, row_values in self._all_rows.items():
if any(search_term in str(val).lower() for val in row_values):
self.add_row(*row_values, key=row_key)
visible_items = [
(key, vals)
for key, vals in self._all_rows.items()
if any(search_term in str(val).lower() for val in vals)
]

for row_key, row_values in self._sort_items(visible_items):
self.add_row(*[str(v) for v in row_values], key=row_key)

def on_data_table_row_selected(self, event) -> None:
"""Handle row selection."""
Expand Down
Loading