Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions graphcode-windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,16 @@ it. Copy puts exactly the selected text on the clipboard: soft-wrapped rows join
into one line and trailing blanks are dropped. Plain
`Ctrl+V` stays terminal input and plain `Ctrl+C` stays the interrupt, except
that `Ctrl+C` copies while the terminal has a selection. Paste reads only
`CF_UNICODETEXT`, normalises line endings, blanks unsafe control bytes, and
`CF_UNICODETEXT`, normalises line endings, blanks the control bytes xterm blanks
(NUL, BS, ESC, DEL and the tty's special characters; BEL and tab pass through) plus
the C1 controls U+0080 to U+009F (8-bit CSI and friends), and
wraps the text in bracketed-paste markers only when the running program asked
for them (DECSET 2004); multi-line text for a program that did not ask asks for
for them (DECSET 2004); text over 1,000,000 UTF-8 bytes is refused. A copy that the
Windows clipboard refuses puts the previous Unicode text back and says whether
that worked; if the existing Unicode text cannot be copied safely the copy is
refused before the clipboard is touched. Only the Unicode text is protected:
emptying the clipboard removes every other format (an image, HTML, RTF), which a
copy replaces and a failed copy does not restore. Multi-line text for a program that did not ask asks for
confirmation first instead of running each line as it is pasted. That includes
`pwsh` and `cmd.exe`: neither asks for bracketed paste under ConPTY, and each
runs the pasted lines as they arrive. macOS auto-confirms that case without a
Expand Down
129 changes: 126 additions & 3 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,27 @@ fn terminalPasteFailureStatus(err: anyerror) []const u8 {
if (err == error.TerminalPasteRequiresConfirmation) {
return "Paste cancelled: multi-line text was not sent to the terminal";
}
if (err == error.TerminalPasteTooLarge or err == error.ClipboardTextTooLarge) {
return "Paste cancelled: clipboard text is larger than 1,000,000 bytes";
}
if (err == error.TerminalClipboardUnavailable) return "Terminal clipboard is unavailable";
if (err == error.TerminalSurfaceUnavailable) return "No terminal is ready to paste into";
return "Unable to paste clipboard text";
}

/// What the status line says when the Windows clipboard refused a copy. The user's previous
/// clipboard text is restored when the write fails, and the message says whether it was.
fn terminalCopyFailureStatus(err: anyerror) []const u8 {
if (err == error.ClipboardWriteFailedKeptPreviousText)
return "Unable to copy: the clipboard refused the text; its previous text was kept";
if (err == error.ClipboardWriteFailedLostPreviousText)
return "Unable to copy: the clipboard refused the text and its previous text could not be restored";
if (err == error.ClipboardOpenFailed) return "Unable to copy: the clipboard is in use by another program";
if (err == error.ClipboardPreviousTextUnreadable)
return "Unable to copy: the clipboard's current text could not be read, so it was left unchanged";
return "Unable to copy terminal selection";
}

fn pasteLineCount(text: []const u8) usize {
var lines: usize = 1;
var index: usize = 0;
Expand All @@ -296,7 +312,7 @@ const TerminalCopyApi = struct {

const TerminalPasteApi = struct {
fn readClipboard(owner: c.HWND, allocator: std.mem.Allocator) ![]u8 {
return Clipboard.readText(owner, allocator);
return Clipboard.readText(owner, allocator, @import("TerminalVt.zig").max_paste_bytes);
}

fn confirmMultiline(owner: c.HWND, lines: usize) bool {
Expand Down Expand Up @@ -6536,7 +6552,7 @@ pub const App = struct {
defer self.allocator.free(selection);
Api.writeClipboard(self.window.hwnd, self.allocator, selection) catch |err| {
std.log.warn("Unable to write terminal selection to Windows clipboard: {s}", .{@errorName(err)});
self.setStatus("Unable to copy terminal selection");
self.setStatus(terminalCopyFailureStatus(err));
return;
};
self.setStatus("Terminal selection copied");
Expand All @@ -6551,7 +6567,7 @@ pub const App = struct {
const workspace = self.workspace orelse return;
const text = Api.readClipboard(self.window.hwnd, self.allocator) catch |err| {
std.log.warn("Unable to read Windows clipboard for terminal paste: {s}", .{@errorName(err)});
self.setStatus("Unable to paste clipboard text");
self.setStatus(terminalPasteFailureStatus(err));
return;
};
defer self.allocator.free(text);
Expand Down Expand Up @@ -10655,6 +10671,59 @@ test "terminal paste never prompts when the program asked for bracketed paste" {
try std.testing.expectEqualStrings("\x1b[200~line one\nline two\n\x1b[201~", item.bytes);
}

test "terminal paste refuses clipboard text above the size limit with a clear status and sends nothing" {
const Api = struct {
var oversize = false;

fn readClipboard(_: c.HWND, allocator: std.mem.Allocator) ![]u8 {
if (!oversize) return error.ClipboardTextTooLarge;
const text = try allocator.alloc(u8, 1_000_001);
@memset(text, 'a');
return text;
}

fn confirmMultiline(_: c.HWND, _: usize) bool {
return true;
}
};
const allocator = std.testing.allocator;
var workspace = try reopenTestWorkspace(allocator, "terminal-paste-too-large-layout.json");
defer workspace.deinit();
defer workspace.surfaces[0].surface = null;
workspace.surfaces[0].surface = @ptrFromInt(0x1000);
var app = try overviewTestApp(Dpi.base_dpi);
defer deinitOverviewTestApp(&app);
defer app.workspace = null;
app.workspace = &workspace;

for ([_]bool{ false, true }) |oversize| {
Api.oversize = oversize;
app.pasteClipboardTextWith(Api);
try std.testing.expectEqualStrings("Paste cancelled: clipboard text is larger than 1,000,000 bytes", app.status());
try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count);
}
}

test "a refused terminal copy says whether the user's previous clipboard text survived" {
try std.testing.expectEqualStrings(
"Unable to copy: the clipboard refused the text; its previous text was kept",
terminalCopyFailureStatus(error.ClipboardWriteFailedKeptPreviousText),
);
try std.testing.expectEqualStrings(
"Unable to copy: the clipboard refused the text and its previous text could not be restored",
terminalCopyFailureStatus(error.ClipboardWriteFailedLostPreviousText),
);
try std.testing.expectEqualStrings(
"Unable to copy: the clipboard is in use by another program",
terminalCopyFailureStatus(error.ClipboardOpenFailed),
);
try std.testing.expectEqualStrings(
"Unable to copy: the clipboard's current text could not be read, so it was left unchanged",
terminalCopyFailureStatus(error.ClipboardPreviousTextUnreadable),
);
try std.testing.expectEqualStrings("Unable to copy terminal selection", terminalCopyFailureStatus(error.ClipboardWriteFailed));
}

test "workspace cycle keyboard fallback never turns Alt paging into terminal tabs" {
try std.testing.expectEqual(InputRouter.Action.none, fallbackKeyAction(c.VK_NEXT, true, false, true));
try std.testing.expectEqual(InputRouter.Action.none, fallbackKeyAction(c.VK_PRIOR, true, false, true));
Expand Down Expand Up @@ -13689,6 +13758,60 @@ const KeyTableCase = struct {
expected: []const u8,
};

test "live terminal copy: a clipboard that refuses the text keeps the selection and reports it" {
var fixture: LiveTerminalFixture = undefined;
try fixture.init(&.{.{ .id = "loop-a" }});
defer fixture.deinit();
try fixture.setLive(&.{"loop-a"});
try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a");
try fixture.waitFor(LiveTerminalFixture.shows, "loop-a");
var keyboard = try LiveKeyboard.begin(&fixture, "loop-a");
defer keyboard.end();
const mouse = try LiveMouse.begin(&keyboard);
try feedMouseFixture(&fixture, keyboard.index);
mouse.drag(0, 1, 8, 1);
try mouse.expectSelected("GC-COPY-2");

const Refusing = struct {
var err: anyerror = error.ClipboardWriteFailed;

fn writeClipboard(_: c.HWND, _: std.mem.Allocator, _: []const u8) !void {
return err;
}
};
Refusing.err = error.ClipboardWriteFailedLostPreviousText;
fixture.app.copyTerminalSelectionWith(Refusing);
try std.testing.expectEqualStrings(
"Unable to copy: the clipboard refused the text and its previous text could not be restored",
fixture.app.status(),
);
// Nothing was copied, so the selection stays for another attempt.
try mouse.expectSelected("GC-COPY-2");
}

test "live terminal paste: the provider's own paste entry point puts nothing raw on the pty" {
var fixture: LiveTerminalFixture = undefined;
try fixture.init(&.{.{ .id = "loop-a" }});
defer fixture.deinit();
try fixture.setLive(&.{"loop-a"});
try clickSidebarLoopRow(&fixture.app, fixture.project, "loop-a");
try fixture.waitFor(LiveTerminalFixture.shows, "loop-a");
var keyboard = try LiveKeyboard.begin(&fixture, "loop-a");
defer keyboard.end();
const surface = fixture.workspace.surfaces[keyboard.index].surface.?;
var sent: std.ArrayListUnmanaged(u8) = .empty;
defer sent.deinit(std.testing.allocator);

// winghostty_surface_paste_text brackets unconditionally and hands the payload to on_paste
// as is, so the shell must not route that callback to the pty.
for ([_][]const u8{ "plain", "x\x1b[201~rm -rf /\r\n", "line one\r\nline two" }) |text| {
_ = c.winghostty_surface_paste_text(surface, text.ptr, @intCast(text.len), 1);
try keyboard.drainInput(&sent);
if (sent.items.len != 0) std.debug.print("provider paste reached the pty: {any}\n", .{sent.items});
try std.testing.expectEqualStrings("", sent.items);
}
}

test "live terminal keyboard: AltGr characters reach the shell and never run a shell shortcut" {
var fixture: LiveTerminalFixture = undefined;
try fixture.init(&.{.{ .id = "loop-a" }});
Expand Down
Loading
Loading