Skip to content

Commit 5bd714e

Browse files
committed
v0.0.3: clipboard watcher replaces the Ctrl+C keybinding approach
The keybinding override on Ctrl+C wasn't firing reliably in real sessions — VS Code's terminal keybinding resolution is finicky and the 'terminalTextSelected' context key doesn't always match when users copy via right-click or mouse-select. New approach: watch the clipboard. An interval tick (default 300 ms, paused when the VS Code window is unfocused) reads the current clipboard text and, if the content has newlines and looks wrapped, cleans it and writes the cleaned version back. Works no matter how the user copied (Ctrl+C, right-click, mouse-select-copy, any shell shortcut) because it operates at the clipboard layer, not the keybinding layer. Also: - Status bar now updates on each clipboard tick: 'watching', 'cleaned N clusters', 'no change (N lines)', etc. - Settings: tidyPaste.watchClipboard (default true) and tidyPaste.watchIntervalMs (default 300). - Manual command 'tidy-paste.terminalCopyClean' still works for users who want to trigger explicitly. - Settings changes are picked up live without a reload.
1 parent 0e7c0d2 commit 5bd714e

2 files changed

Lines changed: 113 additions & 43 deletions

File tree

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "tidy-paste",
33
"displayName": "Tidy Paste",
44
"description": "Cleans terminal-wrapped text on copy from VS Code's integrated terminal so pasted content arrives without stray line breaks or trailing whitespace.",
5-
"version": "0.0.2",
5+
"version": "0.0.3",
66
"publisher": "miller-joe",
77
"license": "MIT",
88
"repository": {
@@ -62,6 +62,16 @@
6262
"default": true,
6363
"description": "Master switch. When off, terminal copy behaves as default."
6464
},
65+
"tidyPaste.watchClipboard": {
66+
"type": "boolean",
67+
"default": true,
68+
"description": "Watch the clipboard for changes and clean wrapped content automatically. Works regardless of how you copied (Ctrl+C, right-click, mouse-select). Turn off if you only want the manual command."
69+
},
70+
"tidyPaste.watchIntervalMs": {
71+
"type": "number",
72+
"default": 300,
73+
"description": "How often to check the clipboard for changes, in milliseconds. Lower = more responsive but uses slightly more CPU."
74+
},
6575
"tidyPaste.appWrapColumn": {
6676
"type": "number",
6777
"default": 80,

src/extension.ts

Lines changed: 102 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { cleanCopiedText } from "./cleaner.js";
33

44
let output: vscode.OutputChannel | undefined;
55
let statusBar: vscode.StatusBarItem | undefined;
6+
let watcherInterval: NodeJS.Timeout | undefined;
7+
let lastProcessed = "";
8+
let windowFocused = true;
69

710
export function activate(context: vscode.ExtensionContext): void {
811
output = vscode.window.createOutputChannel("Tidy Paste");
@@ -14,7 +17,7 @@ export function activate(context: vscode.ExtensionContext): void {
1417
);
1518
statusBar.text = "$(clippy) Tidy Paste: idle";
1619
statusBar.tooltip =
17-
"Tidy Paste status. Updates on each terminal copy. Click to open the Output panel.";
20+
"Tidy Paste status. Updates on each clipboard change. Click to open the Output panel.";
1821
statusBar.command = "tidy-paste.showOutput";
1922
statusBar.show();
2023
context.subscriptions.push(statusBar);
@@ -38,9 +41,28 @@ export function activate(context: vscode.ExtensionContext): void {
3841
output?.show(true);
3942
}),
4043
);
44+
45+
context.subscriptions.push(
46+
vscode.window.onDidChangeWindowState((state) => {
47+
windowFocused = state.focused;
48+
}),
49+
);
50+
51+
startClipboardWatcher(context);
52+
53+
// Re-read settings on change to pick up watcher toggling without reload.
54+
context.subscriptions.push(
55+
vscode.workspace.onDidChangeConfiguration((e) => {
56+
if (e.affectsConfiguration("tidyPaste")) {
57+
stopClipboardWatcher();
58+
startClipboardWatcher(context);
59+
}
60+
}),
61+
);
4162
}
4263

4364
export function deactivate(): void {
65+
stopClipboardWatcher();
4466
statusBar?.dispose();
4567
output?.dispose();
4668
}
@@ -51,62 +73,100 @@ function setStatus(text: string): void {
5173
}
5274
}
5375

54-
async function terminalCopyClean(): Promise<void> {
76+
function startClipboardWatcher(context: vscode.ExtensionContext): void {
5577
const config = vscode.workspace.getConfiguration("tidyPaste");
56-
if (config.get<boolean>("enabled") === false) {
57-
await vscode.commands.executeCommand("workbench.action.terminal.copySelection");
78+
if (!config.get<boolean>("watchClipboard", true)) {
79+
setStatus("watcher off");
80+
return;
81+
}
82+
if (!config.get<boolean>("enabled", true)) {
5883
setStatus("disabled");
5984
return;
6085
}
86+
const intervalMs = Math.max(100, config.get<number>("watchIntervalMs") ?? 300);
6187

62-
await vscode.commands.executeCommand("workbench.action.terminal.copySelection");
88+
watcherInterval = setInterval(() => {
89+
if (!windowFocused) return; // cheap bail-out when VS Code is backgrounded
90+
void tickClipboardWatcher();
91+
}, intervalMs);
6392

64-
const raw = await vscode.env.clipboard.readText();
65-
if (!raw) {
66-
setStatus("empty clipboard");
67-
return;
68-
}
69-
if (!raw.includes("\n")) {
70-
setStatus("single line, no change");
71-
return;
93+
context.subscriptions.push({ dispose: stopClipboardWatcher });
94+
setStatus("watching");
95+
}
96+
97+
function stopClipboardWatcher(): void {
98+
if (watcherInterval) {
99+
clearInterval(watcherInterval);
100+
watcherInterval = undefined;
72101
}
102+
}
73103

74-
const terminalColumns = getActiveTerminalColumns();
104+
async function tickClipboardWatcher(): Promise<void> {
105+
try {
106+
const current = await vscode.env.clipboard.readText();
107+
if (!current || current === lastProcessed) return;
75108

76-
const result = cleanCopiedText(raw, {
77-
terminalColumns,
78-
appWrapColumn: config.get<number>("appWrapColumn") ?? 80,
79-
minWrapLines: config.get<number>("minWrapLines") ?? 2,
80-
stripTrailingWhitespace: config.get<boolean>("stripTrailingWhitespace") ?? true,
81-
preserveCodeFences: config.get<boolean>("preserveCodeFences") ?? true,
82-
});
109+
// Quick bail-outs before the heavier cleanup.
110+
if (!current.includes("\n")) {
111+
lastProcessed = current;
112+
return;
113+
}
83114

84-
if (config.get<boolean>("debug")) {
85-
output?.appendLine("------");
86-
output?.appendLine(
87-
`[copy] terminalColumns=${terminalColumns} appWrap=${config.get<number>("appWrapColumn") ?? 80} changed=${result.changed} rawLines=${raw.split("\n").length}`,
88-
);
89-
for (const n of result.notes) output?.appendLine(` ${n}`);
90-
if (!result.changed && raw.split("\n").length > 1) {
91-
// Show the first few lines with their lengths so the user can see why the
92-
// heuristic didn't fire.
93-
const sample = raw.split("\n").slice(0, 6);
94-
output?.appendLine(" [nothing matched] first lines:");
95-
for (let i = 0; i < sample.length; i++) {
96-
const line = sample[i]!;
97-
const end = line.length > 0 ? JSON.stringify(line[line.length - 1]!) : "(empty)";
98-
output?.appendLine(` ${i}: len=${line.length} lastChar=${end}`);
115+
const config = vscode.workspace.getConfiguration("tidyPaste");
116+
if (!config.get<boolean>("enabled", true)) return;
117+
118+
const terminalColumns = getActiveTerminalColumns();
119+
const result = cleanCopiedText(current, {
120+
terminalColumns,
121+
appWrapColumn: config.get<number>("appWrapColumn") ?? 80,
122+
minWrapLines: config.get<number>("minWrapLines") ?? 2,
123+
stripTrailingWhitespace: config.get<boolean>("stripTrailingWhitespace") ?? true,
124+
preserveCodeFences: config.get<boolean>("preserveCodeFences") ?? true,
125+
});
126+
127+
if (config.get<boolean>("debug", false)) {
128+
output?.appendLine("------");
129+
output?.appendLine(
130+
`[watch] terminalColumns=${terminalColumns} changed=${result.changed} rawLines=${current.split("\n").length}`,
131+
);
132+
for (const n of result.notes) output?.appendLine(` ${n}`);
133+
if (!result.changed && current.split("\n").length > 1) {
134+
const sample = current.split("\n").slice(0, 6);
135+
output?.appendLine(" [nothing matched] first lines:");
136+
for (let i = 0; i < sample.length; i++) {
137+
const line = sample[i]!;
138+
const end =
139+
line.length > 0 ? JSON.stringify(line[line.length - 1]!) : "(empty)";
140+
output?.appendLine(` ${i}: len=${line.length} lastChar=${end}`);
141+
}
99142
}
100143
}
144+
145+
if (result.changed) {
146+
await vscode.env.clipboard.writeText(result.text);
147+
lastProcessed = result.text; // don't re-process our own write
148+
setStatus(`cleaned ${result.notes.length} cluster(s)`);
149+
} else {
150+
lastProcessed = current;
151+
setStatus(`no change (${current.split("\n").length} lines)`);
152+
}
153+
} catch {
154+
// Silent; clipboard access can fail mid-transition.
101155
}
156+
}
102157

103-
if (result.changed) {
104-
await vscode.env.clipboard.writeText(result.text);
105-
const joined = result.notes.length;
106-
setStatus(joined > 0 ? `joined ${joined} cluster(s)` : "cleaned whitespace");
107-
} else {
108-
setStatus("no change");
158+
async function terminalCopyClean(): Promise<void> {
159+
const config = vscode.workspace.getConfiguration("tidyPaste");
160+
if (config.get<boolean>("enabled") === false) {
161+
await vscode.commands.executeCommand("workbench.action.terminal.copySelection");
162+
setStatus("disabled");
163+
return;
109164
}
165+
166+
await vscode.commands.executeCommand("workbench.action.terminal.copySelection");
167+
// The watcher will pick it up on its next tick. Kick a manual tick now
168+
// so the user sees instant feedback if invoked from a keybinding.
169+
await tickClipboardWatcher();
110170
}
111171

112172
async function cleanEditorSelection(): Promise<void> {

0 commit comments

Comments
 (0)