Skip to content

Commit 45ae52c

Browse files
authored
Merge pull request #237 from Maxteabag/worktree-issue-169-copy-export-formats
Markdown export + Copy as… submenu
2 parents 74e1549 + 7161c83 commit 45ae52c

5 files changed

Lines changed: 331 additions & 22 deletions

File tree

sqlit/core/keymap.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,29 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]:
295295
LeaderCommandDef("c", "cell", "Copy cell", "Copy", menu="ry"),
296296
LeaderCommandDef("y", "row", "Copy row", "Copy", menu="ry"),
297297
LeaderCommandDef("a", "all", "Copy all", "Copy", menu="ry"),
298+
LeaderCommandDef("f", "format", "Copy as...", "Copy", menu="ry"),
298299
LeaderCommandDef("e", "export", "Export...", "Export", menu="ry"),
299300
# rye results export menu
300301
LeaderCommandDef("c", "csv", "Export as CSV", "Export", menu="rye"),
301302
LeaderCommandDef("j", "json", "Export as JSON", "Export", menu="rye"),
303+
LeaderCommandDef("m", "markdown", "Export as Markdown", "Export", menu="rye"),
304+
# ryf copy-as format picker
305+
LeaderCommandDef("m", "markdown", "Copy as Markdown...", "Copy as", menu="ryf"),
306+
LeaderCommandDef("j", "json", "Copy as JSON...", "Copy as", menu="ryf"),
307+
LeaderCommandDef("c", "csv", "Copy as CSV...", "Copy as", menu="ryf"),
308+
LeaderCommandDef("v", "values", "Copy column values", "Copy as", menu="ryf"),
309+
# ryfm markdown scope
310+
LeaderCommandDef("c", "cell", "Cell as Markdown", "Copy as", menu="ryfm"),
311+
LeaderCommandDef("y", "row", "Row as Markdown", "Copy as", menu="ryfm"),
312+
LeaderCommandDef("a", "all", "All as Markdown", "Copy as", menu="ryfm"),
313+
# ryfj json scope
314+
LeaderCommandDef("c", "cell", "Cell as JSON", "Copy as", menu="ryfj"),
315+
LeaderCommandDef("y", "row", "Row as JSON", "Copy as", menu="ryfj"),
316+
LeaderCommandDef("a", "all", "All as JSON", "Copy as", menu="ryfj"),
317+
# ryfc csv scope
318+
LeaderCommandDef("c", "cell", "Cell as CSV", "Copy as", menu="ryfc"),
319+
LeaderCommandDef("y", "row", "Row as CSV", "Copy as", menu="ryfc"),
320+
LeaderCommandDef("a", "all", "All as CSV", "Copy as", menu="ryfc"),
302321
# rg results g motion menu (vim-style gg)
303322
LeaderCommandDef("g", "first_row", "Go to first row", "Go to", menu="rg"),
304323
# vy value view yank menu (tree mode)
Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,96 @@
1-
"""Result formatters for export (CSV, JSON)."""
1+
"""Result formatters for copy & export.
2+
3+
Shared by the clipboard copy actions (ry/ryf menus) and the file export
4+
flow (rye menu). Each ResultFormat ties a formatter function to a file
5+
extension and a display label so the registry can drive both paths.
6+
"""
27

38
from __future__ import annotations
49

510
import csv
611
import io
712
import json
8-
from typing import Any
13+
from collections.abc import Sequence
14+
from dataclasses import dataclass
15+
from typing import Any, Callable
16+
17+
Rows = Sequence[Sequence[Any]]
18+
Columns = Sequence[str]
919

1020

11-
def format_csv(columns: list[str], rows: list[tuple[Any, ...]]) -> str:
21+
def format_csv(columns: Columns, rows: Rows) -> str:
1222
"""Format results as CSV string."""
1323
output = io.StringIO()
1424
writer = csv.writer(output)
15-
writer.writerow(columns)
25+
if columns:
26+
writer.writerow(list(columns))
1627
for row in rows:
1728
writer.writerow(str(val) if val is not None else "" for val in row)
1829
return output.getvalue()
1930

2031

21-
def format_json(columns: list[str], rows: list[tuple[Any, ...]]) -> str:
32+
def format_json(columns: Columns, rows: Rows) -> str:
2233
"""Format results as JSON string (array of objects)."""
34+
cols = list(columns)
2335
result = [
24-
dict(zip(columns, [val if val is not None else None for val in row]))
36+
dict(zip(cols, [val if val is not None else None for val in row]))
2537
for row in rows
2638
]
2739
return json.dumps(result, indent=2, default=str)
40+
41+
42+
def format_markdown(columns: Columns, rows: Rows) -> str:
43+
"""Format results as a GitHub-flavored markdown table."""
44+
45+
def cell(value: Any) -> str:
46+
if value is None:
47+
return ""
48+
# Escape pipes and flatten newlines so the table layout survives.
49+
return str(value).replace("|", "\\|").replace("\r", "").replace("\n", " ")
50+
51+
cols = list(columns)
52+
lines: list[str] = []
53+
if cols:
54+
lines.append("| " + " | ".join(cell(c) for c in cols) + " |")
55+
lines.append("| " + " | ".join("---" for _ in cols) + " |")
56+
for row in rows:
57+
lines.append("| " + " | ".join(cell(v) for v in row) + " |")
58+
return "\n".join(lines) + ("\n" if lines else "")
59+
60+
61+
def format_values_list(values: Sequence[Any], separator: str = ", ") -> str:
62+
"""Format a flat sequence of values as a separator-joined plain list.
63+
64+
Intended for re-using a column's values inside `WHERE col IN (...)` or
65+
similar. Numeric values pass through; strings are SQL-quoted with single
66+
quotes doubled. NULL becomes the literal `NULL` (unquoted).
67+
"""
68+
69+
def fmt(value: Any) -> str:
70+
if value is None:
71+
return "NULL"
72+
if isinstance(value, bool):
73+
return "TRUE" if value else "FALSE"
74+
if isinstance(value, (int, float)):
75+
return str(value)
76+
text = str(value).replace("'", "''")
77+
return f"'{text}'"
78+
79+
return separator.join(fmt(v) for v in values)
80+
81+
82+
@dataclass(frozen=True)
83+
class ResultFormat:
84+
"""A copy/export format with formatter, label, and file extension."""
85+
86+
key: str
87+
label: str
88+
extension: str
89+
formatter: Callable[[Columns, Rows], str]
90+
91+
92+
FORMATS: dict[str, ResultFormat] = {
93+
"csv": ResultFormat("csv", "CSV", "csv", format_csv),
94+
"json": ResultFormat("json", "JSON", "json", format_json),
95+
"markdown": ResultFormat("markdown", "Markdown", "md", format_markdown),
96+
}

sqlit/domains/results/ui/mixins/results.py

Lines changed: 140 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -610,33 +610,38 @@ def action_ry_export(self: ResultsMixinHost) -> None:
610610

611611
def action_rye_csv(self: ResultsMixinHost) -> None:
612612
"""Export results as CSV to file."""
613-
self._clear_leader_pending()
614-
if not self._last_result_columns or not self._last_result_rows:
615-
self.notify("No results to export", severity="warning")
616-
return
617-
self._show_export_dialog("csv")
613+
self._open_export_dialog("csv")
618614

619615
def action_rye_json(self: ResultsMixinHost) -> None:
620616
"""Export results as JSON to file."""
617+
self._open_export_dialog("json")
618+
619+
def action_rye_markdown(self: ResultsMixinHost) -> None:
620+
"""Export results as Markdown table to file."""
621+
self._open_export_dialog("markdown")
622+
623+
def _open_export_dialog(self: ResultsMixinHost, fmt_key: str) -> None:
624+
"""Validate the result set and show the save dialog for a format key."""
621625
self._clear_leader_pending()
622626
if not self._last_result_columns or not self._last_result_rows:
623627
self.notify("No results to export", severity="warning")
624628
return
625-
self._show_export_dialog("json")
629+
self._show_export_dialog(fmt_key)
626630

627-
def _show_export_dialog(self: ResultsMixinHost, fmt: str) -> None:
631+
def _show_export_dialog(self: ResultsMixinHost, fmt_key: str) -> None:
628632
"""Show the file save dialog for export."""
629633
from datetime import datetime
630634

635+
from sqlit.domains.results.formatters import FORMATS
631636
from sqlit.shared.ui.screens.file_picker import FilePickerMode, FilePickerScreen
632637

638+
fmt = FORMATS[fmt_key]
633639
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
634-
ext = "csv" if fmt == "csv" else "json"
635-
default_filename = f"results_{timestamp}.{ext}"
640+
default_filename = f"results_{timestamp}.{fmt.extension}"
636641

637642
def handle_result(filename: str | None) -> None:
638643
if filename:
639-
self._save_export_file(filename, fmt)
644+
self._save_export_file(filename, fmt_key)
640645

641646
self.push_screen(
642647
FilePickerScreen(
@@ -647,17 +652,15 @@ def handle_result(filename: str | None) -> None:
647652
handle_result,
648653
)
649654

650-
def _save_export_file(self: ResultsMixinHost, filename: str, fmt: str) -> None:
655+
def _save_export_file(self: ResultsMixinHost, filename: str, fmt_key: str) -> None:
651656
"""Save the export file to disk."""
652657
from pathlib import Path
653658

654-
from sqlit.domains.results.formatters import format_csv, format_json
659+
from sqlit.domains.results.formatters import FORMATS
655660

656661
try:
657-
if fmt == "csv":
658-
content = format_csv(self._last_result_columns, self._last_result_rows)
659-
else:
660-
content = format_json(self._last_result_columns, self._last_result_rows)
662+
fmt = FORMATS[fmt_key]
663+
content = fmt.formatter(self._last_result_columns, self._last_result_rows)
661664

662665
path = Path(filename).expanduser()
663666
path.write_text(content, encoding="utf-8")
@@ -667,6 +670,127 @@ def _save_export_file(self: ResultsMixinHost, filename: str, fmt: str) -> None:
667670
except Exception as e:
668671
self.notify(f"Failed to save: {e}", severity="error")
669672

673+
# ------------------------------------------------------------------
674+
# Copy-as: ryf menu — pick format, then scope (cell/row/all).
675+
# Values list is column-oriented, so ryf v executes directly.
676+
# ------------------------------------------------------------------
677+
678+
def action_ry_format(self: ResultsMixinHost) -> None:
679+
"""Open the 'Copy as…' submenu (format picker)."""
680+
self._clear_leader_pending()
681+
self._start_leader_pending("ryf")
682+
683+
def action_ryf_markdown(self: ResultsMixinHost) -> None:
684+
"""Pick Markdown — open scope submenu."""
685+
self._clear_leader_pending()
686+
self._start_leader_pending("ryfm")
687+
688+
def action_ryf_json(self: ResultsMixinHost) -> None:
689+
"""Pick JSON — open scope submenu."""
690+
self._clear_leader_pending()
691+
self._start_leader_pending("ryfj")
692+
693+
def action_ryf_csv(self: ResultsMixinHost) -> None:
694+
"""Pick CSV — open scope submenu."""
695+
self._clear_leader_pending()
696+
self._start_leader_pending("ryfc")
697+
698+
def action_ryf_values(self: ResultsMixinHost) -> None:
699+
"""Copy the focused column's values as a comma-separated list."""
700+
self._clear_leader_pending()
701+
self._copy_column_values()
702+
703+
def action_ryfm_cell(self: ResultsMixinHost) -> None:
704+
self._copy_scope_as_format("markdown", "cell")
705+
706+
def action_ryfm_row(self: ResultsMixinHost) -> None:
707+
self._copy_scope_as_format("markdown", "row")
708+
709+
def action_ryfm_all(self: ResultsMixinHost) -> None:
710+
self._copy_scope_as_format("markdown", "all")
711+
712+
def action_ryfj_cell(self: ResultsMixinHost) -> None:
713+
self._copy_scope_as_format("json", "cell")
714+
715+
def action_ryfj_row(self: ResultsMixinHost) -> None:
716+
self._copy_scope_as_format("json", "row")
717+
718+
def action_ryfj_all(self: ResultsMixinHost) -> None:
719+
self._copy_scope_as_format("json", "all")
720+
721+
def action_ryfc_cell(self: ResultsMixinHost) -> None:
722+
self._copy_scope_as_format("csv", "cell")
723+
724+
def action_ryfc_row(self: ResultsMixinHost) -> None:
725+
self._copy_scope_as_format("csv", "row")
726+
727+
def action_ryfc_all(self: ResultsMixinHost) -> None:
728+
self._copy_scope_as_format("csv", "all")
729+
730+
def _copy_scope_as_format(
731+
self: ResultsMixinHost, fmt_key: str, scope: str
732+
) -> None:
733+
"""Copy cell/row/all of the active result set as fmt_key."""
734+
from sqlit.domains.results.formatters import FORMATS
735+
736+
self._clear_leader_pending()
737+
table, columns, rows, _stacked = self._get_active_results_context()
738+
if not table or table.row_count <= 0:
739+
self.notify("No results", severity="warning")
740+
return
741+
742+
fmt = FORMATS[fmt_key]
743+
try:
744+
if scope == "cell":
745+
_row_idx, col_idx = table.cursor_coordinate
746+
col_name = columns[col_idx] if 0 <= col_idx < len(columns) else ""
747+
value = _strip_table_markup(
748+
table, table.get_cell_at(table.cursor_coordinate)
749+
)
750+
content = fmt.formatter([col_name], [(value,)])
751+
elif scope == "row":
752+
row_values = [
753+
_strip_table_markup(table, v)
754+
for v in table.get_row_at(table.cursor_row)
755+
]
756+
content = fmt.formatter(columns, [tuple(row_values)])
757+
else: # all
758+
if not columns and not rows:
759+
self.notify("No results", severity="warning")
760+
return
761+
content = fmt.formatter(columns, rows)
762+
except Exception:
763+
return
764+
765+
self._copy_text(content)
766+
self._flash_table_yank(table, scope)
767+
768+
def _copy_column_values(self: ResultsMixinHost) -> None:
769+
"""Copy every value in the focused column as a SQL-ready list."""
770+
from sqlit.domains.results.formatters import format_values_list
771+
772+
table, columns, rows, _stacked = self._get_active_results_context()
773+
if not table or table.row_count <= 0 or not rows:
774+
self.notify("No results", severity="warning")
775+
return
776+
try:
777+
_row_idx, col_idx = table.cursor_coordinate
778+
except Exception:
779+
return
780+
if col_idx < 0 or col_idx >= len(columns):
781+
self.notify("No column selected", severity="warning")
782+
return
783+
784+
values = [
785+
_strip_table_markup(table, row[col_idx])
786+
for row in rows
787+
if col_idx < len(row)
788+
]
789+
text = format_values_list(values)
790+
self._copy_text(text)
791+
self._flash_table_yank(table, "all")
792+
self.notify(f"Copied {len(values)} values from '{columns[col_idx]}'")
793+
670794
def action_results_cursor_left(self: ResultsMixinHost) -> None:
671795
"""Move results cursor left (vim h)."""
672796
table, _columns, _rows, _stacked = self._get_active_results_context()

sqlit/shared/ui/protocols/results.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,15 @@ def _show_export_dialog(self, fmt: str) -> None:
115115
def _save_export_file(self, filename: str, fmt: str) -> None:
116116
...
117117

118+
def _open_export_dialog(self, fmt_key: str) -> None:
119+
...
120+
121+
def _copy_scope_as_format(self, fmt_key: str, scope: str) -> None:
122+
...
123+
124+
def _copy_column_values(self) -> None:
125+
...
126+
118127
def _show_single_result_mode(self) -> None:
119128
...
120129

0 commit comments

Comments
 (0)