Skip to content

Commit 1cf0658

Browse files
committed
Make query paste use reachable system clipboards
Clipboard access varies across terminal, OS, and display-server combinations, so query paste now reads from native OS commands before falling back to pyperclip and sqlit's internal buffer. Copy writes to native clipboard helpers and still attempts Textual OSC52 so local and remote terminal paths both get a chance to work. Constraint: Terminal apps cannot force clipboard access when the OS session or terminal blocks every exposed backend Rejected: Depend only on pyperclip | misses Linux installs without xclip/xsel/wl-clipboard and some terminal-delivered paste flows Confidence: medium Scope-risk: moderate Directive: Keep internal clipboard as a fallback; it is what makes vim y/p reliable when the system clipboard is unavailable Tested: uv run pytest tests/unit/test_clipboard.py tests/ui/keybindings/test_visual_mode.py -q Tested: uv run pytest tests/ui/keybindings -q Tested: uv run ruff check changed clipboard/query/keybinding files Tested: uv run mypy sqlit/shared/ui/clipboard.py sqlit/shared/ui/widgets_text_area.py sqlit/domains/query/ui/mixins/query_editing_clipboard.py sqlit/shared/ui/protocols/query.py Not-tested: Manual paste behavior on macOS, Windows, Wayland, X11, SSH, and terminals that block OSC52
1 parent a132802 commit 1cf0658

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)