Skip to content

Commit eb286c8

Browse files
authored
Merge pull request #193 from Maxteabag/worktree-issue-188-clipboard
2 parents 4cdaeda + 1cf0658 commit eb286c8

7 files changed

Lines changed: 310 additions & 36 deletions

File tree

sqlit/domains/query/ui/mixins/query_editing_clipboard.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,15 @@ def action_copy_selection(self: QueryMixinHost) -> None:
7171

7272
def action_paste(self: QueryMixinHost) -> None:
7373
"""Paste text from clipboard (CTRL+V)."""
74+
clipboard = self._get_clipboard_text()
75+
self._paste_text(clipboard)
76+
77+
def _paste_text(self: QueryMixinHost, clipboard: str) -> None:
78+
"""Paste provided text at the query cursor."""
7479
from textual.widgets.text_area import Selection
7580

7681
from sqlit.domains.query.editing import paste_text
7782

78-
clipboard = self._get_clipboard_text()
7983
if not clipboard:
8084
return
8185

@@ -104,10 +108,13 @@ def action_paste(self: QueryMixinHost) -> None:
104108
cursor = self.query_input.cursor_location
105109
self.query_input.selection = Selection(cursor, cursor)
106110

107-
def _get_clipboard_text(self: QueryMixinHost) -> str:
111+
def _get_clipboard_text(self: QueryMixinHost, prefer_internal: bool = False) -> str:
108112
"""Get text from system clipboard."""
109-
try:
110-
import pyperclip # pyright: ignore[reportMissingModuleSource]
111-
return pyperclip.paste() or ""
112-
except Exception:
113-
return ""
113+
from sqlit.shared.ui.clipboard import get_system_clipboard_text
114+
115+
internal = getattr(self, "_internal_clipboard", "") or ""
116+
117+
if prefer_internal and internal:
118+
return internal
119+
120+
return get_system_clipboard_text() or internal

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

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import sys
65
from typing import Any
76

87
from sqlit.shared.ui.protocols import ResultsMixinHost
@@ -112,9 +111,9 @@ async def work_async() -> None:
112111
def _normalize_column_name(self: ResultsMixinHost, name: str) -> str:
113112
trimmed = name.strip()
114113
if len(trimmed) >= 2:
115-
if trimmed[0] == trimmed[-1] and trimmed[0] in ("\"", "`"):
116-
trimmed = trimmed[1:-1]
117-
elif trimmed[0] == "[" and trimmed[-1] == "]":
114+
if (trimmed[0] == trimmed[-1] and trimmed[0] in ("\"", "`")) or (
115+
trimmed[0] == "[" and trimmed[-1] == "]"
116+
):
118117
trimmed = trimmed[1:-1]
119118
if "." in trimmed and not any(q in trimmed for q in ("\"", "`", "[")):
120119
trimmed = trimmed.split(".")[-1]
@@ -139,33 +138,20 @@ def _get_active_results_table_info(
139138

140139
def _copy_text(self: ResultsMixinHost, text: str) -> bool:
141140
"""Copy text to clipboard if possible, otherwise store internally."""
142-
self._internal_clipboard = text
141+
from sqlit.shared.ui.clipboard import copy_to_system_clipboard
143142

144-
if sys.platform == "darwin":
145-
# Prefer pyperclip on macOS; Textual's copy_to_clipboard can no-op.
146-
try:
147-
import pyperclip # type: ignore
143+
self._internal_clipboard = text
148144

149-
pyperclip.copy(text)
150-
return True
151-
except Exception:
152-
pass
145+
system_copied = copy_to_system_clipboard(text)
153146

154-
# Prefer Textual's clipboard support (OSC52 where available).
147+
# Textual uses OSC52. It helps in terminals that support local clipboard
148+
# writes, including some remote sessions where OS commands target the
149+
# wrong machine.
155150
try:
156151
self.copy_to_clipboard(text)
157152
return True
158153
except Exception:
159-
pass
160-
161-
# Fallback to system clipboard via pyperclip (requires platform support).
162-
try:
163-
import pyperclip # type: ignore
164-
165-
pyperclip.copy(text)
166-
return True
167-
except Exception:
168-
return False
154+
return system_copied
169155

170156
def _get_active_results_context(
171157
self: ResultsMixinHost,

sqlit/shared/ui/clipboard.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""System clipboard helpers.
2+
3+
Terminal clipboard access is platform and terminal dependent. These helpers try
4+
native OS commands first, then pyperclip, and leave terminal-mediated OSC52 to
5+
the caller because Textual owns the active driver.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import shutil
12+
import subprocess
13+
import sys
14+
from importlib import import_module
15+
from typing import Any
16+
17+
CLIPBOARD_TIMEOUT_S = 1.5
18+
19+
20+
def get_system_clipboard_text() -> str:
21+
"""Return text from the system clipboard, or an empty string if unavailable."""
22+
text = _get_with_native_command()
23+
if text is not None:
24+
return text
25+
26+
try:
27+
pyperclip = _load_pyperclip()
28+
return pyperclip.paste() or ""
29+
except Exception:
30+
return ""
31+
32+
33+
def copy_to_system_clipboard(text: str) -> bool:
34+
"""Copy text to the system clipboard using the best available backend."""
35+
if _copy_with_native_command(text):
36+
return True
37+
38+
try:
39+
pyperclip = _load_pyperclip()
40+
pyperclip.copy(text)
41+
return True
42+
except Exception:
43+
return False
44+
45+
46+
def _load_pyperclip() -> Any:
47+
return import_module("pyperclip")
48+
49+
50+
def _get_with_native_command() -> str | None:
51+
if sys.platform == "darwin":
52+
return _run_text_output_command(["pbpaste"])
53+
54+
if sys.platform == "win32":
55+
command = _powershell_command()
56+
if command:
57+
return _run_text_output_command(command + ["Get-Clipboard", "-Raw"])
58+
return None
59+
60+
for command in _linux_paste_commands():
61+
text = _run_text_output_command(command)
62+
if text is not None:
63+
return text
64+
65+
return None
66+
67+
68+
def _copy_with_native_command(text: str) -> bool:
69+
if sys.platform == "darwin":
70+
return _run_text_input_command(["pbcopy"], text)
71+
72+
if sys.platform == "win32":
73+
command = _powershell_command()
74+
if command and _run_text_input_command(command + ["Set-Clipboard -Value ([Console]::In.ReadToEnd())"], text):
75+
return True
76+
return _run_text_input_command(["clip.exe"], text)
77+
78+
return any(_run_text_input_command(command, text) for command in _linux_copy_commands())
79+
80+
81+
def _linux_paste_commands() -> list[list[str]]:
82+
commands: list[list[str]] = []
83+
if os.environ.get("WAYLAND_DISPLAY"):
84+
commands.append(["wl-paste", "--type", "text"])
85+
if os.environ.get("DISPLAY"):
86+
commands.extend(
87+
[
88+
["xclip", "-selection", "clipboard", "-out"],
89+
["xsel", "--clipboard", "--output"],
90+
]
91+
)
92+
if not commands:
93+
commands.extend(
94+
[
95+
["wl-paste", "--type", "text"],
96+
["xclip", "-selection", "clipboard", "-out"],
97+
["xsel", "--clipboard", "--output"],
98+
]
99+
)
100+
return commands
101+
102+
103+
def _linux_copy_commands() -> list[list[str]]:
104+
commands: list[list[str]] = []
105+
if os.environ.get("WAYLAND_DISPLAY"):
106+
commands.append(["wl-copy", "--type", "text/plain"])
107+
if os.environ.get("DISPLAY"):
108+
commands.extend(
109+
[
110+
["xclip", "-selection", "clipboard"],
111+
["xsel", "--clipboard", "--input"],
112+
]
113+
)
114+
if not commands:
115+
commands.extend(
116+
[
117+
["wl-copy", "--type", "text/plain"],
118+
["xclip", "-selection", "clipboard"],
119+
["xsel", "--clipboard", "--input"],
120+
]
121+
)
122+
return commands
123+
124+
125+
def _powershell_command() -> list[str] | None:
126+
for executable in ("pwsh", "powershell.exe", "powershell"):
127+
if shutil.which(executable):
128+
return [executable, "-NoProfile", "-NonInteractive", "-Command"]
129+
return None
130+
131+
132+
def _run_text_output_command(command: list[str]) -> str | None:
133+
if not shutil.which(command[0]):
134+
return None
135+
try:
136+
completed = subprocess.run(
137+
command,
138+
capture_output=True,
139+
check=False,
140+
text=True,
141+
timeout=CLIPBOARD_TIMEOUT_S,
142+
)
143+
except Exception:
144+
return None
145+
if completed.returncode != 0:
146+
return None
147+
return completed.stdout
148+
149+
150+
def _run_text_input_command(command: list[str], text: str) -> bool:
151+
if not shutil.which(command[0]):
152+
return False
153+
try:
154+
completed = subprocess.run(
155+
command,
156+
check=False,
157+
input=text,
158+
stdout=subprocess.DEVNULL,
159+
stderr=subprocess.DEVNULL,
160+
text=True,
161+
timeout=CLIPBOARD_TIMEOUT_S,
162+
)
163+
except Exception:
164+
return False
165+
return completed.returncode == 0

sqlit/shared/ui/protocols/query.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ def _show_char_pending_menu(self, motion: str) -> None:
120120
def _show_text_object_menu(self, mode: str) -> None:
121121
...
122122

123-
def _get_clipboard_text(self) -> str:
123+
def _get_clipboard_text(self, prefer_internal: bool = False) -> str:
124+
...
125+
126+
def _paste_text(self, clipboard: str) -> None:
124127
...
125128

126129
def _get_undo_history(self) -> Any:

sqlit/shared/ui/widgets_text_area.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
from rich.segment import Segment
88
from textual.color import Color
9-
from textual.events import Key
9+
from textual.events import Key, Paste
1010
from textual.strip import Strip
1111
from textual.widgets import TextArea
12+
from textual.widgets.text_area import Selection
1213

1314
if TYPE_CHECKING:
1415
from sqlit.shared.ui.protocols import AutocompleteProtocol
@@ -117,7 +118,7 @@ def sync_terminal_cursor(self) -> None:
117118
self._sync_terminal_cursor()
118119

119120
@property
120-
def _draw_cursor(self) -> bool: # type: ignore[override]
121+
def _draw_cursor(self) -> bool:
121122
if self._should_use_terminal_cursor():
122123
return False
123124
return super()._draw_cursor
@@ -200,6 +201,23 @@ async def _on_key(self, event: Key) -> None:
200201
# For all other keys, use default TextArea behavior
201202
await super()._on_key(event)
202203

204+
async def _on_paste(self, event: Paste) -> None:
205+
"""Handle terminal-delivered paste text explicitly."""
206+
if not self._is_insert_mode():
207+
event.prevent_default()
208+
event.stop()
209+
return
210+
211+
self._push_undo_if_changed()
212+
paste_text = getattr(self.app, "_paste_text", None)
213+
if callable(paste_text):
214+
paste_text(event.text)
215+
event.prevent_default()
216+
event.stop()
217+
return
218+
219+
await super()._on_paste(event)
220+
203221
def _is_visual_mode(self) -> bool:
204222
"""Check if app is in any vim visual mode."""
205223
from sqlit.core.vim import VimMode
@@ -266,9 +284,9 @@ def relative_line_numbers(self, value: bool) -> None:
266284
self._line_cache.clear()
267285
self.refresh()
268286

269-
def _watch_selection(self, previous_selection: object, selection: object) -> None:
287+
def _watch_selection(self, previous_selection: Selection, selection: Selection) -> None:
270288
"""Clear line cache when cursor row changes (for relative line numbers)."""
271-
super()._watch_selection(previous_selection, selection) # type: ignore[arg-type]
289+
super()._watch_selection(previous_selection, selection)
272290
if self._relative_line_numbers and self.show_line_numbers:
273291
# Get current cursor row
274292
cursor_row = self.selection.end[0]

tests/ui/keybindings/test_visual_mode.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,51 @@ async def test_visual_yank(self) -> None:
314314
# Text should be unchanged
315315
assert app.query_input.text == "hello world"
316316

317+
@pytest.mark.asyncio
318+
async def test_normal_p_uses_internal_clipboard_when_system_clipboard_unavailable(
319+
self, monkeypatch: pytest.MonkeyPatch
320+
) -> None:
321+
app = _make_app()
322+
323+
monkeypatch.setattr("sqlit.shared.ui.clipboard.get_system_clipboard_text", lambda: "")
324+
325+
async with app.run_test(size=(100, 35)) as pilot:
326+
app.action_focus_query()
327+
await pilot.pause()
328+
329+
app.query_input.text = "SELECT * FROM users"
330+
app.query_input.cursor_location = (0, len(app.query_input.text))
331+
app._internal_clipboard = " WHERE id = 1"
332+
await pilot.pause()
333+
334+
await pilot.press("p")
335+
await pilot.pause()
336+
337+
assert app.query_input.text == "SELECT * FROM users WHERE id = 1"
338+
339+
@pytest.mark.asyncio
340+
async def test_normal_p_prefers_system_clipboard(self, monkeypatch: pytest.MonkeyPatch) -> None:
341+
app = _make_app()
342+
343+
monkeypatch.setattr(
344+
"sqlit.shared.ui.clipboard.get_system_clipboard_text",
345+
lambda: " WHERE active = true",
346+
)
347+
348+
async with app.run_test(size=(100, 35)) as pilot:
349+
app.action_focus_query()
350+
await pilot.pause()
351+
352+
app.query_input.text = "SELECT * FROM users"
353+
app.query_input.cursor_location = (0, len(app.query_input.text))
354+
app._internal_clipboard = " WHERE id = 1"
355+
await pilot.pause()
356+
357+
await pilot.press("p")
358+
await pilot.pause()
359+
360+
assert app.query_input.text == "SELECT * FROM users WHERE active = true"
361+
317362
@pytest.mark.asyncio
318363
async def test_visual_delete(self) -> None:
319364
app = _make_app()

0 commit comments

Comments
 (0)