Skip to content
Open
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 sqlit/core/input_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ class InputContext:
# True when the column under the cursor is referenced by some other table's FK
# (i.e. pressing the navigate-referrers key would open the picker).
cursor_column_is_foreign_key_target: bool = False
# True when the active results table is showing the transposed (columns-as-rows) view.
results_transposed: bool = False
1 change: 1 addition & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ def _build_action_keys(self) -> list[ActionKeyDef]:
ActionKeyDef("tab", "next_result_section", "results"),
ActionKeyDef("shift+tab", "prev_result_section", "results"),
ActionKeyDef("z", "toggle_result_section", "results"),
ActionKeyDef("T", "toggle_transpose", "results"),
ActionKeyDef("escape", "results_filter_close", "results_filter"),
ActionKeyDef("enter", "results_filter_accept", "results_filter"),
# Value view
Expand Down
2 changes: 2 additions & 0 deletions sqlit/domains/query/ui/mixins/query_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _replace_results_table_with_data(
) -> None:
"""Replace the results table with new data."""
self._cancel_results_render()
self._results_transposed = False
container = self.results_area
old_table = self.results_table
was_focused = old_table.has_focus
Expand Down Expand Up @@ -321,6 +322,7 @@ async def _display_query_results(
self._last_result_columns = columns
self._last_result_rows = rows
self._last_result_row_count = row_count
self._results_transposed = False
table_info = getattr(self, "_pending_result_table_info", None)

# Switch to single result mode (in case we were showing stacked results)
Expand Down
76 changes: 54 additions & 22 deletions sqlit/domains/results/state/results_focused.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,36 @@ def _setup_actions(self) -> None:
def has_results(app: InputContext) -> bool:
return app.has_results

def not_transposed(app: InputContext) -> bool:
return app.has_results and not app.results_transposed

def can_toggle_transpose(app: InputContext) -> bool:
return app.has_results and not app.results_filter_active

self.allows("view_cell", has_results, key="v", label="View cell", help="Preview cell (tooltip)")
self.allows("view_cell_full", has_results, key="V", label="View full", help="View full cell value")
self.allows("edit_cell", has_results, key="u", label="Update cell", help="Update cell (generate UPDATE)")
self.allows("delete_row", has_results, key="d", label="Delete row", help="Delete row (generate DELETE)")
self.allows("navigate_fk", has_results, key="o", label="FK jump", help="Open row referenced by foreign key")
self.allows("navigate_referrers", has_results, key="O", label="Refs", help="Show tables referencing this row")
self.allows("edit_cell", not_transposed, key="u", label="Update cell", help="Update cell (generate UPDATE)")
self.allows("delete_row", not_transposed, key="d", label="Delete row", help="Delete row (generate DELETE)")
self.allows(
"navigate_fk", not_transposed, key="o", label="FK jump", help="Open row referenced by foreign key"
)
self.allows(
"navigate_referrers",
not_transposed,
key="O",
label="Refs",
help="Show tables referencing this row",
)
self.allows("results_yank_leader_key", has_results, key="y", label="Copy", help="Copy menu (cell/row/all)")
self.allows("clear_results", has_results, key="x", label="Clear", help="Clear results")
self.allows("results_filter", has_results, key="slash", label="Filter", help="Filter rows")
self.allows("results_filter", not_transposed, key="slash", label="Filter", help="Filter rows")
self.allows(
"toggle_transpose",
can_toggle_transpose,
key="T",
label="Transpose",
help="Transpose columns/rows",
)
self.allows("results_cursor_left", has_results) # vim h
self.allows("results_cursor_down", has_results) # vim j
self.allows("results_cursor_up", has_results) # vim k
Expand Down Expand Up @@ -83,29 +104,30 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
action="view_cell_full",
)
)
left.append(
DisplayBinding(
key=resolve_display_key("edit_cell") or "u",
label="Update",
action="edit_cell",
if not app.results_transposed:
left.append(
DisplayBinding(
key=resolve_display_key("edit_cell") or "u",
label="Update",
action="edit_cell",
)
)
)
left.append(
DisplayBinding(
key=resolve_display_key("delete_row") or "d",
label="Delete",
action="delete_row",
left.append(
DisplayBinding(
key=resolve_display_key("delete_row") or "d",
label="Delete",
action="delete_row",
)
)
)
if app.cursor_column_is_foreign_key:
if app.cursor_column_is_foreign_key and not app.results_transposed:
left.append(
DisplayBinding(
key=resolve_display_key("navigate_fk") or "o",
label="FK",
action="navigate_fk",
)
)
if app.cursor_column_is_foreign_key_target:
if app.cursor_column_is_foreign_key_target and not app.results_transposed:
left.append(
DisplayBinding(
key=resolve_display_key("navigate_referrers") or "O",
Expand All @@ -129,11 +151,19 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
)
left.append(
DisplayBinding(
key=resolve_display_key("results_filter") or "/",
label="Filter",
action="results_filter",
key=resolve_display_key("toggle_transpose") or "T",
label="Untranspose" if app.results_transposed else "Transpose",
action="toggle_transpose",
)
)
if not app.results_transposed:
left.append(
DisplayBinding(
key=resolve_display_key("results_filter") or "/",
label="Filter",
action="results_filter",
)
)
if app.stacked_result_count > 1:
left.append(
DisplayBinding(
Expand All @@ -154,11 +184,13 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding],
[
"view_cell",
"view_cell_full",
"edit_cell",
"delete_row",
"navigate_fk",
"navigate_referrers",
"results_yank_leader_key",
"clear_results",
"toggle_transpose",
"results_filter",
"next_result_section",
"prev_result_section",
Expand Down
133 changes: 129 additions & 4 deletions sqlit/domains/results/ui/mixins/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

FK_NAVIGATION_DEFAULT_LIMIT = 100

# Each original row becomes a column in the transposed view, and SqlitDataTable
# (Arrow-backed) isn't built for hundreds of columns, so cap how many rows get
# transposed at once.
MAX_TRANSPOSE_ROWS = 200


def build_fk_navigation_query(
*,
Expand Down Expand Up @@ -60,13 +65,33 @@ def _strip_table_markup(table: Any, value: Any) -> Any:
return value


def _transpose_result_data(
columns: list[str], rows: list[tuple[Any, ...]]
) -> tuple[list[str], list[tuple[Any, ...]]]:
"""Swap axes: column names become the "Column" column, each row a "Row N" column.

Each transposed "Row N" column mixes values from every original column, which
may have different types (e.g. an int id next to a str name) - the Arrow-backed
table requires one type per column, so values are formatted to strings up front
rather than left raw (which Arrow would otherwise silently coerce inconsistently).
"""

def fmt(value: Any) -> str:
return "NULL" if value is None else str(value)

header = ["Column"] + [f"Row {i + 1}" for i in range(len(rows))]
transposed = [(col_name, *(fmt(row[col_idx]) for row in rows)) for col_idx, col_name in enumerate(columns)]
return header, transposed


class ResultsMixin:
"""Mixin providing results handling functionality."""

_last_result_columns: list[str] = []
_last_result_rows: list[tuple[Any, ...]] = []
_export_column_indices: list[int] | None = None
_last_result_row_count: int = 0
_results_transposed: bool = False
_tooltip_cell_coord: tuple[int, int] | None = None
_tooltip_showing: bool = False
_tooltip_timer: Any | None = None
Expand Down Expand Up @@ -339,6 +364,41 @@ def _find_results_section(self: ResultsMixinHost, widget: Any) -> Any | None:
current = getattr(current, "parent", None)
return None

def _replace_results_section_table_typed(
self: ResultsMixinHost,
section: Any,
old_table: SqlitDataTable,
columns: list[str],
rows: list[tuple[Any, ...]],
) -> None:
"""Replace a stacked result section's table, without results-filter markup escaping.

Not `_build_results_section_table` (results_filter.py), which stringifies every
cell to highlight filter matches. Used both to build the transposed view (where
`columns`/`rows` already come pre-formatted as strings from `_transpose_result_data`)
and to restore the original table on untranspose (where `rows` are the untouched,
per-cell-typed source data, so numeric/date formatting still applies there).
"""
table_height = min(2 + len(rows), 16)
was_focused = old_table.has_focus
# `old_table.remove()` below doesn't free its widget id synchronously, so a
# fresh id (mirroring `_build_results_table`'s counter) avoids a DuplicateIds
# error when mounting `new_table` while `old_table` is still registered.
self._results_table_counter += 1
new_table = SqlitDataTable(
id=f"result-table-{section.index}-{self._results_table_counter}",
zebra_stripes=True,
data=rows,
column_labels=columns,
render_markup=False,
null_rep="NULL",
)
new_table.styles.height = table_height
section.mount(new_table, after=old_table)
old_table.remove()
if was_focused:
new_table.focus()

def _flash_table_yank(self: ResultsMixinHost, table: SqlitDataTable, scope: str) -> None:
"""Briefly flash the yanked cell(s) to confirm a copy action."""
from sqlit.shared.ui.widgets import flash_widget
Expand Down Expand Up @@ -410,7 +470,7 @@ def action_view_cell_full(self: ResultsMixinHost) -> None:
"""View the full value of the selected cell inline."""
from sqlit.shared.ui.widgets import InlineValueView

table, _columns, _rows, _stacked = self._get_active_results_context()
table, _columns, _rows, stacked = self._get_active_results_context()
if not table or table.row_count <= 0:
self.notify("No results", severity="warning")
return
Expand All @@ -422,9 +482,17 @@ def action_view_cell_full(self: ResultsMixinHost) -> None:

self._hide_cell_tooltip(table)

# Get column name if available
# Get column name if available. While transposed, cursor_col runs over the
# displayed Column/Row-N headers, not `_last_result_columns` (the original,
# untransposed source) - read the label straight off the live table instead.
column_name = ""
if self._last_result_columns and cursor_col < len(self._last_result_columns):
if self._is_active_results_transposed(table, stacked):
try:
if 0 <= cursor_col < len(table.ordered_columns):
column_name = table.ordered_columns[cursor_col].label.plain
except Exception:
column_name = ""
elif self._last_result_columns and cursor_col < len(self._last_result_columns):
column_name = self._last_result_columns[cursor_col]

# Show inline value view
Expand Down Expand Up @@ -524,6 +592,51 @@ def action_vy_all(self: ResultsMixinHost) -> None:
except Exception:
pass

def _is_active_results_transposed(self: ResultsMixinHost, table: Any, stacked: bool) -> bool:
"""Whether the given active results table is currently showing the transposed view."""
if stacked:
section = self._find_results_section(table) if table else None
return bool(getattr(section, "result_transposed", False))
return bool(getattr(self, "_results_transposed", False))

def action_toggle_transpose(self: ResultsMixinHost) -> None:
"""Toggle the transposed (columns-as-rows) view of the active results table."""
table, columns, rows, stacked = self._get_active_results_context()
if not table or not columns or not rows:
self.notify("No results", severity="warning")
return

section = self._find_results_section(table) if stacked else None
currently_transposed = self._is_active_results_transposed(table, stacked)

if currently_transposed:
if stacked and section is not None:
self._replace_results_section_table_typed(section, table, columns, rows)
section.result_transposed = False
else:
self._replace_results_table(columns, rows)
self._results_transposed = False
self._update_footer_bindings()
return

display_rows = rows
truncated = len(rows) > MAX_TRANSPOSE_ROWS
if truncated:
display_rows = rows[:MAX_TRANSPOSE_ROWS]

t_columns, t_rows = _transpose_result_data(columns, display_rows)

if stacked and section is not None:
self._replace_results_section_table_typed(section, table, t_columns, t_rows)
section.result_transposed = True
else:
self._replace_results_table(t_columns, t_rows)
self._results_transposed = True

if truncated:
self.notify(f"Transposed first {MAX_TRANSPOSE_ROWS} of {len(rows)} rows", severity="warning")
self._update_footer_bindings()

def action_toggle_value_view_mode(self: ResultsMixinHost) -> None:
"""Toggle between tree and syntax view in the inline value view."""
from sqlit.shared.ui.widgets import InlineValueView
Expand Down Expand Up @@ -911,10 +1024,16 @@ def _copy_scope_as_format(
from sqlit.domains.results.formatters import FORMATS

self._clear_leader_pending()
table, columns, rows, _stacked = self._get_active_results_context()
table, columns, rows, stacked = self._get_active_results_context()
if not table or table.row_count <= 0:
self.notify("No results", severity="warning")
return
# "cell" labels the value with `columns[col_idx]` and "row" pairs live row
# values with `columns` - both index/zip against the original untransposed
# source, which is the wrong shape once cursor_col/row run over Column/Row-N.
if scope != "all" and self._is_active_results_transposed(table, stacked):
self.notify("Not available in transposed view", severity="warning")
return

fmt = FORMATS[fmt_key]
try:
Expand Down Expand Up @@ -983,6 +1102,12 @@ def _copy_column_values(self: ResultsMixinHost) -> None:
if not table or table.row_count <= 0 or not rows:
self.notify("No results", severity="warning")
return
# The cursor's column index runs over the transposed Column/Row-N axis,
# not `columns` (the original untransposed source), so this would read
# values from the wrong original column.
if self._is_active_results_transposed(table, _stacked):
self.notify("Not available in transposed view", severity="warning")
return
try:
_row_idx, col_idx = table.cursor_coordinate
except Exception:
Expand Down
9 changes: 8 additions & 1 deletion sqlit/domains/shell/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def __init__(
self._autocomplete_just_applied: bool = False
self._suppress_autocomplete_once: bool = False
self._value_view_active: bool = False
self._results_transposed: bool = False
self._last_result_columns: list[str] = []
self._last_result_rows: list[tuple[Any, ...]] = []
self._last_result_row_count: int = 0
Expand Down Expand Up @@ -301,10 +302,15 @@ def _get_input_context(self) -> InputContext:
# before the widget is mounted, so guard it.
active_table_info: dict[str, Any] | None = None
cursor_column_name: str | None = None
results_transposed = False
try:
rt, columns, _rows, stacked = self._get_active_results_context()
active_table_info = self._get_active_results_table_info(rt, stacked) if rt else None
if rt and rt.row_count > 0:
results_transposed = self._is_active_results_transposed(rt, stacked) if rt else False
# When transposed, the cursor's column index runs over the "Row N"
# columns, not the original DB columns, so cursor_column_name (and the
# FK lookups below) would be nonsense - skip it.
if rt and rt.row_count > 0 and not results_transposed:
col_index = rt.cursor_coordinate.column
if 0 <= col_index < len(columns):
cursor_column_name = columns[col_index]
Expand Down Expand Up @@ -354,6 +360,7 @@ def _get_input_context(self) -> InputContext:
count_buffer=self._count_buffer,
cursor_column_is_foreign_key=cursor_column_is_foreign_key,
cursor_column_is_foreign_key_target=cursor_column_is_foreign_key_target,
results_transposed=results_transposed,
)

def _debug_screen_label(self, screen: Any | None) -> str:
Expand Down
Loading