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
2 changes: 2 additions & 0 deletions docs/source/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Press `?` in the TUI to show the help screen with all shortcuts.
| `Ctrl+k` | Kill all running jobs |
| `S` | Sort by status |
| `D` | Sort by date |
| `O` | Sort alphabetically |

**Jobs:**

Expand All @@ -120,6 +121,7 @@ Press `?` in the TUI to show the help screen with all shortcuts.
| `S` | Sort by status |
| `T` | Sort by task |
| `D` | Sort by date |
| `O` | Sort alphabetically |
| `f` | Copy folder path |

**Log Viewer:**
Expand Down
1 change: 0 additions & 1 deletion scripts/make-app-icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
from __future__ import annotations

import argparse
import colorsys
import re
import subprocess
import sys
Expand Down
107 changes: 107 additions & 0 deletions src/experimaestro/tests/test_tui_sorting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for TUI sorting keybindings and logic."""

from dataclasses import dataclass
from typing import Optional
from experimaestro.tui.widgets.experiments import ExperimentsList
from experimaestro.tui.widgets.jobs import JobsTable


@dataclass
class DummyExperiment:
experiment_id: str
started_at: Optional[object] = None
failed_jobs: int = 0
total_jobs: int = 0
finished_jobs: int = 0


@dataclass
class DummyJob:
identifier: str
task_id: str = "task_a"


def test_experiments_alphabetical_sorting():
exps = [
DummyExperiment("zebra_exp"),
DummyExperiment("Alpha_exp"),
DummyExperiment("beta_exp"),
]

# Standard sorting by ID ascending (case-insensitive)
sorted_asc = sorted(exps, key=lambda e: (e.experiment_id or "").lower())
assert [e.experiment_id for e in sorted_asc] == [
"Alpha_exp",
"beta_exp",
"zebra_exp",
]

# Sorting by ID descending
sorted_desc = sorted(
exps, key=lambda e: (e.experiment_id or "").lower(), reverse=True
)
assert [e.experiment_id for e in sorted_desc] == [
"zebra_exp",
"beta_exp",
"Alpha_exp",
]


def test_jobs_alphabetical_sorting():
jobs = [
DummyJob("job_z99"),
DummyJob("job_a01"),
DummyJob("job_M50"),
]

sorted_asc = sorted(jobs, key=lambda j: (j.identifier or "").lower())
assert [j.identifier for j in sorted_asc] == ["job_a01", "job_M50", "job_z99"]

sorted_desc = sorted(
jobs, key=lambda j: (j.identifier or "").lower(), reverse=True
)
assert [j.identifier for j in sorted_desc] == ["job_z99", "job_M50", "job_a01"]


def test_sort_column_mappings_and_bindings():
assert ExperimentsList.SORTABLE_COLUMNS.get("id") == "id"
assert JobsTable.SORTABLE_COLUMNS.get("job_id") == "job_id"

exp_binding_keys = [b.key for b in ExperimentsList.BINDINGS]
assert "O" in exp_binding_keys
assert "S" in exp_binding_keys

job_binding_keys = [b.key for b in JobsTable.BINDINGS]
assert "O" in job_binding_keys
assert "S" in job_binding_keys


def test_apply_sort_in_memory(monkeypatch):
"""Verify apply_sort uses cached data when available instead of triggering refresh"""
exp_list = ExperimentsList.__new__(ExperimentsList)
exp_list.experiments = [DummyExperiment("b"), DummyExperiment("a")]
exp_list._runs_counts = {}
exp_list._sort_column = "id"
exp_list._sort_reverse = False

refresh_called = False

def fake_refresh():
nonlocal refresh_called
refresh_called = True

exp_list.refresh_experiments = fake_refresh
exp_list._update_column_headers = lambda: None

loaded_exps = []

def fake_on_loaded(exps, counts):
nonlocal loaded_exps
loaded_exps = exps

exp_list._on_experiments_loaded = fake_on_loaded

exp_list.apply_sort()
assert not refresh_called
assert loaded_exps == exp_list.experiments

2 changes: 2 additions & 0 deletions src/experimaestro/tui/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def compose(self) -> ComposeResult:
Ctrl+k Kill all running jobs
S Sort by status
D Sort by date
O Sort alphabetically

[bold cyan]Jobs[/bold cyan]
l View job logs
Expand All @@ -143,6 +144,7 @@ def compose(self) -> ComposeResult:
S Sort by status
T Sort by task
D Sort by date
O Sort alphabetically
f Copy folder path
t Toggle tree view
g Cycle tree grouping (in tree view)
Expand Down
48 changes: 37 additions & 11 deletions src/experimaestro/tui/widgets/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ class ExperimentsList(Widget):

BINDINGS = [
Binding("d", "show_runs", "Runs"),
Binding("f", "copy_path", "Copy Path", show=False),
Binding("ctrl+d", "delete_experiment", "Delete", show=False),
Binding("ctrl+k", "kill_experiment", "Kill", show=False),
Binding("S", "sort_by_status", "Sort ⚑", show=False),
Binding("D", "sort_by_date", "Sort Date", show=False),
Binding("f", "copy_path", "Copy Path", show=True),
Binding("ctrl+d", "delete_experiment", "Delete", show=True),
Binding("ctrl+k", "kill_experiment", "Kill", show=True),
Binding("S", "sort_by_status", "Sort ⚑", show=True),
Binding("D", "sort_by_date", "Sort Date", show=True),
Binding("O", "sort_alphabetical", "Sort Name", show=True),
]

current_experiment: reactive[Optional[str]] = reactive(None)
Expand Down Expand Up @@ -65,6 +66,7 @@ class ExperimentsList(Widget):

# Columns that support sorting (column key -> sort column name)
SORTABLE_COLUMNS = {
"id": "id",
"status": "status",
"started": "started",
}
Expand All @@ -73,6 +75,7 @@ def __init__(self, state_provider: StateProvider) -> None:
super().__init__()
self.state_provider = state_provider
self.experiments = []
self._runs_counts: dict[str, str] = {} # Cache for fast in-memory re-sorting

def _get_selected_experiment_id(self) -> Optional[str]:
"""Get the experiment ID from the currently selected row"""
Expand Down Expand Up @@ -131,15 +134,22 @@ def action_show_runs(self) -> None:
)
self.post_message(ShowRunsRequest(exp_id, current_run_id))

def apply_sort(self) -> None:
"""Apply current sort settings in-memory immediately if cached data exists"""
self._update_column_headers()
if self.experiments:
self._on_experiments_loaded(self.experiments, self._runs_counts)
else:
self.refresh_experiments()

def action_sort_by_status(self) -> None:
"""Sort experiments by status"""
if self._sort_column == "status":
self._sort_reverse = not self._sort_reverse
else:
self._sort_column = "status"
self._sort_reverse = False
self._update_column_headers()
self.refresh_experiments()
self.apply_sort()
order = "desc" if self._sort_reverse else "asc"
self.notify(f"Sorted by status ({order})", severity="information")

Expand All @@ -150,11 +160,21 @@ def action_sort_by_date(self) -> None:
else:
self._sort_column = "started"
self._sort_reverse = True # Default to newest first for date
self._update_column_headers()
self.refresh_experiments()
self.apply_sort()
order = "newest first" if self._sort_reverse else "oldest first"
self.notify(f"Sorted by date ({order})", severity="information")

def action_sort_alphabetical(self) -> None:
"""Sort experiments alphabetically by ID"""
if self._sort_column == "id":
self._sort_reverse = not self._sort_reverse
else:
self._sort_column = "id"
self._sort_reverse = False
self.apply_sort()
order = "desc" if self._sort_reverse else "asc"
self.notify(f"Sorted alphabetically ({order})", severity="information")

@staticmethod
def _format_experiment_status(exp) -> str:
"""Format experiment status for display.
Expand Down Expand Up @@ -245,8 +265,7 @@ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None
self._sort_column = sort_col
# Default to reverse for date (newest first)
self._sort_reverse = sort_col == "started"
self._update_column_headers()
self.refresh_experiments()
self.apply_sort()

def compose(self) -> ComposeResult:
# Collapsed header (hidden initially)
Expand Down Expand Up @@ -330,6 +349,7 @@ def _on_experiments_loaded( # noqa: C901
pass

self.experiments = experiments
self._runs_counts = runs_counts

try:
table = self.query_one("#experiments-table", DataTable)
Expand All @@ -353,6 +373,12 @@ def _on_experiments_loaded( # noqa: C901
key=lambda e: e.started_at or datetime.min,
reverse=self._sort_reverse,
)
elif self._sort_column == "id":
# Sort alphabetically by experiment ID
experiments_sorted.sort(
key=lambda e: (e.experiment_id or "").lower(),
reverse=self._sort_reverse,
)
# Default: no sorting, use order from state provider

# Get existing row keys
Expand Down
49 changes: 37 additions & 12 deletions src/experimaestro/tui/widgets/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,7 @@ class JobsTable(Vertical):
Binding("S", "sort_by_status", "Sort ⚑", show=False),
Binding("T", "sort_by_task", "Sort Task", show=False),
Binding("D", "sort_by_submitted", "Sort Date", show=False),
Binding("O", "sort_alphabetical", "Sort Name", show=False),
Binding("t", "toggle_tree_view", "Tree"),
Binding("escape", "clear_search", show=False, priority=True),
]
Expand All @@ -1152,6 +1153,7 @@ def __init__(self, state_provider: StateProvider) -> None:
self.task_id_map: dict[str, str] = {} # job_id -> task_id
self.experiment_job_info: dict = {} # job_id -> ExperimentJobInformation
self._current_experiment_obj = None # BaseExperiment object for resolved state
self._cached_jobs: Optional[list] = None # Cache for fast in-memory re-sorting

def compose(self) -> ComposeResult:
yield Static("", id="past-run-banner", classes="hidden")
Expand Down Expand Up @@ -1222,16 +1224,26 @@ def action_clear_filter(self) -> None:
self.refresh_jobs()
self.notify("Filter cleared", severity="information")

def apply_sort(self) -> None:
"""Apply current sort settings in-memory immediately if cached data exists"""
self._needs_rebuild = True
self._update_column_headers()
if self._cached_jobs is not None:
if self._tree_mode:
self._update_tree_view()
else:
self._refresh_jobs_with_data(self._cached_jobs)
else:
self.refresh_jobs()

def action_sort_by_status(self) -> None:
"""Sort jobs by status"""
if self._sort_column == "status":
self._sort_reverse = not self._sort_reverse
else:
self._sort_column = "status"
self._sort_reverse = False
self._needs_rebuild = True
self._update_column_headers()
self.refresh_jobs()
self.apply_sort()
order = "desc" if self._sort_reverse else "asc"
self.notify(f"Sorted by status ({order})", severity="information")

Expand All @@ -1242,9 +1254,7 @@ def action_sort_by_task(self) -> None:
else:
self._sort_column = "task"
self._sort_reverse = False
self._needs_rebuild = True
self._update_column_headers()
self.refresh_jobs()
self.apply_sort()
order = "desc" if self._sort_reverse else "asc"
self.notify(f"Sorted by task ({order})", severity="information")

Expand All @@ -1255,12 +1265,21 @@ def action_sort_by_submitted(self) -> None:
else:
self._sort_column = "submitted"
self._sort_reverse = False
self._needs_rebuild = True
self._update_column_headers()
self.refresh_jobs()
self.apply_sort()
order = "newest first" if self._sort_reverse else "oldest first"
self.notify(f"Sorted by date ({order})", severity="information")

def action_sort_alphabetical(self) -> None:
"""Sort jobs alphabetically by ID"""
if self._sort_column == "job_id":
self._sort_reverse = not self._sort_reverse
else:
self._sort_column = "job_id"
self._sort_reverse = False
self.apply_sort()
order = "desc" if self._sort_reverse else "asc"
self.notify(f"Sorted alphabetically ({order})", severity="information")

def action_clear_search(self) -> None:
"""Handle escape: hide search bar if visible, or go back"""
search_bar = self.query_one(SearchBar)
Expand Down Expand Up @@ -1445,6 +1464,7 @@ def _get_status_sort_key(self, job):

# Columns that support sorting (column key -> sort column name)
SORTABLE_COLUMNS = {
"job_id": "job_id",
"status": "status",
"task": "task",
"submitted": "submitted",
Expand Down Expand Up @@ -1489,9 +1509,7 @@ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None
else:
self._sort_column = sort_col
self._sort_reverse = False
self._needs_rebuild = True
self._update_column_headers()
self.refresh_jobs()
self.apply_sort()

def set_experiment(
self,
Expand Down Expand Up @@ -1582,6 +1600,7 @@ def _on_data_loaded(
if experiment_job_info is not None:
self.experiment_job_info = experiment_job_info
self._current_experiment_obj = experiment_obj
self._cached_jobs = jobs

# Refresh display with loaded jobs
if self._tree_mode:
Expand Down Expand Up @@ -1634,6 +1653,12 @@ def _refresh_jobs_with_data(self, jobs: list) -> None: # noqa: C901
key=lambda j: j.task_id or "",
reverse=self._sort_reverse,
)
elif self._sort_column == "job_id":
# Sort alphabetically by job ID
jobs.sort(
key=lambda j: (j.identifier or "").lower(),
reverse=self._sort_reverse,
)
else:
# Default: sort by submission time (oldest first by default)
# Use experiment_job_info timestamp for submittime
Expand Down
Loading