v1.8.58: results context menu — Copy Cell/Row/All As 16 formats

Right-click on results table:
- Copy Cell — single cell value
- Copy Row — tab-delimited row
- Copy Row As → 16 formats submenu
- Copy All As → 16 formats submenu

Formats: Excel CSV, Tab-delimited, HTML, XML, SQL INSERTs,
SQL INSERT IGNOREs, SQL REPLACEs, SQL DELETE/INSERTs,
SQL UPDATEs, LaTeX, Textile, Jira, PHP Array, Markdown,
JSON, JSON Lines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-25 05:06:54 -05:00
parent 4a6464ede9
commit ec4ad28c29
4 changed files with 264 additions and 1 deletions

View File

@@ -340,6 +340,11 @@ _EN = {
"tree_no_tables": "(no tables)",
"tree_no_columns": "(no columns)",
"tree_connected": "Connected",
# Results context menu
"res_copy_cell": "Copy Cell",
"res_copy_row": "Copy Row",
"res_copy_row_as": "Copy Row As",
"res_copy_all_as": "Copy All As",
"query_exported": "Exported to {path}",
# Redis tab
@@ -766,6 +771,11 @@ _RU = {
"tree_no_tables": "(нет таблиц)",
"tree_no_columns": "(нет колонок)",
"tree_connected": "Подключено",
# Results context menu
"res_copy_cell": "Копировать ячейку",
"res_copy_row": "Копировать строку",
"res_copy_row_as": "Копировать строку как",
"res_copy_all_as": "Копировать всё как",
# Redis tab
"redis_clear": "Очистить",
@@ -1191,6 +1201,11 @@ _ZH = {
"tree_no_tables": "(无表)",
"tree_no_columns": "(无列)",
"tree_connected": "已连接",
# Results context menu
"res_copy_cell": "复制单元格",
"res_copy_row": "复制行",
"res_copy_row_as": "复制行为",
"res_copy_all_as": "全部复制为",
# Redis tab
"redis_clear": "清除",

View File

@@ -3,6 +3,9 @@ Query tab — SQL database interaction with tree explorer, editor, results grid,
"""
import csv
import io
import json
import re
import time
import threading
import tkinter as tk
@@ -77,6 +80,25 @@ def _apply_db_tree_theme():
class QueryTab(ctk.CTkFrame):
_FORMAT_OPTIONS = [
("Excel CSV", "csv"),
("Delimited Text (Tab)", "tsv"),
("HTML Table", "html"),
("XML", "xml"),
("SQL INSERTs", "sql_insert"),
("SQL INSERT IGNOREs", "sql_insert_ignore"),
("SQL REPLACEs", "sql_replace"),
("SQL DELETE/INSERTs", "sql_delete_insert"),
("SQL UPDATEs", "sql_update"),
("LaTeX", "latex"),
("Textile", "textile"),
("Jira Textile", "jira"),
("PHP Array", "php"),
("Markdown", "markdown"),
("JSON", "json"),
("JSON Lines", "jsonl"),
]
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self._current_alias: str | None = None
@@ -216,6 +238,7 @@ class QueryTab(ctk.CTkFrame):
yscrollcommand=self._res_yscroll.set,
)
self._results_tree.pack(fill="both", expand=True)
self._results_tree.bind("<Button-3>", self._on_results_rightclick)
self._res_xscroll.config(command=self._results_tree.xview)
self._res_yscroll.config(command=self._results_tree.yview)
@@ -586,6 +609,231 @@ class QueryTab(ctk.CTkFrame):
def _insert_text(self, text: str):
self._editor.insert("insert", text)
# ── Results context menu ────────────────────────────────────────
def _on_results_rightclick(self, event):
"""Context menu on results Treeview — copy cell/row/all in 16 formats."""
if not self._columns or not self._results:
return
row_iid = self._results_tree.identify_row(event.y)
col_id = self._results_tree.identify_column(event.x)
if row_iid:
self._results_tree.selection_set(row_iid)
row_values = None
if row_iid:
row_values = list(self._results_tree.item(row_iid, "values"))
cell_value = None
if row_values and col_id:
try:
col_index = int(col_id.replace("#", "")) - 1
if 0 <= col_index < len(row_values):
cell_value = row_values[col_index]
except (ValueError, IndexError):
pass
table_name = self._extract_table_name()
_menu_kw = dict(tearoff=0, bg="#2b2b2b", fg="#dcdcdc",
activebackground="#3b82f6", activeforeground="#ffffff",
font=("Segoe UI", 10))
menu = tk.Menu(self, **_menu_kw)
if cell_value is not None:
menu.add_command(
label=t("res_copy_cell"),
command=lambda: self._copy_to_clipboard(str(cell_value)),
)
if row_values is not None:
menu.add_command(
label=t("res_copy_row"),
command=lambda: self._copy_to_clipboard(
"\t".join(str(v) for v in row_values)),
)
if cell_value is not None or row_values is not None:
menu.add_separator()
if row_values is not None:
row_sub = tk.Menu(menu, **_menu_kw)
for label, fmt_key in self._FORMAT_OPTIONS:
row_sub.add_command(
label=label,
command=lambda fk=fmt_key, rv=row_values: self._copy_to_clipboard(
self._format_data(fk, self._columns, [rv], table_name)),
)
menu.add_cascade(label=t("res_copy_row_as"), menu=row_sub)
all_sub = tk.Menu(menu, **_menu_kw)
all_rows = [list(self._results_tree.item(iid, "values"))
for iid in self._results_tree.get_children()]
for label, fmt_key in self._FORMAT_OPTIONS:
all_sub.add_command(
label=label,
command=lambda fk=fmt_key, ar=all_rows: self._copy_to_clipboard(
self._format_data(fk, self._columns, ar, table_name)),
)
menu.add_cascade(label=t("res_copy_all_as"), menu=all_sub)
menu.tk_popup(event.x_root, event.y_root)
def _extract_table_name(self) -> str:
sql = self._editor.get("0.0", "end").strip()
m = re.search(r'\bFROM\s+`?(\w+)`?', sql, re.IGNORECASE)
return m.group(1) if m else "table"
# ── Format converters ──────────────────────────────────────────
def _format_data(self, fmt: str, columns: list[str],
rows: list[list], table: str) -> str:
formatter = getattr(self, f"_fmt_{fmt}", None)
if formatter:
return formatter(columns, rows, table)
return "\t".join(columns) + "\n" + "\n".join(
"\t".join(str(v) for v in r) for r in rows)
def _fmt_csv(self, cols, rows, _t):
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(cols)
for r in rows:
w.writerow(r)
return buf.getvalue()
def _fmt_tsv(self, cols, rows, _t):
lines = ["\t".join(cols)]
for r in rows:
lines.append("\t".join(str(v) for v in r))
return "\n".join(lines)
def _fmt_html(self, cols, rows, _t):
h = "<table>\n<tr>" + "".join(f"<th>{c}</th>" for c in cols) + "</tr>\n"
for r in rows:
h += "<tr>" + "".join(f"<td>{v}</td>" for v in r) + "</tr>\n"
h += "</table>"
return h
def _fmt_xml(self, cols, rows, _t):
lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<results>"]
for r in rows:
lines.append(" <row>")
for c, v in zip(cols, r):
tag = re.sub(r'[^a-zA-Z0-9_]', '_', c)
lines.append(f" <{tag}>{v}</{tag}>")
lines.append(" </row>")
lines.append("</results>")
return "\n".join(lines)
def _fmt_sql_insert(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"INSERT INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_insert_ignore(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"INSERT IGNORE INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_replace(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"REPLACE INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_delete_insert(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
lines = []
for r in rows:
where = f"`{cols[0]}` = {self._sql_val(r[0])}"
lines.append(f"DELETE FROM `{tbl}` WHERE {where};")
lines.append(f"INSERT INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});")
return "\n".join(lines)
def _fmt_sql_update(self, cols, rows, tbl):
lines = []
for r in rows:
sets = ", ".join(f"`{c}` = {self._sql_val(v)}" for c, v in zip(cols[1:], r[1:]))
where = f"`{cols[0]}` = {self._sql_val(r[0])}"
lines.append(f"UPDATE `{tbl}` SET {sets} WHERE {where};")
return "\n".join(lines)
@staticmethod
def _sql_val(v) -> str:
if v is None or str(v) == "NULL":
return "NULL"
s = str(v)
try:
float(s)
return s
except ValueError:
return "'" + s.replace("'", "''") + "'"
def _fmt_latex(self, cols, rows, _t):
align = "|".join("l" * len(cols))
lines = [f"\\begin{{tabular}}{{|{align}|}}", "\\hline"]
lines.append(" & ".join(cols) + " \\\\")
lines.append("\\hline")
for r in rows:
lines.append(" & ".join(str(v) for v in r) + " \\\\")
lines.append("\\hline")
lines.append("\\end{tabular}")
return "\n".join(lines)
def _fmt_textile(self, cols, rows, _t):
lines = ["|_. " + " |_. ".join(cols) + " |"]
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_jira(self, cols, rows, _t):
lines = ["|| " + " || ".join(cols) + " ||"]
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_php(self, cols, rows, _t):
lines = ["["]
for r in rows:
pairs = ", ".join(f"'{c}' => {self._php_val(v)}" for c, v in zip(cols, r))
lines.append(f" [{pairs}],")
lines.append("]")
return "\n".join(lines)
@staticmethod
def _php_val(v) -> str:
if v is None or str(v) == "NULL":
return "null"
s = str(v)
try:
float(s)
return s
except ValueError:
return "'" + s.replace("'", "\\'") + "'"
def _fmt_markdown(self, cols, rows, _t):
lines = ["| " + " | ".join(cols) + " |"]
lines.append("| " + " | ".join("---" for _ in cols) + " |")
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_json(self, cols, rows, _t):
data = [dict(zip(cols, [str(v) for v in r])) for r in rows]
return json.dumps(data, indent=2, ensure_ascii=False)
def _fmt_jsonl(self, cols, rows, _t):
lines = []
for r in rows:
obj = dict(zip(cols, [str(v) for v in r]))
lines.append(json.dumps(obj, ensure_ascii=False))
return "\n".join(lines)
# ── Query execution ────────────────────────────────────────────
def _execute_query(self):

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.57"
__version__ = "1.8.58"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"